Skip to content

✨ Adopt exact QDMI 1.4 payload contracts across Core - #2226

Open
burgholzer wants to merge 1 commit into
codex/selected-payload-environmentfrom
codex/qdmi-v14-payload-contract
Open

✨ Adopt exact QDMI 1.4 payload contracts across Core#2226
burgholzer wants to merge 1 commit into
codex/selected-payload-environmentfrom
codex/qdmi-v14-payload-contract

Conversation

@burgholzer

@burgholzer burgholzer commented Aug 24, 2026

Copy link
Copy Markdown
Member

🤖 AI text below 🤖

Description

Adopt the exact payload contract from Munich-Quantum-Software-Stack/QDMI#508 and Munich-Quantum-Software-Stack/QDMI#509 across MQT Core producers and consumers.

This pull request:

  • pins QDMI ✨ Add multi-program jobs Munich-Quantum-Software-Stack/QDMI#509 at ff59f75ce18344122c5de265cccdd560df448325;
  • replaces the closed program-format enum with exact immutable descriptors and optional feature records;
  • routes one-program and multi-program submission through the atomic program-list API;
  • preserves indexed result ordering and arbitrary output bytes;
  • updates the bundled DDSIM and SC providers without removing the retained calibration-state query;
  • makes vendor Qiskit backends own vendor serialization and result decoding; and
  • aligns Qiskit and PennyLane result layouts with the QDMI contract.

This PR is stacked on #2219. Its direct child, #2227, adds the focused compiler TargetEnvironment bridge.

Validation on this exact layer:

  • full release build: passed;
  • QDMI, Qiskit, and PennyLane Python tests: 395/395 passed in one process;
  • stub generation: passed with no unexpected diff;
  • documentation build with warnings as errors: passed; and
  • full lint, including ty: passed.

The complete two-layer stack also passes all 4,073 configured CTest cases, with one expected SC job-ID skip.

AI assistance: OpenAI Codex assisted with implementation, review, testing, and this description.

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.

@burgholzer burgholzer added dependencies Pull requests that update a dependency file feature New feature or request c++ Anything related to C++ code python Anything related to Python code QDMI Anything related to QDMI labels Aug 24, 2026
@burgholzer burgholzer self-assigned this Aug 24, 2026
@burgholzer
burgholzer force-pushed the codex/qdmi-v14-payload-contract branch from ee2c0b4 to f19a838 Compare August 24, 2026 14:55
@burgholzer
burgholzer force-pushed the codex/qdmi-v14-payload-contract branch 2 times, most recently from b9a5363 to 31d6f2d Compare August 24, 2026 16:01
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

@burgholzer burgholzer added this to the QDMI Support milestone Aug 24, 2026
Preserve exact format descriptors, optional feature records, atomic program lists, indexed results, and raw output bytes across Core producers and framework consumers. Let vendor backends own exact serialization and result decoding, and map QDMI output slots at each framework boundary.

Assisted-by: GPT-5.6 Sol via Codex
@burgholzer
burgholzer force-pushed the codex/qdmi-v14-payload-contract branch from 31d6f2d to b7d1f2a Compare August 24, 2026 23:47

@burgholzer burgholzer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This one took quite a bit of time to review. and there are still several areas that I would like to see changed. However, I have the feeling this is going to be the last iteration on this before it is ready.

@denialhaag @simon1hofmann do you have any feedback on top of the above?

Comment on lines +73 to +85
[[nodiscard]] inline std::string
decodeText(std::string value, const std::string_view description) {
if (value.empty() || value.back() != '\0') {
throw std::invalid_argument(std::string(description) +
" is not null-terminated");
}
if (value.find('\0') != value.size() - 1U) {
throw std::invalid_argument(std::string(description) +
" contains an embedded null byte");
}
value.pop_back();
return value;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I am likely the one being too dense here, but it feels like this may be introducing off-by-one errors or false requirements.
an std::string is always null-terminated and IIRC the null terminator is not accounted in size(). I want to make absolutely sure that this doesn't introduce an architecture where strings are essentially <chars>\0\0 in memory.

Comment on lines +90 to +93
if (value.empty()) {
throw std::invalid_argument(std::string(description) +
" is not null-terminated");
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This error message feels wrong. are we sure this is useful, needed, and correct?

Comment on lines -704 to +719
std::string value(size - 1, '\0');
std::string value(size, '\0');
result = QDMI_device_query_device_property(device_.get(), prop, size,
value.data(), nullptr);
qdmi::throwIfError(result, msg);
return value;
return detail::decodeText(std::move(value), msg);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is another case where I would want to make absolutely sure this is correct and does not introduce off-by-one errors. There's more cases below.

Comment thread bindings/qdmi/qdmi.cpp
Comment on lines +171 to +180
job.def(
"get_results",
[](const qdmi::Job& self, const size_t programIndex,
const QDMI_Job_Result result) {
const auto value = self.getResults(programIndex, result);
return nb::bytes(reinterpret_cast<const char*>(value.data()),
value.size());
},
"program_index"_a, "result"_a,
"Returns one indexed result as exact bytes.");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

would it makes sense to flip the order of the arguments here so that the index could receive a default argument? Which would allow to simplify a few call sites.

Comment thread bindings/qdmi/qdmi.cpp
Comment on lines +272 to +273
job.def_prop_ro("programs_num", &qdmi::Job::getProgramsNum,
"The number of programs in the job.");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Related to another comment from a PR as part of this stack: should this be called num_programs to align better with the rest of the bindings and Python exposure. May also be worth to already rename that on the C++ side before binding to Python.

Comment on lines -834 to -842


# MQT Core owns the two OpenQASM formats and registers them through the same
# registry as everyone else, so the backend walks one ordered list of formats
# with no format-specific branch. `mqt.core.plugins.qiskit.__init__` imports this
# module whenever Qiskit is installed, so both formats are available as soon as
# the adapter is.
register_program_serializer(ProgramFormat.QASM3, _serialize_to_qasm3)
register_program_serializer(ProgramFormat.QASM2, _serialize_to_qasm2)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This changes how program serialization for Qiskit is handled from the 3.9.1 release. Make sure all documentation is updated accordingly and there are no remaining fragments of the old integration style.

self._capabilities = DeviceCapabilities(
qjit_compatible=False,
runtime_code_generation=False,
dynamic_qubit_management=False,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

feels like the capability management could be better here. dynamic qubit management is an explicit payload capability.
Carefully check for a suitable mapping between feature of the payload and how that maps to PennyLane.

Comment on lines +390 to +392
if len(dependencies) > 16:
msg = "A conditional depends on more than 16 measurements."
raise ValidationError(msg)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is this an artificial limitation or an actual PennyLane limitation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

QDMI v1.4 also introduces multi-program job submission.
Qiskit backend run natively supports passing QuantumCircuit | Sequence[QuantumCircuit]. the latter case can now be natively be handled by QDMI instead of in the QDMI backend code itself. This should clean up the implementation quite a bit.
With the planned changes to the DDSIM QDMI backend, this can also be properly tested.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I am not familiar enough with PennyLane to judge, but I would guess it also supports the submission of multiple circuits to a device as part of one job. This should now work natively with QDMI v1.4
Explore whether the device itself can be improved in that regard.

@simon1hofmann simon1hofmann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Went through the changes and have some comments that should be double-checked.

@@ -505,15 +613,16 @@
// Update the measurement counts.
++counts_[runtime.getMeasurements()];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getMeasurements() stores slot 0 first, but QDMI requires the highest slot first. Values [1,0,0] therefore produce "100" instead of "001".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

If this is true, then we may want to adjust the QIR runtime function itself so that it follows the same convention. We should generally try to stick to the same convention throughout MQT Core wherever we can; even if this means changes now.

Comment on lines 891 to 897
switch (result) {
case QDMI_JOB_RESULT_HIST_KEYS:
case QDMI_JOB_RESULT_HIST_VALUES:
if (numShots_ == 0) {
return QDMI_ERROR_INVALIDARGUMENT;
}
return getHistogram(result, size, data, sizeRet);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sampled jobs always expose histograms even when bool, integer, floating-point, or container outputs are omitted. QDMI requires QDMI_ERROR_NOTSUPPORTED when output cannot be represented losslessly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hm. This feels a bit wrong to me. Shouldn't it be up to getHistogram to report the error? Maybe I am misinterpreting this though?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes that should be reported by getHistogram, but that function currently never reports that error 🤔

🤖 AI text below 🤖
Yes, getHistogram is the better place to report this. My concern was about the current behavior, not that the check must live in this switch. getHistogram currently always succeeds, while counts_ only contains RESULT/RESULT_ARRAY bits. For AdaptiveRecordOutputs.ll, that omits the recorded integer and double values, so the three-bit histogram is only a partial representation. Under the pinned QDMI contract, the histogram queries must return QDMI_ERROR_NOTSUPPORTED in that case. Container records alone do not invalidate the histogram; that part of my wording was too broad.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hm. We may want to adjust the QDMI spec here. This feels overly restrictive. It may still make sense to export a histogram, even though there is further output in the proprietary results.
Good time to make changes to the open QDMI PRs so this becomes less of a hassle.

Comment on lines +529 to +537
capabilities = self._program_capabilities or set()
for capability, instruction, name in (
("forward-branching", IfElseOp, "if_else"),
("counted-iteration", ForLoopOp, "for_loop"),
("conditional-loop", WhileLoopOp, "while_loop"),
("multiway-branching", SwitchCaseOp, "switch_case"),
):
if capability in capabilities:
target.add_instruction(instruction, name=name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Circuits using measurement-driven branches and measured-qubit reuse are accepted without the separately required capabilities.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is that something that we can reasonably express in Qiskit? without going entirely out of our way checking the program?
This may become a little easier once we can actually wedge the compiler collection between the input program and the required output program.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Probably not..
Just noticed that for example in this test: test_mock_backend.py:547-566 mid-circuit-measurement should be required due to the circuit.measure(0, 0), but boolean-computation is not needed.

Comment on lines +873 to +883
pending = list(bound_circuit.data)
while pending:
instruction = pending.pop()
op_name = instruction.operation.name
for block in getattr(instruction.operation, "blocks", ()):
pending.extend(block.data)
if op_name in {"if_else", "for_loop", "while_loop", "switch_case"}:
if op_name not in self._target.operation_names:
msg = f"Unsupported control flow operation: '{op_name}'"
raise UnsupportedOperationError(msg)
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Circuits using measurement-driven branches and measured-qubit reuse are accepted without the separately required capabilities.

Comment on lines 639 to 648
std::ostringstream output;
runtime.setOstream(output);
if (const auto rc = jitSession.run(); rc != 0) {
throw std::runtime_error(
llvm::formatv("QIR program failed with error: {}", rc));
}
auto state = runtime.takeState();
dd_ = std::move(state.dd);
stateVecDD_ = state.edge;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

State extraction neither emits the required QIR header nor assigns programOutput_ (observed at :916-918).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

State extraction itself should likely not do this at all. But we should definitely be exposing the output stream from QIR through the new result type, which should already be captured by the QIR runner.

Comment on lines +72 to +79
[[nodiscard]] constexpr bool
isValidProgramFormat(const QDMI_Program_Format& format) noexcept {
return format.version != 0U && format.id[0] != '\0' &&
(format.encoding == QDMI_PROGRAM_ENCODING_TEXT ||
format.encoding == QDMI_PROGRAM_ENCODING_BINARY) &&
detail::isCanonicalFixedString(format.id) &&
detail::isCanonicalFixedString(format.profile);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It accepts binary OpenQASM, unsupported QIR profiles and unqualified vendor IDs, causing NOTSUPPORTED where QDMI requires INVALIDARGUMENT.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair enough. Validation could be more strict here.

-> QDMI_STATUS {
if ((data != nullptr && size == 0) ||
IS_INVALID_ARGUMENT(result, QDMI_JOB_RESULT)) {
IS_INVALID_ARGUMENT(result, QDMI_JOB_RESULT) || programIndex != 0U) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Index 1 on a completed single-program job returns INVALIDARGUMENT; QDMI requires OUTOFRANGE.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is true, but the actual implementation will likely change a bit based on the other review comments. It is still good to make use of OUTOFRANGE for out of range access to the result list.

Comment on lines 636 to 652
auto QDMI_Session_impl_d::setParameter(QDMI_Session_Parameter param,
const size_t size,
const void* value) const -> int {
if ((value != nullptr && size == 0) || param >= QDMI_SESSION_PARAMETER_MAX) {
return QDMI_ERROR_INVALIDARGUMENT;
}
if (status_ != qdmi::SessionStatus::ALLOCATED) {
return QDMI_ERROR_BADSTATE;
}
return QDMI_ERROR_NOTSUPPORTED;
}

auto QDMI_Session_impl_d::querySessionProperty(QDMI_Session_Property prop,
size_t size, void* value,
size_t* sizeRet) const -> int {
if ((value != nullptr && size == 0) || prop >= QDMI_SESSION_PROPERTY_MAX) {
return QDMI_ERROR_INVALIDARGUMENT;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The >= *_MAX checks reject QDMI’s valid custom range 999999995..INT32_MAX instead of returning NOTSUPPORTED.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I believe this is fixed in a later PR down the line in this PR stack. Might be worth pulling the changes in here already.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

After checking again, this might not actually be fixed yet. And it definitely should be fixed. Here would be a good place to do so.

Comment on lines 82 to 101
@overload
def get_custom_result(self, custom_property: CustomProperty, value_type: type[str]) -> str | None: ...
@overload
def get_custom_result(self, custom_property: CustomProperty, value_type: type[bool]) -> bool | None: ...
@overload
def get_custom_result(self, custom_property: CustomProperty, value_type: type[int]) -> int | None: ...
@overload
def get_custom_result(self, custom_property: CustomProperty, value_type: type[float]) -> float | None: ...
@overload
def get_custom_result(self, custom_property: CustomProperty, value_type: type[bytes]) -> bytes | None: ...
@overload
def get_custom_result(
self, custom_property: CustomProperty, value_type: type[str | bool | int | float | bytes]
) -> str | bool | int | float | bytes | None:
"""Return an implementation-defined custom job result.

The caller must provide the type documented by the device implementation.
Use ``bytes`` to retrieve the value without interpretation. Returns ``None``
when the custom slot is unsupported.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The runtime binding accepts program_index: int = 0, but every overload omits it, so valid multi-program calls fail static type checking.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is definitely an oversight and needs to be fixed either in the bindings themselves or in the pattern file.

@burgholzer
burgholzer marked this pull request as ready for review August 25, 2026 12:20

@denialhaag denialhaag 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.

Some additional comments from my side, none of which I'm super sure about myself:

Comment thread docs/conf.py


cpp_api_tagfile = ("_build/doxygen/mqt-core.tag", "cpp/", "_build/doxygen/xml")
_qdmi_api_base = "https://munich-quantum-software-stack.github.io/QDMI/pr-preview/pr-509/"

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.

Just noting that this likely needs to be changed before merging.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah. It needs to at least point to a merged version on the develop branch.
Before the eventual v4 release it needs to be pinned to a released version (the v1.4.0 tag).

Comment thread docs/Doxyfile
# run, you must also specify the path to the tagfile here.

TAGFILES = _build/qdmi.tag=https://munich-quantum-software-stack.github.io/QDMI/v1.3.2/
TAGFILES = _build/qdmi.tag=https://munich-quantum-software-stack.github.io/QDMI/pr-preview/pr-509/

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.

Same here.

Comment thread include/mqt-core/qdmi/ProgramFormat.hpp
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Important

Approval pending

CodeRabbit has no unresolved comments, but it has not reviewed the latest commit.

Use the checkbox below to review the latest commit. CodeRabbit will approve the changes if it finds no blocking issues.

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Anything related to C++ code dependencies Pull requests that update a dependency file feature New feature or request python Anything related to Python code QDMI Anything related to QDMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants