From 29d17ced47b8c5cd33d8d19c39c662bf82708d68 Mon Sep 17 00:00:00 2001 From: Lukas Burgholzer Date: Fri, 4 Sep 2026 11:15:49 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Adopt=20QDMI=20program-capability?= =?UTF-8?q?=20descriptors=20independently?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain the design-gated exact-format and optional-feature prototype with its mechanical SDK consumers. Extract native multi-program jobs and indexed results into their own enum-based workstream; preserve current optional shots, binary transport, target inference, and simulator concurrency. Assisted-by: GPT-5.6 Sol via Codex --- .agent/plans/qdmi-v14-payload-contract.md | 52 +++ CHANGELOG.md | 3 + UPGRADING.md | 60 +++ bindings/qdmi/qdmi.cpp | 298 +++++++++++--- cmake/ExternalDependencies.cmake | 6 +- docs/Doxyfile | 2 +- docs/conf.py | 5 +- docs/qdmi/ddsim_device.md | 2 +- docs/qdmi/qdmi_backend.md | 65 +-- docs/qdmi/slurm.md | 2 +- include/mqt-core/qdmi/Client.hpp | 140 +++---- include/mqt-core/qdmi/ProgramFormat.hpp | 88 ++++ include/mqt-core/qdmi/common/Common.hpp | 38 +- include/mqt-core/qdmi/devices/dd/Device.hpp | 30 +- include/mqt-core/qdmi/driver/Driver.hpp | 7 + .../mqt/core/plugins/pennylane/converter.py | 102 ++++- python/mqt/core/plugins/pennylane/device.py | 134 ++++-- python/mqt/core/plugins/qiskit/__init__.py | 10 - python/mqt/core/plugins/qiskit/backend.py | 175 +++++--- python/mqt/core/plugins/qiskit/job.py | 2 +- python/mqt/core/plugins/qiskit/provider.py | 4 +- python/mqt/core/plugins/qiskit/serializers.py | 383 ------------------ python/mqt/core/qdmi/__init__.pyi | 119 ++++-- src/qdmi/Client.cpp | 152 ++++--- src/qdmi/devices/dd/Device.cpp | 191 +++++++-- src/qdmi/devices/sc/Device.cpp | 21 + src/qdmi/driver/Driver.cpp | 30 +- test/python/plugins/qdmi_pennylane/helpers.py | 20 +- .../plugins/qdmi_pennylane/test_converter.py | 115 +++++- .../plugins/qdmi_pennylane/test_device.py | 74 +++- .../plugins/qiskit/test_mock_backend.py | 337 ++++++++------- .../python/plugins/qiskit/test_serializers.py | 321 --------------- test/python/qdmi/test_qdmi.py | 174 ++++---- test/qdmi/devices/dd/concurrency_test.cpp | 12 +- .../devices/dd/device_properties_test.cpp | 62 ++- test/qdmi/devices/dd/device_status_test.cpp | 2 +- test/qdmi/devices/dd/error_handling_test.cpp | 20 +- test/qdmi/devices/dd/helpers/test_utils.cpp | 22 +- test/qdmi/devices/dd/helpers/test_utils.hpp | 42 ++ test/qdmi/devices/dd/job_lifecycle_test.cpp | 14 +- test/qdmi/devices/dd/job_parameters_test.cpp | 56 +-- .../devices/dd/results_probabilities_test.cpp | 4 +- .../qdmi/devices/dd/results_sampling_test.cpp | 68 +++- .../devices/dd/results_statevector_test.cpp | 24 +- test/qdmi/devices/sc/test_device.cpp | 42 +- test/qdmi/driver/session_device.cpp | 159 ++++++-- test/qdmi/driver/test_driver.cpp | 61 ++- test/qdmi/test_client.cpp | 231 +++++------ test/slurm/bell_job.py | 2 +- 49 files changed, 2253 insertions(+), 1730 deletions(-) create mode 100644 .agent/plans/qdmi-v14-payload-contract.md create mode 100644 include/mqt-core/qdmi/ProgramFormat.hpp delete mode 100644 python/mqt/core/plugins/qiskit/serializers.py delete mode 100644 test/python/plugins/qiskit/test_serializers.py diff --git a/.agent/plans/qdmi-v14-payload-contract.md b/.agent/plans/qdmi-v14-payload-contract.md new file mode 100644 index 0000000000..edbc181969 --- /dev/null +++ b/.agent/plans/qdmi-v14-payload-contract.md @@ -0,0 +1,52 @@ +# Independent QDMI program-capability prototype + +Status: independent rebase; design decisions remain gated. + +## Scope and dependencies + +Core #2226 adopts the experimental format descriptors and optional execution +feature query from QDMI #508. It is a non-blocking Core 4.1 / QDMI 1.4 +candidate, not Core 4.0 scope. Core #2365 and QDMI #523 must settle the contract +before implementation is merge-ready. + +This runtime layer has no compiler-only #2219 ancestry, no driver replacement, +and no metadata-removal dependency. Native multi-program jobs were extracted to +Core #2362 and QDMI #509 with the existing program-format enum. This branch +retains single-program setters and unindexed results against QDMI #508. The +compiler/runtime integration layer remains in Core PR `#2227`. + +## Preserved behavior and prototype boundaries + +Retain optional shots, byte-exact binary transport, current DDSIM QCO-backed +simulation, session ownership, concurrent job behavior and target inference. +Unknown topology or gate sets still fail early; simulator controlled-operation +families and zero-arity global phase remain unchanged. + +Format descriptors, optional feature records and text/result framing retain the +existing prototype semantics for evaluation. They are not a final answer to +format identity versus execution capabilities, supported versus native +operations, classical guarantees, opaque programs or provider-neutral verbatim +execution. Calibration status remains distinct from program vocabulary. + +Keep mechanical SDK adaptations here because the same package must still import +and use the descriptor-valued runtime. Backend-owned serializers and decoders +remain part of that prototype. Core issues `#2363` and `#2364` track native SDK +batching separately. Do not replace concurrent single submissions with synthetic +aggregate jobs. + +## Validation and release gate + +Build independently against QDMI #508. Test descriptor validation, optional +feature metadata, text and binary submission/retrieval, optional shots, SDK +serialization/layout, asynchronous failure and concurrency. Preserve newer +mainline tests. Run stubs, repository lint and C++ lint. Check both bundled +devices and the compiler's existing device-to-target adapter. + +Local validation passed 3,874 native tests with one existing skip and 399 Python +QDMI/SDK tests. Generated stubs, repository lint and C++ lint passed. Hosted CI +and contract design review remain separate gates. + +Use the design trackers to record any contract change rather than silently +stabilizing one during a rebase. Published artifacts require released pins. +Preserve existing PR identity, attribution and review history; no archives or +automatic review requests. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2cc9f384..e4208c998d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,9 @@ releases may include breaking changes. - ✨ Expose ordered shots from DDSIM QDMI OpenQASM and QIR jobs, with matching histograms ([#2368]) ([**@burgholzer**]) +- ✨ Add backend-owned program serialization and result decoding to QDMI Qiskit + backends. [QDMI-on-IQM] now provides the IQM JSON integration and `MoveGate` + ([#2114], [#2226]) ([**@marcelwa**], [**@burgholzer**]) - 🐳 Add dev container configuration for a consistent local development environment ([#1786]) ([**@denialhaag**]) diff --git a/UPGRADING.md b/UPGRADING.md index 2d05eae44f..66c8a8357f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -6,6 +6,20 @@ of changes including minor and patch releases, please refer to the ## [Unreleased] +### QDMI program capabilities + +Program formats are exact descriptors rather than enum members. Use +`ProgramFormat.OPENQASM3`, for example, or construct a vendor-namespaced +descriptor with its version, profile and text/binary encoding. A format's +optional execution features are queried separately. Unknown metadata is distinct +from a successful empty feature list. + +Single-program submission still accepts `str` or exact `bytes`, and omitting the +shot count still leaves it to the device. Native multi-program submission is a +separate API workstream and is not introduced by this capability change. The +calibration and batch pseudo-formats are no longer payload descriptors; +`submit_calibration_job` is removed, while calibration status remains available. + ### Removal of the classic circuit representation MQT Core 4 removes the complete classic circuit surface. This includes the C++ @@ -151,6 +165,52 @@ The Python bindings depend on `nanobind-backend`, which supplies the interpreter-specific nanobind runtime. This dependency does not change the C++ API or the Python import paths. +### Program serialization for QDMI Qiskit backends + +`QDMIBackend` now serializes only the exact OpenQASM 3 and OpenQASM 2 formats. +The backend tries supported formats in the order reported by the device. MQT +Core no longer provides a global serializer registry or loads serializers from +the `mqt.core.qiskit.program_serializers` entry point group. + +A package that owns a vendor format must also own the backend that serializes +and decodes it. Override the two protected hooks: + +```python +class MyBackend(QDMIBackend): + def _program_serializer(self, program_format): + if program_format == IQM_JSON: + return qiskit_to_iqm_json + return super()._program_serializer(program_format) + + def _decode_counts(self, job): + if self.payload_descriptor == IQM_JSON: + return decode_iqm_counts(job) + return super()._decode_counts(job) +``` + +A serializer takes the circuit and the backend. It returns `str` for a text +format and `bytes` for a binary format. The backend checks the returned type +against the exact format. + +A backend subclass that must represent a device-native operation outside +Qiskit's standard gate library sets `_EXTRA_GATES`: + +```python +class MyBackend(QDMIBackend): + _EXTRA_GATES = {"move": MoveGate()} +``` + +MQT Core no longer provides `qiskit_to_iqm_json` or `MoveGate`. +[QDMI-on-IQM](https://github.com/iqm-finland/QDMI-on-IQM) owns both. Import them +from `iqm.qdmi` instead: + +```python +from iqm.qdmi.serializers import qiskit_to_iqm_json +from iqm.qdmi.gates import MoveGate +``` + +Use the `IQMBackend` from `iqm-qdmi` to submit IQM JSON and decode IQM results. + ### Removal of DD approximation and density-matrix support MQT Core no longer provides the decision-diagram approximation algorithm. The diff --git a/bindings/qdmi/qdmi.cpp b/bindings/qdmi/qdmi.cpp index 1339bd6b06..765b2df385 100644 --- a/bindings/qdmi/qdmi.cpp +++ b/bindings/qdmi/qdmi.cpp @@ -9,28 +9,37 @@ */ #include "qdmi/Client.hpp" +#include "qdmi/ProgramFormat.hpp" #include "qdmi/driver/Driver.hpp" #include "qdmi/driver/SessionConfig.hpp" #include #include -#include // NOLINT(misc-include-cleaner) -#include // NOLINT(misc-include-cleaner) -#include // NOLINT(misc-include-cleaner) -#include // NOLINT(misc-include-cleaner) -#include // NOLINT(misc-include-cleaner) -#include // NOLINT(misc-include-cleaner) -#include // NOLINT(misc-include-cleaner) -#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) +#include // NOLINT(misc-include-cleaner) #include +#include #include +#include +#include #include +#include +#include #include #include #include +#include +#include #include -#include #include namespace mqt { @@ -43,6 +52,56 @@ void registerSlurm(nb::module_& qdmiModule); } namespace { +void copyProgramFormatField(char (&destination)[64], + const std::string_view value, + const std::string_view field) { + if (value.size() >= std::size(destination) || + value.find('\0') != std::string_view::npos) { + throw nb::value_error( + (std::string(field) + " must contain fewer than 64 non-NUL bytes") + .c_str()); + } + std::ranges::copy(value, std::span{destination}.begin()); +} + +template +[[nodiscard]] std::string_view decodeFixedField(const char (&value)[N], + const std::string_view field) { + if (!qdmi::detail::isCanonicalFixedString(value)) { + throw nb::value_error( + (std::string(field) + " is not a canonical fixed string").c_str()); + } + const auto begin = std::cbegin(value); + const auto terminator = std::find(begin, std::cend(value), '\0'); + return {begin, static_cast(terminator - begin)}; +} + +QDMI_Program_Format makeProgramFormat(const std::string_view id, + const uint32_t major, + const uint32_t minor, + const uint32_t patch, + const std::string_view profile, + const QDMI_Program_Encoding encoding) { + if (id.empty()) { + throw nb::value_error("id must not be empty"); + } + if (major > 0x3FFU || minor > 0x3FFU || patch > 0xFFFU) { + throw nb::value_error("version components exceed the QDMI packed range"); + } + if (major == 0U && minor == 0U && patch == 0U) { + throw nb::value_error("version must not be zero"); + } + QDMI_Program_Format format{ + .version = QDMI_MAKE_VERSION(major, minor, patch), + .encoding = static_cast(encoding), + .id = {}, + .profile = {}, + }; + copyProgramFormatField(format.id, id, "id"); + copyProgramFormatField(format.profile, profile, "profile"); + return format; +} + template [[nodiscard]] nb::object queryCustomValue(Query query, const nb::handle valueType) { @@ -114,6 +173,24 @@ NB_MODULE(MQT_CORE_MODULE_NAME, qdmiModule) { nb::call_guard(), "Returns the measurement counts from the job."); + job.def( + "get_results", + [](const qdmi::Job& self, const QDMI_Job_Result result) { + const auto value = self.getResults(result); + return nb::bytes(reinterpret_cast(value.data()), + value.size()); + }, + "result"_a, "Returns one result as exact bytes."); + + job.def( + "get_program_output", + [](const qdmi::Job& self) { + const auto output = self.getProgramOutput(); + return nb::bytes(reinterpret_cast(output.data()), + output.size()); + }, + "Returns the exact format-defined program output bytes."); + job.def("get_dense_statevector", &qdmi::Job::getDenseStateVector, "Returns the dense statevector from the job (typically only " "available from simulator devices)."); @@ -164,7 +241,8 @@ when the custom slot is unsupported.)pb"); "custom_property"_a, "value_type"_a, nb::sig("def get_custom_result(self, custom_property: CustomProperty, " "value_type: type[str] | type[bool] | type[int] | type[float] | " - "type[bytes]) -> str | bool | int | float | bytes | None"), + "type[bytes]) -> str | bool | int | " + "float | bytes | None"), R"pb(Return an implementation-defined custom job result. The caller must provide the type documented by the device implementation. @@ -208,33 +286,149 @@ when the custom slot is unsupported.)pb"); .value("CANCELED", QDMI_JOB_STATUS_CANCELED) .value("FAILED", QDMI_JOB_STATUS_FAILED); - // ProgramFormat enum - nb::enum_(qdmiModule, "ProgramFormat", - "Enumeration of program formats.") - .value("QASM2", QDMI_PROGRAM_FORMAT_QASM2) - .value("QASM3", QDMI_PROGRAM_FORMAT_QASM3) - .value("QIR_BASE_STRING", QDMI_PROGRAM_FORMAT_QIRBASESTRING) - .value("QIR_BASE_MODULE", QDMI_PROGRAM_FORMAT_QIRBASEMODULE) - .value("QIR_ADAPTIVE_STRING", QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING) - .value("QIR_ADAPTIVE_MODULE", QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE) - .value("CALIBRATION", QDMI_PROGRAM_FORMAT_CALIBRATION) - .value("QPY", QDMI_PROGRAM_FORMAT_QPY) - .value("IQM_JSON", QDMI_PROGRAM_FORMAT_IQMJSON) - .value("BATCH_JOB", QDMI_PROGRAM_FORMAT_BATCHJOB) - .value("CUSTOM1", QDMI_PROGRAM_FORMAT_CUSTOM1) - .value("CUSTOM2", QDMI_PROGRAM_FORMAT_CUSTOM2) - .value("CUSTOM3", QDMI_PROGRAM_FORMAT_CUSTOM3) - .value("CUSTOM4", QDMI_PROGRAM_FORMAT_CUSTOM4) - .value("CUSTOM5", QDMI_PROGRAM_FORMAT_CUSTOM5); + nb::enum_(job, "Result", "One raw job result format.") + .value("SHOTS", QDMI_JOB_RESULT_SHOTS) + .value("HIST_KEYS", QDMI_JOB_RESULT_HIST_KEYS) + .value("HIST_VALUES", QDMI_JOB_RESULT_HIST_VALUES) + .value("STATEVECTOR_DENSE", QDMI_JOB_RESULT_STATEVECTOR_DENSE) + .value("PROBABILITIES_DENSE", QDMI_JOB_RESULT_PROBABILITIES_DENSE) + .value("STATEVECTOR_SPARSE_KEYS", QDMI_JOB_RESULT_STATEVECTOR_SPARSE_KEYS) + .value("STATEVECTOR_SPARSE_VALUES", + QDMI_JOB_RESULT_STATEVECTOR_SPARSE_VALUES) + .value("PROBABILITIES_SPARSE_KEYS", + QDMI_JOB_RESULT_PROBABILITIES_SPARSE_KEYS) + .value("PROBABILITIES_SPARSE_VALUES", + QDMI_JOB_RESULT_PROBABILITIES_SPARSE_VALUES) + .value("PROGRAM_OUTPUT", QDMI_JOB_RESULT_PROGRAMOUTPUT); + + nb::enum_(qdmiModule, "ProgramEncoding", + "Program payload encoding.") + .value("TEXT", QDMI_PROGRAM_ENCODING_TEXT) + .value("BINARY", QDMI_PROGRAM_ENCODING_BINARY); + + auto programFormat = nb::class_( + qdmiModule, "ProgramFormat", + "The exact format, version, profile, and encoding of a payload."); + programFormat + .def( + "__init__", + [](QDMI_Program_Format* self, const std::string_view id, + const std::tuple& version, + const std::string_view profile, + const QDMI_Program_Encoding encoding) { + new (self) QDMI_Program_Format(makeProgramFormat( + id, std::get<0>(version), std::get<1>(version), + std::get<2>(version), profile, encoding)); + }, + "format_id"_a, "version"_a, "profile"_a = "", + "encoding"_a = QDMI_PROGRAM_ENCODING_TEXT) + .def_prop_ro("format_id", + [](const QDMI_Program_Format& self) { + return std::string( + decodeFixedField(self.id, "program format ID")); + }) + .def_prop_ro("version", + [](const QDMI_Program_Format& self) { + return std::tuple{QDMI_VERSION_MAJOR(self.version), + QDMI_VERSION_MINOR(self.version), + QDMI_VERSION_PATCH(self.version)}; + }) + .def_prop_ro("profile", + [](const QDMI_Program_Format& self) { + return std::string( + decodeFixedField(self.profile, "program profile")); + }) + .def_prop_ro("encoding", + [](const QDMI_Program_Format& self) { + return static_cast(self.encoding); + }) + .def( + "__eq__", + [](const QDMI_Program_Format& self, const nb::handle other) { + return nb::isinstance(other) && + qdmi::equal(self, nb::cast(other)); + }, + nb::sig("def __eq__(self, arg: object, /) -> bool")) + .def("__hash__", [](const QDMI_Program_Format& self) { + size_t hash = std::hash{}( + decodeFixedField(self.id, "program format ID")); + hash ^= static_cast(self.version) << 1U; + hash ^= static_cast(self.encoding) << 3U; + hash ^= std::hash{}( + decodeFixedField(self.profile, "program profile")) + << 5U; + return hash; + }); + programFormat + .def_prop_ro_static( + "OPENQASM2", [](nb::handle) { return qdmi::OPENQASM2; }, + "The canonical OpenQASM 2.0 text format.") + .def_prop_ro_static( + "OPENQASM3", [](nb::handle) { return qdmi::OPENQASM3; }, + "The canonical OpenQASM 3.0 text format.") + .def_prop_ro_static( + "QIR21_BASE_TEXT", [](nb::handle) { return qdmi::QIR21_BASE_TEXT; }, + "The canonical QIR 2.1 Base Profile text format.") + .def_prop_ro_static( + "QIR21_BASE_BINARY", + [](nb::handle) { return qdmi::QIR21_BASE_BINARY; }, + "The canonical QIR 2.1 Base Profile binary format.") + .def_prop_ro_static( + "QIR21_ADAPTIVE_TEXT", + [](nb::handle) { return qdmi::QIR21_ADAPTIVE_TEXT; }, + "The canonical QIR 2.1 Adaptive Profile text format.") + .def_prop_ro_static( + "QIR21_ADAPTIVE_BINARY", + [](nb::handle) { return qdmi::QIR21_ADAPTIVE_BINARY; }, + "The canonical QIR 2.1 Adaptive Profile binary " + "format."); + + nb::class_( + qdmiModule, "ProgramFeature", + "One exact feature or constraint record for a program format.") + .def_prop_ro("id", + [](const QDMI_Program_Feature& self) { + return std::string( + decodeFixedField(self.id, "program feature ID")); + }) + .def_ro("value", &QDMI_Program_Feature::value) + .def_prop_ro("constraint_id", + [](const QDMI_Program_Feature& self) { + return std::string(decodeFixedField( + self.constraint_id, "program constraint ID")); + }) + .def_ro("constraint_value", &QDMI_Program_Feature::constraint_value) + .def( + "__eq__", + [](const QDMI_Program_Feature& self, const nb::handle other) { + if (!nb::isinstance(other)) { + return false; + } + const auto value = nb::cast(other); + return std::ranges::equal(self.id, value.id) && + self.value == value.value && + std::ranges::equal(self.constraint_id, + value.constraint_id) && + self.constraint_value == value.constraint_value; + }, + nb::sig("def __eq__(self, arg: object, /) -> bool")) + .def("__hash__", [](const QDMI_Program_Feature& self) { + size_t hash = std::hash{}( + decodeFixedField(self.id, "program feature ID")); + hash ^= static_cast(self.value) << 1U; + hash ^= std::hash{}(decodeFixedField( + self.constraint_id, "program constraint ID")) + << 3U; + hash ^= static_cast(self.constraint_value) << 5U; + return hash; + }); qdmiModule.def("is_binary_program_format", &qdmi::isBinaryProgramFormat, "program_format"_a, R"pb(Returns whether a program format carries a binary payload. -``QIR_BASE_MODULE``, ``QIR_ADAPTIVE_MODULE``, and ``QPY`` hold bitcode or -another serialized object. Such a payload may contain a null byte and is not -text, so the device must receive it as exact bytes. Pass ``bytes`` to -:meth:`Device.submit_job` for these formats and ``str`` for the others. +Binary payloads may contain null bytes. Pass ``bytes`` to +:meth:`Device.submit_job` for binary descriptors and ``str`` for text. Args: program_format: The program format to classify. @@ -323,6 +517,11 @@ text, so the device must receive it as exact bytes. Pass ``bytes`` to &qdmi::Device::getSupportedProgramFormats, "Returns the list of program formats supported by the device."); + device.def("try_program_features", &qdmi::Device::tryGetProgramFeatures, + "program_format"_a, + "Returns the complete optional capability list for an exact " + "payload, or None when the metadata is unknown."); + device.def("child_devices", &qdmi::Device::getChildDevices, "Returns the direct child devices managed by this device."); @@ -401,41 +600,6 @@ when the custom slot is unsupported.)pb"); "custom5"_a = nb::none(), nb::rv_policy::reference_internal, "Submits an exact byte payload to the device."); - device.def( - "submit_calibration_job", - [](const qdmi::Device& self, - const std::optional>& program, - const std::optional& custom1, - const std::optional& custom2, - const std::optional& custom3, - const std::optional& custom4, - const std::optional& custom5) { - if (!program.has_value()) { - return self.submitCalibrationJob(std::nullopt, custom1, custom2, - custom3, custom4, custom5); - } - if (const auto* text = std::get_if(&*program); - text != nullptr) { - return self.submitCalibrationJob(*text, custom1, custom2, custom3, - custom4, custom5); - } - const auto& payload = std::get(*program); - const auto bytes = std::span{ - static_cast(payload.data()), payload.size()}; - return self.submitCalibrationJob(bytes, custom1, custom2, custom3, - custom4, custom5); - }, - "program"_a = nb::none(), nb::kw_only(), "custom1"_a = nb::none(), - "custom2"_a = nb::none(), "custom3"_a = nb::none(), - "custom4"_a = nb::none(), "custom5"_a = nb::none(), - nb::rv_policy::reference_internal, - R"pb(Triggers a calibration run on the device. - -QDMI does not require a program for a calibration run, so ``program`` is -optional and may be a string or bytes. When it is given, the device defines -what it means, which is usually a configuration for the run. A calibration run -executes no circuit, so it takes no shot count.)pb"); - device.def( "retrieve_job_by_id", [](const qdmi::Device& self, const std::string& jobId) { diff --git a/cmake/ExternalDependencies.cmake b/cmake/ExternalDependencies.cmake index a1d267880f..c3f156ff77 100644 --- a/cmake/ExternalDependencies.cmake +++ b/cmake/ExternalDependencies.cmake @@ -67,11 +67,11 @@ if(BUILD_MQT_CORE_TESTS) endif() # cmake-format: off -set(QDMI_MINIMUM_VERSION 1.3.3 +set(QDMI_MINIMUM_VERSION 1.4.0 CACHE STRING "Minimum QDMI version") -set(QDMI_VERSION 1.3.3 +set(QDMI_VERSION 1.4.0 CACHE STRING "QDMI version") -set(QDMI_REV "18cfb67fd9042761d3005c2f8655751c1758f9c5" # v1.3.3 +set(QDMI_REV "91955f3e878890e8038005929bd75024e8216ba4" CACHE STRING "QDMI identifier (tag, branch or commit hash)") set(QDMI_REPO_OWNER "Munich-Quantum-Software-Stack" CACHE STRING "QDMI repository owner (change when using a fork)") diff --git a/docs/Doxyfile b/docs/Doxyfile index e3ec51ebf9..671976d117 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -2101,7 +2101,7 @@ SKIP_FUNCTION_MACROS = YES # the path). If a tag file is not located in the directory in which Doxygen is # 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-508/ # When a file name is specified after GENERATE_TAGFILE, Doxygen will create a # tag file that is based on the input files it reads. See section "Linking to diff --git a/docs/conf.py b/docs/conf.py index 8743d4ba7c..894e37db34 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -160,6 +160,7 @@ def format_url(self, _e: Entry) -> HRef: # ruff:ignore[no-self-use] ("py:class", r"Annotated\[numpy\.typing\.NDArray\[numpy\.complex128\], \{'shape': \(.*\)\}\]"), ("py:class", r"Ellipsis"), ("py:class", r"ParametersType"), + ("py:class", r"pennylane\.devices\.DeviceCapabilities"), ("py:class", r"pennylane\.tape\.QuantumScriptOrBatch"), ("py:class", r"pennylane\.transforms\.core\.CompilePipeline"), ("py:class", r"pennylane\.typing\.(Result|ResultBatch)"), @@ -176,10 +177,12 @@ def format_url(self, _e: Entry) -> HRef: # ruff:ignore[no-self-use] 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-508/" qdmi_api_tagfile = ( "_build/qdmi.tag", - "https://munich-quantum-software-stack.github.io/QDMI/v1.3.3/", + _qdmi_api_base, ) +qdmi_api_tagfile_url = f"{_qdmi_api_base}qdmi.tag" # -- Options for HTML output ------------------------------------------------- diff --git a/docs/qdmi/ddsim_device.md b/docs/qdmi/ddsim_device.md index 2c598ed26d..7ede078022 100644 --- a/docs/qdmi/ddsim_device.md +++ b/docs/qdmi/ddsim_device.md @@ -72,7 +72,7 @@ program = compile_program( job = device.submit_job( program.to_bitcode(), - ProgramFormat.QIR_BASE_MODULE, + ProgramFormat.QIR21_BASE_BINARY, num_shots=1024, custom1=7, ) diff --git a/docs/qdmi/qdmi_backend.md b/docs/qdmi/qdmi_backend.md index 8129026a8f..fc682813f1 100644 --- a/docs/qdmi/qdmi_backend.md +++ b/docs/qdmi/qdmi_backend.md @@ -410,17 +410,17 @@ When you run a circuit, the backend: 1. Validates the circuit (checks for unbound parameters, supported operations, valid options) -2. Serializes the circuit into one of the program formats supported by the - target device, through the program serializer registered for that format +2. Serializes the circuit into the first supported format that the backend owns 3. Submits the program to the QDMI device via `device.submit_job()` 4. Returns a {py:class}`~mqt.core.plugins.qiskit.job.QDMIJob` ### Program Serializers -A _program serializer_ turns one circuit into one program in one program format. -MQT Core provides the serializers for OpenQASM 2 and OpenQASM 3. Every other -format belongs to the package that owns the device, which registers its -serializer through the same registry. +A _program serializer_ turns one circuit into one program in one exact program +format. `QDMIBackend` provides serializers for OpenQASM 3 and OpenQASM 2. It +tries formats in the order reported by the device. A subclass can own a vendor +format by overriding `_program_serializer` and delegating other formats to the +base implementation. A format fixes the kind of payload it carries, so there are two signatures. A text format takes a serializer that returns `str`: @@ -443,50 +443,21 @@ A serializer reads the device through operations through {py:attr}`~mqt.core.plugins.qiskit.backend.QDMIBackend.target`. -A package advertises its serializers through the -`mqt.core.qiskit.program_serializers` entry point group. The entry point name is -the {py:class}`~mqt.core.qdmi.ProgramFormat` member name: - -```toml -[project.entry-points."mqt.core.qiskit.program_serializers"] -IQM_JSON = "iqm.qdmi.serializers:qiskit_to_iqm_json" -``` - -{py:func}`~mqt.core.plugins.qiskit.serializers.register_program_serializer` does -the same at run time: - ```python -from mqt.core.plugins.qiskit import register_program_serializer -from mqt.core.qdmi import ProgramFormat - -register_program_serializer(ProgramFormat.IQM_JSON, qiskit_to_iqm_json) -``` - -Pass `replace=True` to take over a format that already has a serializer, -including OpenQASM 2 and OpenQASM 3. - -A device usually accepts several formats. The backend walks them in the order of -{py:data}`~mqt.core.plugins.qiskit.serializers.PROGRAM_FORMAT_PREFERENCE` and -uses the first one that has a serializer, so the order of the list decides and -not the order the device reports: - -```text -IQM_JSON, CUSTOM1 ... CUSTOM5, -QIR_ADAPTIVE_MODULE, QIR_ADAPTIVE_STRING, -QPY, QASM3, -QIR_BASE_MODULE, QIR_BASE_STRING, -QASM2 +class VendorBackend(QDMIBackend): + def _program_serializer(self, program_format): + if program_format == VENDOR_FORMAT: + return serialize_vendor_program + return super()._program_serializer(program_format) + + def _decode_counts(self, job): + if self.payload_descriptor == VENDOR_FORMAT: + return decode_vendor_counts(job) + return super()._decode_counts(job) ``` -A device-native format comes first, because a package that registers a -serializer for its own device's format wants that format used. 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, because it decides how the program travels rather than what -it may say. `CALIBRATION` and `BATCH_JOB` are absent because a serialized -circuit is not what they carry. +The same backend owns vendor result decoding through `_decode_counts`. The base +implementation uses the standard QDMI counts result. ### Device Introspection diff --git a/docs/qdmi/slurm.md b/docs/qdmi/slurm.md index 7cb690b0a3..929a9d83c9 100644 --- a/docs/qdmi/slurm.md +++ b/docs/qdmi/slurm.md @@ -131,7 +131,7 @@ measure q -> c; """ device = slurm.open_device_from_license() -job = device.submit_job(program, ProgramFormat.QASM2, num_shots=256) +job = device.submit_job(program, ProgramFormat.OPENQASM2, num_shots=256) if not job.wait(60): raise RuntimeError("DDSIM did not finish within 60 seconds") diff --git a/include/mqt-core/qdmi/Client.hpp b/include/mqt-core/qdmi/Client.hpp index 71fa9681f3..1278dc38a0 100644 --- a/include/mqt-core/qdmi/Client.hpp +++ b/include/mqt-core/qdmi/Client.hpp @@ -14,6 +14,7 @@ #pragma once +#include "qdmi/ProgramFormat.hpp" #include "qdmi/common/Common.hpp" #include "qdmi/driver/Driver.hpp" #include "qdmi/types.h" @@ -69,6 +70,32 @@ concept custom_property_value = std::same_as>; namespace detail { +[[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; +} + +[[nodiscard]] inline std::string +decodeText(const std::span value, + const std::string_view description) { + if (value.empty()) { + throw std::invalid_argument(std::string(description) + + " is not null-terminated"); + } + return decodeText( + std::string{reinterpret_cast(value.data()), value.size()}, + description); +} + [[nodiscard]] inline std::optional queuePositionFromResult(const int result, const size_t queuePosition) { if (result == QDMI_ERROR_NOTSUPPORTED || result == QDMI_ERROR_BADSTATE) { @@ -124,12 +151,7 @@ queryCustomValue(Query query, const std::string_view description) { if constexpr (std::same_as>) { return bytes; } else if constexpr (std::same_as) { - if (bytes.empty() || bytes.back() != std::byte{0}) { - throw std::invalid_argument("Cannot decode " + std::string(description) + - " as a null-terminated string"); - } - return std::string(reinterpret_cast(bytes.data()), - bytes.size() - 1); + return decodeText(bytes, description); } else { if (bytes.size() != sizeof(T)) { throw std::invalid_argument("Cannot decode " + std::string(description) + @@ -254,23 +276,6 @@ toJobResult(const CustomProperty property) { } } // namespace detail -/** - * @brief Returns whether a program format carries a binary payload. - * @details `QDMI_PROGRAM_FORMAT_QIRBASEMODULE`, - * `QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE`, and `QDMI_PROGRAM_FORMAT_QPY` hold - * bitcode or another serialized object. Such a payload can contain a null byte - * and is not text, so it must be submitted as exact bytes. The string overload - * of `Device::submitJob` rejects these formats. - * @param format The program format to classify. - * @return True if the format requires exact-byte submission. - */ -[[nodiscard]] constexpr bool -isBinaryProgramFormat(const QDMI_Program_Format format) noexcept { - return format == QDMI_PROGRAM_FORMAT_QIRBASEMODULE || - format == QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE || - format == QDMI_PROGRAM_FORMAT_QPY; -} - /** * @brief Concept for ranges that are contiguous in memory and can be * constructed with a size. @@ -569,6 +574,20 @@ class Device { [[nodiscard]] std::vector getSupportedProgramFormats() const; + /// Try to return the program formats reported by the device. + /// @return The reported formats, including an empty vector when the device + /// reports no formats, or `std::nullopt` when the property is unsupported. + /// @see QDMI_DEVICE_PROPERTY_SUPPORTEDPROGRAMFORMATS + [[nodiscard]] std::optional> + tryGetSupportedProgramFormats() const; + + /// Try to query the complete optional capabilities for an exact + /// payload. + /// @return The complete optional list, or `std::nullopt` when metadata is + /// unknown. + [[nodiscard]] std::optional> + tryGetProgramFeatures(const QDMI_Program_Format& format) const; + /** * @brief Returns the direct child devices managed by this device. * @return The child devices, or an empty vector if child devices are not @@ -613,8 +632,7 @@ class Device { * @brief Submits a textual program. * @details The terminating null byte required by QDMI text formats is * included in the submitted payload. - * @throws std::invalid_argument If the format requires binary submission, - * names a batch job, or names a calibration run. + * @throws std::invalid_argument If the format requires binary submission. * @see QDMI_job_submit */ [[nodiscard]] Job submitJob( @@ -642,8 +660,6 @@ class Device { * @brief Submits a binary program. * @details The bytes are submitted exactly as provided without appending a * null byte. - * @throws std::invalid_argument If the format names a batch job or a - * calibration run. * @see QDMI_job_submit */ [[nodiscard]] Job submitJob( @@ -655,11 +671,8 @@ class Device { const std::optional& custom4 = std::nullopt, const std::optional& custom5 = std::nullopt) const; - /** - * @brief Submits a binary program without setting a shot count. - * @details Repetition semantics are left to the submitted program and device. - * @see QDMI_job_submit - */ + /// Submits a binary program without setting a shot count. + /// Repetition semantics are left to the submitted program and device. [[nodiscard]] Job submitJob( std::span program, QDMI_Program_Format format, const std::optional& custom1 = std::nullopt, @@ -668,42 +681,6 @@ class Device { const std::optional& custom4 = std::nullopt, const std::optional& custom5 = std::nullopt) const; - /** - * @brief Triggers a calibration run. - * @details A device that reports a nonzero - * `QDMI_DEVICE_PROPERTY_NEEDSCALIBRATION` is asked to calibrate by submitting - * a job in the `QDMI_PROGRAM_FORMAT_CALIBRATION` format. QDMI does not - * require a program for such a job, so the payload is optional; when it is - * present, the device defines what it means, which is usually a - * configuration for the run. A calibration run executes no circuit, so no - * shot count is set. - * @param program The calibration payload. An empty span or `std::nullopt` - * means that the job has no payload. - * @see QDMI_job_submit - */ - [[nodiscard]] Job submitCalibrationJob( - std::optional> program = std::nullopt, - const std::optional& custom1 = std::nullopt, - const std::optional& custom2 = std::nullopt, - const std::optional& custom3 = std::nullopt, - const std::optional& custom4 = std::nullopt, - const std::optional& custom5 = std::nullopt) const; - - /** - * @brief Triggers a calibration run with a text payload. - * @details The terminating null byte required by QDMI text formats is - * included in the submitted payload. - * @param program The calibration payload. - * @see QDMI_job_submit - */ - [[nodiscard]] Job submitCalibrationJob( - const std::string& program, - const std::optional& custom1 = std::nullopt, - const std::optional& custom2 = std::nullopt, - const std::optional& custom3 = std::nullopt, - const std::optional& custom4 = std::nullopt, - const std::optional& custom5 = std::nullopt) const; - /** * @brief Retrieves an existing job by its device-provided ID. * @details Opening a job does not submit, clone, or modify the remote job. @@ -753,11 +730,11 @@ class Device { } qdmi::throwIfError(result, msg); - 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); } else if constexpr (maybe_optional_size_constructible_contiguous_range< T>) { size_t size = 0; @@ -794,8 +771,7 @@ class Device { } [[nodiscard]] Job - submitJobImpl(QDMI_Program_Format format, - std::optional> program, + submitJobImpl(QDMI_Program_Format format, std::span program, std::optional numShots, const std::optional& custom1, const std::optional& custom2, @@ -853,8 +829,8 @@ class Job { /** * @brief Gets a textual program without its terminating null byte. - * @throws std::invalid_argument If the format is not textual or the device - * does not return a null-terminated payload. + * @throws std::invalid_argument If the format is not textual or the payload + * does not contain exactly one null byte as its final byte. */ [[nodiscard]] std::string getProgram() const; @@ -917,6 +893,10 @@ class Job { "custom job result " + std::to_string(static_cast(property))); } + /// Returns one raw result without interpreting its bytes. + /// @param result Result representation to query. + [[nodiscard]] std::vector getResults(QDMI_Job_Result result) const; + /** * @brief Returns the measurement shots as a vector of bitstrings. * @see QDMI_JOB_RESULT_SHOTS @@ -959,6 +939,10 @@ class Job { */ [[nodiscard]] std::map getSparseProbabilities() const; + /// Returns the exact format-defined program output bytes. + /// @see QDMI_JOB_RESULT_PROGRAMOUTPUT + [[nodiscard]] std::vector getProgramOutput() const; + auto operator<=>(const Job&) const noexcept = default; private: @@ -1086,12 +1070,12 @@ class Site { } qdmi::throwIfError(result, std::string("Querying size") + qdmi::toString(prop)); - std::string value(size - 1, '\0'); + std::string value(size, '\0'); qdmi::throwIfError(QDMI_device_query_site_property(device_.get(), site_, prop, size, value.data(), nullptr), std::string("Querying ") + qdmi::toString(prop)); - return value; + return detail::decodeText(std::move(value), qdmi::toString(prop)); } else { remove_optional_t value{}; const auto result = QDMI_device_query_site_property( @@ -1263,12 +1247,12 @@ class Operation { } } qdmi::throwIfError(result, msg); - std::string value(size - 1, '\0'); + std::string value(size, '\0'); result = QDMI_device_query_operation_property( device_.get(), operation_, sites.size(), qdmiSites.data(), params.size(), params.data(), prop, size, value.data(), nullptr); qdmi::throwIfError(result, msg); - return value; + return detail::decodeText(std::move(value), msg); } else if constexpr (maybe_optional_size_constructible_contiguous_range< T>) { size_t size = 0; diff --git a/include/mqt-core/qdmi/ProgramFormat.hpp b/include/mqt-core/qdmi/ProgramFormat.hpp new file mode 100644 index 0000000000..06361663e1 --- /dev/null +++ b/include/mqt-core/qdmi/ProgramFormat.hpp @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023 - 2026 Chair for Design Automation, TUM + * Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH + * All rights reserved. + * + * SPDX-License-Identifier: MIT + * + * Licensed under the MIT License + */ + +#pragma once + +#include + +#include +#include + +namespace qdmi { + +namespace detail { +template +[[nodiscard]] constexpr bool +isCanonicalFixedString(const char (&value)[N]) noexcept { + const auto terminator = std::ranges::find(value, '\0'); + return terminator != std::end(value) && + std::ranges::all_of(terminator, std::end(value), + [](const char byte) { return byte == '\0'; }); +} +} // namespace detail + +inline constexpr QDMI_Program_Format OPENQASM2{ + .version = QDMI_MAKE_VERSION(2, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "openqasm", + .profile = ""}; +inline constexpr QDMI_Program_Format OPENQASM3{ + .version = QDMI_MAKE_VERSION(3, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "openqasm", + .profile = ""}; +inline constexpr QDMI_Program_Format QIR21_BASE_TEXT{ + .version = QDMI_MAKE_VERSION(2, 1, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "qir", + .profile = "base"}; +inline constexpr QDMI_Program_Format QIR21_BASE_BINARY{ + .version = QDMI_MAKE_VERSION(2, 1, 0), + .encoding = QDMI_PROGRAM_ENCODING_BINARY, + .id = "qir", + .profile = "base"}; +inline constexpr QDMI_Program_Format QIR21_ADAPTIVE_TEXT{ + .version = QDMI_MAKE_VERSION(2, 1, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "qir", + .profile = "adaptive"}; +inline constexpr QDMI_Program_Format QIR21_ADAPTIVE_BINARY{ + .version = QDMI_MAKE_VERSION(2, 1, 0), + .encoding = QDMI_PROGRAM_ENCODING_BINARY, + .id = "qir", + .profile = "adaptive"}; + +[[nodiscard]] inline bool equal(const QDMI_Program_Format& lhs, + const QDMI_Program_Format& rhs) noexcept { + return QDMI_program_format_equal(&lhs, &rhs) != 0; +} + +[[nodiscard]] constexpr bool +isBinaryProgramFormat(const QDMI_Program_Format& format) noexcept { + return format.encoding == QDMI_PROGRAM_ENCODING_BINARY; +} + +[[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); +} + +[[nodiscard]] constexpr bool +isValidProgramFeature(const QDMI_Program_Feature& feature) noexcept { + return feature.id[0] != '\0' && detail::isCanonicalFixedString(feature.id) && + detail::isCanonicalFixedString(feature.constraint_id) && + (feature.constraint_id[0] != '\0' || feature.constraint_value == 0U); +} + +} // namespace qdmi diff --git a/include/mqt-core/qdmi/common/Common.hpp b/include/mqt-core/qdmi/common/Common.hpp index d68eb8d275..f5d371f56b 100644 --- a/include/mqt-core/qdmi/common/Common.hpp +++ b/include/mqt-core/qdmi/common/Common.hpp @@ -15,10 +15,21 @@ #pragma once #include +#include +#include #include namespace qdmi { +namespace detail { +/// Returns whether @p value is in QDMI's provider-defined enum range. +constexpr auto isCustomValue(const auto value) noexcept -> bool { + const auto numericValue = static_cast(value); + return numericValue >= QDMI_CUSTOM_ENUM_VALUE_MIN && + numericValue <= QDMI_CUSTOM_ENUM_VALUE_MAX; +} +} // namespace detail + template class Singleton { protected: /// @brief Protected constructor to enforce the singleton pattern. @@ -131,9 +142,8 @@ template class Singleton { } #define IS_INVALID_ARGUMENT(prop, prefix) \ - ((prop) >= prefix##_MAX && (prop) != prefix##_CUSTOM1 && \ - (prop) != prefix##_CUSTOM2 && (prop) != prefix##_CUSTOM3 && \ - (prop) != prefix##_CUSTOM4 && (prop) != prefix##_CUSTOM5) + (static_cast(prop) < 0 || \ + ((prop) >= prefix##_MAX && !::qdmi::detail::isCustomValue(prop))) // NOLINTEND(bugprone-macro-parentheses) /// Returns the string representation of the given status code @p result. @@ -208,6 +218,9 @@ constexpr auto toString(const QDMI_Session_Parameter param) -> const char* { case QDMI_SESSION_PARAMETER_CUSTOM5: return "CUSTOM5"; } + if (detail::isCustomValue(param)) { + return "CUSTOM"; + } unreachable(); } @@ -229,6 +242,9 @@ constexpr auto toString(const QDMI_Session_Property prop) -> const char* { case QDMI_SESSION_PROPERTY_CUSTOM5: return "CUSTOM5"; } + if (detail::isCustomValue(prop)) { + return "CUSTOM"; + } unreachable(); } @@ -264,6 +280,9 @@ constexpr auto toString(const QDMI_Device_Session_Parameter param) -> const case QDMI_DEVICE_SESSION_PARAMETER_CUSTOM5: return "CUSTOM5"; } + if (detail::isCustomValue(param)) { + return "CUSTOM"; + } unreachable(); } @@ -309,6 +328,9 @@ constexpr auto toString(const QDMI_Site_Property prop) -> const char* { case QDMI_SITE_PROPERTY_CUSTOM5: return "CUSTOM5"; } + if (detail::isCustomValue(prop)) { + return "CUSTOM"; + } unreachable(); } @@ -350,6 +372,9 @@ constexpr auto toString(const QDMI_Operation_Property prop) -> const char* { case QDMI_OPERATION_PROPERTY_CUSTOM5: return "CUSTOM5"; } + if (detail::isCustomValue(prop)) { + return "CUSTOM"; + } unreachable(); } @@ -372,8 +397,6 @@ constexpr auto toString(const QDMI_Device_Property prop) -> const char* { return "OPERATIONS"; case QDMI_DEVICE_PROPERTY_COUPLINGMAP: return "COUPLING MAP"; - case QDMI_DEVICE_PROPERTY_NEEDSCALIBRATION: - return "NEEDS CALIBRATION"; case QDMI_DEVICE_PROPERTY_LENGTHUNIT: return "LENGTH UNIT"; case QDMI_DEVICE_PROPERTY_LENGTHSCALEFACTOR: @@ -392,6 +415,8 @@ constexpr auto toString(const QDMI_Device_Property prop) -> const char* { return "CHILD DEVICES"; case QDMI_DEVICE_PROPERTY_QUEUELENGTH: return "QUEUE LENGTH"; + case QDMI_DEVICE_PROPERTY_NEEDSCALIBRATION: + return "NEEDS CALIBRATION"; case QDMI_DEVICE_PROPERTY_MAX: return "MAX"; case QDMI_DEVICE_PROPERTY_CUSTOM1: @@ -405,6 +430,9 @@ constexpr auto toString(const QDMI_Device_Property prop) -> const char* { case QDMI_DEVICE_PROPERTY_CUSTOM5: return "CUSTOM5"; } + if (detail::isCustomValue(prop)) { + return "CUSTOM"; + } unreachable(); } diff --git a/include/mqt-core/qdmi/devices/dd/Device.hpp b/include/mqt-core/qdmi/devices/dd/Device.hpp index 871e9241d1..1f30996ede 100644 --- a/include/mqt-core/qdmi/devices/dd/Device.hpp +++ b/include/mqt-core/qdmi/devices/dd/Device.hpp @@ -17,6 +17,7 @@ #include "dd/DDDefinitions.hpp" #include "dd/Package.hpp" #include "mqt_ddsim_qdmi/device.h" +#include "qdmi/ProgramFormat.hpp" #include "qdmi/common/Common.hpp" #include @@ -156,6 +157,10 @@ struct MQT_DDSIM_QDMI_Device_Session_impl_d { auto queryDeviceProperty(QDMI_Device_Property prop, size_t size, void* value, size_t* sizeRet) const -> QDMI_STATUS; + auto queryProgramFeatures(const QDMI_Program_Format& format, size_t size, + QDMI_Program_Feature* value, size_t* sizeRet) const + -> QDMI_STATUS; + /** * @brief Forwards a query of a site property to the site. * @see MQT_DDSIM_QDMI_device_session_query_site_property @@ -191,7 +196,10 @@ struct MQT_DDSIM_QDMI_Device_Job_impl_d { std::atomic status_{QDMI_JOB_STATUS_CREATED}; /// The program format - QDMI_Program_Format format_ = QDMI_PROGRAM_FORMAT_QASM3; + QDMI_Program_Format format_ = qdmi::OPENQASM3; + + /// Whether the program format has been set. + bool hasFormat_ = false; /// The quantum program associated with the job. /// Text formats (QASM2/3, QIR Base/Adaptive String) are stored as @@ -199,6 +207,9 @@ struct MQT_DDSIM_QDMI_Device_Job_impl_d { /// @c std::vector. std::variant> program_; + /// Whether the program payload has been set. + bool hasProgram_ = false; + /// The number of shots for the job size_t numShots_ = 1024U; @@ -214,6 +225,9 @@ struct MQT_DDSIM_QDMI_Device_Job_impl_d { /// Measurement outcomes in sampling order. std::vector shots_; + /// The format-defined program output stream. + std::string programOutput_; + /// The DD package used for the state vector simulation std::unique_ptr dd_; @@ -297,19 +311,7 @@ struct MQT_DDSIM_QDMI_Device_Job_impl_d { */ auto free() -> void; - /** - * @brief Sets a parameter for the job. - * @note When setting @c QDMI_DEVICE_JOB_PARAMETER_PROGRAM, the device uses - * the current @c QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT to decide whether - * the payload's wire @p size: - * - includes a trailing @c '\0' (text formats: QASM2, QASM3, - * QIR Base/Adaptive String) or - * - is the exact byte count (binary formats: QIR Base/Adaptive Module). - * Callers should therefore set @c PROGRAMFORMAT before @c PROGRAM. - * The default of @c QDMI_PROGRAM_FORMAT_QASM3 is assumed if @c PROGRAMFORMAT - * is not set. - * @see MQT_DDSIM_QDMI_device_job_set_parameter - */ + /// @see MQT_DDSIM_QDMI_device_job_set_parameter auto setParameter(QDMI_Device_Job_Parameter param, size_t size, const void* value) -> QDMI_STATUS; diff --git a/include/mqt-core/qdmi/driver/Driver.hpp b/include/mqt-core/qdmi/driver/Driver.hpp index 3e3d740bfb..28c73d94c6 100644 --- a/include/mqt-core/qdmi/driver/Driver.hpp +++ b/include/mqt-core/qdmi/driver/Driver.hpp @@ -145,6 +145,9 @@ struct DeviceLibrary { /// Function pointer to @ref QDMI_device_session_query_device_property. decltype(QDMI_device_session_query_device_property)* device_session_query_device_property{}; + /// Function pointer to @ref QDMI_device_session_query_program_features. + decltype(QDMI_device_session_query_program_features)* + device_session_query_program_features{}; /// Function pointer to @ref QDMI_device_session_query_site_property. decltype(QDMI_device_session_query_site_property)* device_session_query_site_property{}; @@ -293,6 +296,10 @@ struct QDMI_Device_impl_d { auto queryDeviceProperty(QDMI_Device_Property prop, size_t size, void* value, size_t* sizeRet) const -> int; + auto queryProgramFeatures(const QDMI_Program_Format* format, size_t size, + QDMI_Program_Feature* value, size_t* sizeRet) const + -> int; + /** * @brief Queries a site property. * @see QDMI_device_query_site_property diff --git a/python/mqt/core/plugins/pennylane/converter.py b/python/mqt/core/plugins/pennylane/converter.py index 31153179f4..8eca704e80 100644 --- a/python/mqt/core/plugins/pennylane/converter.py +++ b/python/mqt/core/plugins/pennylane/converter.py @@ -46,6 +46,8 @@ class _ConvertedProgram: program_format: ProgramFormat wire_map: Mapping[Hashable, int] measurement_order: tuple[int, ...] + mcm_slot_by_uid: Mapping[str, int] + output_width: int @dataclass(frozen=True) @@ -189,11 +191,18 @@ class _ProgramConverter: program_format: Program format the device selected. """ - def __init__(self, device: QDMIDevice, device_wires: Wires, program_format: ProgramFormat) -> None: + def __init__( + self, + device: QDMIDevice, + device_wires: Wires, + program_format: ProgramFormat, + capabilities: frozenset[str] = frozenset(), + ) -> None: """Read the advertised capabilities of one opened QDMI device.""" self._device = device self._device_wires = device_wires self._program_format = program_format + self._capabilities = capabilities self._advertised = {operation.name().lower(): operation for operation in device.operations()} self._wire_map: Mapping[Hashable, int] = MappingProxyType({ wire: index for index, wire in enumerate(device_wires) @@ -210,7 +219,11 @@ def supports(self, operation: Operator) -> bool: Returns: Whether the device runs the operation without further decomposition. """ - if self._program_format == ProgramFormat.QASM3: + if isinstance(operation, qp.ops.MidMeasure): + return self._supports_mcm(reset=operation.reset) + if isinstance(operation, qp.ops.op_math.Conditional): + return self._supports_mcm() and self.supports(operation.base) + if self._program_format == ProgramFormat.OPENQASM3: return _resolve_qasm3_operation(operation, self._advertised) is not None spelling = _QASM2_OPERATIONS.get(operation.name) return spelling is not None and spelling in self._advertised @@ -224,11 +237,13 @@ def convert(self, tape: QuantumScript) -> _ConvertedProgram: Returns: The converted program and its deterministic measurement metadata. """ - if self._program_format == ProgramFormat.QASM3: + if self._program_format == ProgramFormat.OPENQASM3: return self._convert_qasm3(tape) return self._convert_qasm2(tape) - def _program(self, tape: QuantumScript, payload: str) -> _ConvertedProgram: + def _program( + self, tape: QuantumScript, payload: str, mcm_slot_by_uid: Mapping[str, int] | None = None + ) -> _ConvertedProgram: """Attach the measurement-decoding metadata to one converted payload. Returns: @@ -239,6 +254,25 @@ def _program(self, tape: QuantumScript, payload: str) -> _ConvertedProgram: program_format=self._program_format, wire_map=self._wire_map, measurement_order=self._measurement_order(tape), + mcm_slot_by_uid=MappingProxyType(dict(mcm_slot_by_uid or {})), + output_width=len(self._device_wires) + len(mcm_slot_by_uid or {}), + ) + + def _supports_mcm(self, *, reset: bool = False) -> bool: + """Return whether the selected OpenQASM path supports one-shot MCM.""" + required = { + "mid-circuit-measurement", + "measured-qubit-reuse", + "measurement-result-use", + "boolean-computation", + "forward-branching", + } + operations = self._advertised + return ( + self._program_format == ProgramFormat.OPENQASM3 + and required <= self._capabilities + and "measure" in operations + and (not reset or "reset" in operations) ) def _measurement_order(self, tape: QuantumScript) -> tuple[int, ...]: @@ -317,13 +351,67 @@ def _convert_qasm3(self, tape: QuantumScript) -> _ConvertedProgram: PennyLaneUnsupportedOperationError: If no advertised spelling exists. PennyLaneValidationError: If parameters, wires, or topology are invalid. """ + mid_measurements = [operation for operation in tape.operations if isinstance(operation, qp.ops.MidMeasure)] + if any(not operation.meas_uid for operation in mid_measurements): + msg = "Mid-circuit measurements require unique nonempty measurement IDs." + raise ValidationError(msg) + uids = [operation.meas_uid for operation in mid_measurements if operation.meas_uid is not None] + if len(set(uids)) != len(uids): + msg = "Mid-circuit measurements require unique nonempty measurement IDs." + raise ValidationError(msg) + mcm_slot_by_uid = {uid: len(self._device_wires) + index for index, uid in enumerate(uids)} lines = [ "OPENQASM 3.0;", f"qubit[{len(self._device_wires)}] q;", - f"bit[{len(self._device_wires)}] c;", + f"bit[{len(self._device_wires) + len(uids)}] c;", ] + seen_measurements: set[str] = set() for operation in tape.operations: + if isinstance(operation, qp.ops.MidMeasure): + if not self._supports_mcm(reset=operation.reset): + msg = "The selected payload does not support mid-circuit measurement and reset." + raise UnsupportedOperationError(msg) + wire = self._wire_map[operation.wires[0]] + uid = operation.meas_uid + assert uid is not None + lines.append(f"c[{mcm_slot_by_uid[uid]}] = measure q[{wire}];") + seen_measurements.add(uid) + if operation.reset: + lines.append(f"reset q[{wire}];") + continue + if isinstance(operation, qp.ops.op_math.Conditional): + if not self._supports_mcm(): + msg = "The selected payload does not support measurement-conditioned execution." + raise UnsupportedOperationError(msg) + dependencies = operation.meas_val.measurements + if any(measurement.meas_uid not in seen_measurements for measurement in dependencies): + msg = "A conditional uses a measurement that does not precede it." + raise ValidationError(msg) + if len(dependencies) > 16: + msg = "A conditional depends on more than 16 measurements." + raise ValidationError(msg) + true_assignments = [assignment for assignment, value in operation.meas_val.items() if bool(value)] + resolved = _resolve_qasm3_operation(operation.base, self._advertised) + if resolved is None: + msg = f"Conditional operation '{operation.base.name}' is not supported." + raise UnsupportedOperationError(msg) + spelling, spec, qdmi_operation = resolved + _validate_operation_shape(operation.base, spec) + indices = tuple(self._wire_map[wire] for wire in operation.base.wires) + self._validate_qdmi_contract(operation.base, spec, qdmi_operation, indices) + parameters = ",".join( + repr(_finite_parameter(parameter, operation.base.name)) for parameter in operation.base.parameters + ) + gate = f"{spelling}{f'({parameters})' if parameters else ''} " + gate += ",".join(f"q[{index}]" for index in indices) + ";" + for assignment in true_assignments: + terms = [ + f"c[{mcm_slot_by_uid[measurement.meas_uid]}] == {bit}" + for measurement, bit in zip(dependencies, assignment, strict=True) + ] + lines.append(f"if ({' && '.join(terms)}) {{ {gate} }}") + continue resolved = _resolve_qasm3_operation(operation, self._advertised) if resolved is None: msg = ( @@ -353,8 +441,8 @@ def _convert_qasm3(self, tape: QuantumScript) -> _ConvertedProgram: operands = ",".join(f"q[{index}]" for index in indices) lines.append(f"{spelling}{parameter_list} {operands};") - lines.append("c = measure q;") - return self._program(tape, "\n".join(lines) + "\n") + lines.extend(f"c[{index}] = measure q[{index}];" for index in range(len(self._device_wires))) + return self._program(tape, "\n".join(lines) + "\n", mcm_slot_by_uid) def _convert_qasm2(self, tape: QuantumScript) -> _ConvertedProgram: """Serialize a QASM2-only program with PennyLane's built-in converter. diff --git a/python/mqt/core/plugins/pennylane/device.py b/python/mqt/core/plugins/pennylane/device.py index e25c17d252..e391ed23d6 100644 --- a/python/mqt/core/plugins/pennylane/device.py +++ b/python/mqt/core/plugins/pennylane/device.py @@ -11,22 +11,38 @@ from __future__ import annotations import operator +from collections import Counter from contextlib import suppress from time import monotonic from typing import TYPE_CHECKING, Any, cast import numpy as np import pennylane as qp -from pennylane.devices import Device, ExecutionConfig +from pennylane.devices import Device, DeviceCapabilities, ExecutionConfig, MCMConfig from pennylane.devices.preprocess import ( decompose, measurements_from_samples, validate_device_wires, validate_measurements, ) -from pennylane.measurements import CountsMP, ExpectationMP, ProbabilityMP, SampleMP, Shots, VarianceMP -from pennylane.transforms import broadcast_expand, defer_measurements, split_non_commuting -from pennylane.transforms.core import CompilePipeline +from pennylane.measurements import ( + CountsMP, + ExpectationMP, + MeasurementValue, + ProbabilityMP, + SampleMeasurement, + SampleMP, + Shots, + VarianceMP, +) +from pennylane.transforms import ( + broadcast_expand, + defer_measurements, + diagonalize_measurements, + dynamic_one_shot, + split_non_commuting, +) +from pennylane.transforms.core import CompilePipeline, transform from mqt.core.qdmi import Device as QDMIDeviceHandle from mqt.core.qdmi import Job as QDMIJobHandle @@ -51,7 +67,7 @@ ) if TYPE_CHECKING: - from collections.abc import Hashable, Mapping, Sequence + from collections.abc import Callable, Hashable, Mapping, Sequence from pennylane.tape import QuantumScript, QuantumScriptOrBatch from pennylane.typing import Result, ResultBatch @@ -79,6 +95,20 @@ _SAMPLED_MEASUREMENTS = (SampleMP, CountsMP, ProbabilityMP, ExpectationMP, VarianceMP) +@transform +def _dynamic_or_samples( + tape: QuantumScript, postselect_mode: str | None = None +) -> tuple[Sequence[QuantumScript], Callable[[Sequence[Result]], Result]]: + """Use dynamic one-shot only for tapes that contain mid-circuit measurements. + + Returns: + The transformed tapes and their result postprocessor. + """ + if any(isinstance(operation, qp.ops.MidMeasure) for operation in tape.operations): + return dynamic_one_shot(tape, postselect_mode=postselect_mode) + return measurements_from_samples(tape) + + def _validate_parameter_names(parameters: Mapping[str, object], allowed: frozenset[str], kind: str) -> None: """Reject unknown QDMI configuration fields before opening or submission. @@ -180,10 +210,47 @@ def __init__( super().__init__(wires=resolved_wires, shots=None) self._shots = Shots(shots) self._program_format = self._select_program_format() - self._converter = _ProgramConverter(self._qdmi_device, self.wires, self._program_format) + reported = self._qdmi_device.try_program_features(self._program_format) + feature_groups = Counter((feature.id, feature.value) for feature in reported or ()) + self._program_capabilities = frozenset( + feature.id + for feature in reported or () + if feature.value == 0 + and feature_groups[feature.id, feature.value] == 1 + and not feature.constraint_id + and feature.constraint_value == 0 + ) + operations = {operation.name().lower() for operation in self._qdmi_device.operations()} + mcm = { + "mid-circuit-measurement", + "measured-qubit-reuse", + "measurement-result-use", + "boolean-computation", + "forward-branching", + } + supports_one_shot = ( + reported is not None + and self._program_format == ProgramFormat.OPENQASM3 + and mcm <= self._program_capabilities + and {"measure", "reset"} <= operations + ) + self._capabilities = DeviceCapabilities( + qjit_compatible=False, + runtime_code_generation=False, + dynamic_qubit_management=False, + supported_mcm_methods=["one-shot"] if supports_one_shot else [], + ) + self._converter = _ProgramConverter( + self._qdmi_device, self.wires, self._program_format, self._program_capabilities + ) self._submitted_jobs = 0 self._execution_time = 0.0 + @property + def capabilities(self) -> DeviceCapabilities: + """Capabilities projected from the selected QDMI payload.""" + return self._capabilities + @property def device_id(self) -> str | None: """Stable QDMI device ID, if the device was opened by ID.""" @@ -214,10 +281,10 @@ def _select_program_format(self) -> ProgramFormat: PennyLaneUnsupportedFormatError: If neither OpenQASM version is advertised. """ formats = set(self._qdmi_device.supported_program_formats()) - if ProgramFormat.QASM3 in formats: - return ProgramFormat.QASM3 - if ProgramFormat.QASM2 in formats: - return ProgramFormat.QASM2 + if ProgramFormat.OPENQASM3 in formats: + return ProgramFormat.OPENQASM3 + if ProgramFormat.OPENQASM2 in formats: + return ProgramFormat.OPENQASM2 msg = f"QDMI device '{self._device_name}' advertises neither OpenQASM 3 nor OpenQASM 2." raise UnsupportedFormatError(msg) @@ -227,10 +294,11 @@ def preprocess_transforms(self, execution_config: ExecutionConfig | None = None) Returns: The transforms applied before device execution. """ - del execution_config + config = execution_config or ExecutionConfig() + mcm_config = MCMConfig(**config.mcm_config) if isinstance(config.mcm_config, dict) else config.mcm_config + one_shot = mcm_config.mcm_method == "one-shot" pipeline = CompilePipeline() pipeline.add_transform(_validate_finite_shots) - pipeline.add_transform(defer_measurements, allow_postselect=False, num_wires=len(self.wires)) pipeline.add_transform(validate_device_wires, self.wires, name=self.name) pipeline.add_transform( validate_measurements, @@ -239,7 +307,12 @@ def preprocess_transforms(self, execution_config: ExecutionConfig | None = None) name=self.name, ) pipeline.add_transform(split_non_commuting, grouping_strategy="qwc") - pipeline.add_transform(measurements_from_samples) + if one_shot: + pipeline.add_transform(_dynamic_or_samples, postselect_mode=mcm_config.postselect_mode) + pipeline.add_transform(diagonalize_measurements) + else: + pipeline.add_transform(defer_measurements, allow_postselect=False, num_wires=len(self.wires)) + pipeline.add_transform(measurements_from_samples) pipeline.add_transform( decompose, stopping_condition=self._converter.supports, @@ -306,17 +379,16 @@ def _samples(self, job: QDMIJobHandle, converted: _ConvertedProgram, shots: int) raise ExecutionError(msg) rows: list[list[int]] = [] - width = len(converted.wire_map) + width = converted.output_width for bitstring in bitstrings: clean = bitstring.replace(" ", "") if len(clean) != width or any(bit not in "01" for bit in clean): msg = f"QDMI returned an invalid {width}-wire shot: {bitstring!r}." raise ExecutionError(msg) - # QDMI bit strings use the conventional basis-state spelling with - # the highest-index site on the left. PennyLane sample columns use - # the declared wire order, starting with wire zero. + # QDMI writes output slot zero at the right. PennyLane sample + # columns follow the declared wire order, starting with wire zero. wire_order = clean[::-1] - rows.append([int(wire_order[index]) for index in converted.measurement_order]) + rows.append([int(bit) for bit in wire_order]) return np.asarray(rows, dtype=np.int8) @staticmethod @@ -353,11 +425,13 @@ def _submit(self, converted: _ConvertedProgram, shots: int) -> QDMIJobHandle: raise ExecutionError(msg) from exc return job - def _result(self, job: QDMIJobHandle, converted: _ConvertedProgram, shots: int) -> np.ndarray: + def _result( + self, job: QDMIJobHandle, converted: _ConvertedProgram, tape: QuantumScript, shots: int + ) -> np.ndarray | tuple[Any, ...]: """Wait for and decode one QDMI job. Returns: - Raw samples from the completed job. + PennyLane samples from the completed job. Raises: PennyLaneExecutionError: If waiting or execution fails. @@ -368,7 +442,21 @@ def _result(self, job: QDMIJobHandle, converted: _ConvertedProgram, shots: int) msg = f"QDMI execution on '{self._device_name}' failed: {exc}" raise ExecutionError(msg) from exc self._require_done(job) - return self._samples(job, converted, shots) + samples = self._samples(job, converted, shots) + if not converted.mcm_slot_by_uid: + return samples[:, converted.measurement_order] + + terminal = samples[:, : len(self.wires)] + measurement_results = [] + for measurement in tape.measurements: + if isinstance(measurement.mv, MeasurementValue): + source = measurement.mv.measurements[0] + uid = source.meas_uid + assert uid is not None + measurement_results.append(samples[0, converted.mcm_slot_by_uid[uid]]) + else: + measurement_results.append(cast("SampleMeasurement", measurement).process_samples(terminal, self.wires)) + return tuple(measurement_results) def execute( self, @@ -393,9 +481,9 @@ def execute( for index, (converted, shot_copies) in enumerate(prepared): submitted.extend((index, converted, shots, self._submit(converted, shots)) for shots in shot_copies) - tape_results: list[list[np.ndarray]] = [[] for _ in tapes] + tape_results: list[list[Any]] = [[] for _ in tapes] for index, converted, shots, job in submitted: - tape_results[index].append(self._result(job, converted, shots)) + tape_results[index].append(self._result(job, converted, tapes[index], shots)) except BaseException: for *_unused, job in submitted: with suppress(BaseException): diff --git a/python/mqt/core/plugins/qiskit/__init__.py b/python/mqt/core/plugins/qiskit/__init__.py index b2295f6e36..59344f0df7 100644 --- a/python/mqt/core/plugins/qiskit/__init__.py +++ b/python/mqt/core/plugins/qiskit/__init__.py @@ -40,17 +40,10 @@ ) from .job import QDMIJob from .provider import QDMIProvider - from .serializers import ( - ProgramSerializer, - program_serializer, - register_program_serializer, - unregister_program_serializer, - ) __all__ += [ "CircuitValidationError", "JobSubmissionError", - "ProgramSerializer", "QDMIBackend", "QDMIJob", "QDMIProvider", @@ -58,7 +51,4 @@ "TranslationError", "UnsupportedFormatError", "UnsupportedOperationError", - "program_serializer", - "register_program_serializer", - "unregister_program_serializer", ] diff --git a/python/mqt/core/plugins/qiskit/backend.py b/python/mqt/core/plugins/qiskit/backend.py index 6b98b5e2e2..134d9961d0 100644 --- a/python/mqt/core/plugins/qiskit/backend.py +++ b/python/mqt/core/plugins/qiskit/backend.py @@ -15,11 +15,12 @@ import inspect import warnings +from collections import Counter from numbers import Integral from typing import TYPE_CHECKING, Any, ClassVar from qiskit import qasm2, qasm3 -from qiskit.circuit import QuantumCircuit +from qiskit.circuit import ForLoopOp, IfElseOp, QuantumCircuit, SwitchCaseOp, WhileLoopOp from qiskit.circuit.library import ( MCPhaseGate, MCXGate, @@ -42,10 +43,9 @@ UnsupportedOperationError, ) from .job import QDMIJob, _cancel_jobs -from .serializers import preferred_program_formats, program_serializer, register_program_serializer if TYPE_CHECKING: - from collections.abc import Iterable, Mapping, MutableSet, Sequence + from collections.abc import Callable, Iterable, Mapping, MutableSet, Sequence from typing import Unpack from qiskit.circuit import Instruction, Parameter @@ -214,8 +214,8 @@ def _check_payload_type(program: str | bytes, fmt: ProgramFormat) -> None: expected = bytes if is_binary_program_format(fmt) else str if not isinstance(program, expected): msg = ( - f"The program serializer for {fmt.name} returned {type(program).__name__}, " - f"but {fmt.name} requires {expected.__name__}" + f"The program serializer for {fmt.format_id} returned {type(program).__name__}, " + f"but its {fmt.encoding.name.lower()} encoding requires {expected.__name__}" ) raise TranslationError(msg) @@ -247,6 +247,39 @@ def is_convertible(device: QDMIDevice) -> bool: # Zoned operations cannot easily be represented in Qiskit's Target model return not any(op.is_zoned() for op in device.operations()) + def _program_serializer( # ruff:ignore[no-self-use] + self, program_format: ProgramFormat + ) -> Callable[[QuantumCircuit, QDMIBackend], str | bytes] | None: + """Return the serializer for one exact program format. + + A device-specific backend can override this method for a format that it + owns and delegate all other formats to this implementation. + + Args: + program_format: The exact format to serialize. + + Returns: + A serializer, or ``None`` if the backend does not support the format. + """ + if program_format == ProgramFormat.OPENQASM3: + return _serialize_to_qasm3 + if program_format == ProgramFormat.OPENQASM2: + return _serialize_to_qasm2 + return None + + def _decode_counts(self, job: QDMIJobHandle) -> dict[str, int]: # ruff:ignore[no-self-use] + """Decode one completed QDMI job into Qiskit counts. + + A backend that owns a vendor result format can override this method. + + Args: + job: The completed QDMI job. + + Returns: + The measurement counts. + """ + return job.get_counts() + # Define known aliases _GATE_ALIASES: ClassVar[dict[str, set[str]]] = { "id": {"i"}, # Identity gate can also be called 'i' @@ -301,6 +334,7 @@ def __init__( provider: QDMIProvider | None = None, *, device_id: str | None = None, + payload_descriptor: ProgramFormat | None = None, ) -> None: """Initialize the backend with a QDMI device wrapper. @@ -308,9 +342,14 @@ def __init__( device: QDMI device wrapper. provider: Provider instance that created this backend. device_id: Stable registry ID for the opened device, if known. + payload_descriptor: Exact payload to produce. By default, select + the first device-supported descriptor that this backend can + serialize. Raises: UnsupportedDeviceError: If the device cannot be represented in Qiskit's Target model. + UnsupportedFormatError: If this backend cannot serialize any + accepted payload. """ if not self.is_convertible(device): msg = f"Device '{device.name()}' cannot be represented in Qiskit's Target model" @@ -320,6 +359,44 @@ def __init__( self._device = device self._device_id = device_id + formats = device.supported_program_formats() + if payload_descriptor is not None: + formats = [fmt for fmt in formats if fmt == payload_descriptor] + if not formats: + msg = "The device does not accept the requested payload descriptor" + raise UnsupportedFormatError(msg) + selected = None + for fmt in formats: + if serializer := self._program_serializer(fmt): + selected = (fmt, serializer) + break + if selected is None: + msg = "The device reports no payload descriptor that this Qiskit backend can serialize" + raise UnsupportedFormatError(msg) + self._program_format, self._serialize_program = selected + features = device.try_program_features(self._program_format) + feature_groups = Counter((feature.id, feature.value) for feature in features or ()) + self._program_capabilities = ( + None + if features is None + else { + feature.id + for feature in features + if feature.value == 0 + and feature_groups[feature.id, feature.value] == 1 + and not feature.constraint_id + and feature.constraint_value == 0 + } + ) + if self._program_format.format_id == "qir" and self._program_format.profile == "adaptive": + self._program_capabilities = (self._program_capabilities or set()) | { + "mid-circuit-measurement", + "measured-qubit-reuse", + "measurement-result-use", + "boolean-computation", + "forward-branching", + } + # Build Target from device self._target = self._build_target() @@ -329,6 +406,7 @@ def from_device_id( device_id: str, *, provider: QDMIProvider | None = None, + payload_descriptor: ProgramFormat | None = None, **session_parameters: Unpack[QDMISessionParameters], ) -> QDMIBackend: """Open a registered QDMI device and adapt it for Qiskit. @@ -336,6 +414,7 @@ def from_device_id( Args: device_id: Stable ID from the QDMI device registry. provider: Provider to associate with the backend. + payload_descriptor: Exact payload to produce. session_parameters: Optional overrides for this device session. Returns: @@ -345,6 +424,7 @@ def from_device_id( device=open_device(device_id, **session_parameters), provider=provider, device_id=device_id, + payload_descriptor=payload_descriptor, ) @property @@ -357,6 +437,11 @@ def device_id(self) -> str | None: """Stable QDMI device ID, if known.""" return self._device_id + @property + def payload_descriptor(self) -> ProgramFormat: + """The exact payload produced by this backend.""" + return self._program_format + def sampler(self, **options: Unpack[QiskitSamplerOptions]) -> BackendSamplerV2: """Construct Qiskit's native sampler with typed keyword options. @@ -441,6 +526,16 @@ def _build_target(self) -> Target: stacklevel=2, ) + 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) + return target def _add_operation_to_target( @@ -646,55 +741,33 @@ def _preprocess_circuit(self, circuit: QuantumCircuit) -> QuantumCircuit: # ruf """ return circuit - def _serialize_circuit( - self, circuit: QuantumCircuit, supported_program_formats: Iterable[ProgramFormat] - ) -> tuple[str | bytes, ProgramFormat]: + def _serialize_circuit(self, circuit: QuantumCircuit) -> tuple[str | bytes, ProgramFormat]: """Serialize a :class:`~qiskit.circuit.QuantumCircuit` into a program the device accepts. - The method walks the formats the device supports in the order of - :data:`~mqt.core.plugins.qiskit.serializers.PROGRAM_FORMAT_PREFERENCE` - and uses the first one that has a registered serializer. See - :mod:`mqt.core.plugins.qiskit.serializers` for how a package registers a - serializer. + The backend selects the descriptor and serializer once during + construction. Args: circuit: The circuit to serialize. - supported_program_formats: The program formats the device accepts. Returns: Tuple of (program, program format). The program is a string for a text format and bytes for a binary format. Raises: - UnsupportedFormatError: If the device reports no program format that - has a serializer. UnsupportedOperationError: If the circuit contains an operation the chosen format cannot express. TranslationError: If serialization fails. """ - formats = list(supported_program_formats) - if not formats: - msg = "The device reports no supported program formats" - raise UnsupportedFormatError(msg) - - for fmt in preferred_program_formats(formats): - serializer = program_serializer(fmt) - if serializer is None: - continue - try: - program = serializer(circuit, self) - except UnsupportedOperationError: - # A circuit the chosen format cannot express must fail loudly - # rather than arrive at the device in a weaker format. - raise - except Exception as exc: - msg = f"Failed to serialize the circuit to {fmt.name}: {exc}" - raise TranslationError(msg) from exc - _check_payload_type(program, fmt) - return program, fmt - - msg = f"No program serializer for any format the device supports: {[fmt.name for fmt in formats]}" - raise UnsupportedFormatError(msg) + try: + program = self._serialize_program(circuit, self) + except UnsupportedOperationError: + raise + except Exception as exc: + msg = f"Failed to serialize the circuit to {self._program_format.format_id}: {exc}" + raise TranslationError(msg) from exc + _check_payload_type(program, self._program_format) + return program, self._program_format def run( self, @@ -779,8 +852,6 @@ def run( # Build set of all supported QDMI operation names once device_ops = {op.name().lower() for op in self._device.operations()} - supported_formats = self._device.supported_program_formats() - # Process each circuit qdmi_jobs: list[QDMIJobHandle] = [] prepared_circuits: list[QuantumCircuit] = [] @@ -811,8 +882,17 @@ def run( raise CircuitValidationError(msg) # Validate operations are supported - for instruction in bound_circuit.data: + 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 # Map the Qiskit gate name to possible QDMI operation names and check if any match possible_qdmi_names = self._map_qiskit_gate_to_operation_names(op_name) # Check if any of the possible QDMI names are supported by the device @@ -822,7 +902,7 @@ def run( raise UnsupportedOperationError(msg) # Serialize the circuit into a program format the device accepts - serialized_circuits.append(self._serialize_circuit(bound_circuit, supported_formats)) + serialized_circuits.append(self._serialize_circuit(bound_circuit)) prepared_circuits.append(bound_circuit) # Second pass: submit all validated circuits @@ -839,12 +919,3 @@ def run( except BaseException: _cancel_jobs(qdmi_jobs) raise - - -# 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) diff --git a/python/mqt/core/plugins/qiskit/job.py b/python/mqt/core/plugins/qiskit/job.py index b5621b4fe8..08daa113c4 100644 --- a/python/mqt/core/plugins/qiskit/job.py +++ b/python/mqt/core/plugins/qiskit/job.py @@ -190,7 +190,7 @@ def _collect_result(self, job: QDMIJobHandle, header: dict[str, Any]) -> Experim elif not width: data = {"counts": {}} else: - counts = job.get_counts() + counts = self._backend._decode_counts(job) # ruff:ignore[private-member-access] if any(not isinstance(count, Integral) or count < 0 for count in counts.values()): msg = "Invalid QDMI histogram: counts must be nonnegative integers." raise JobError(msg) diff --git a/python/mqt/core/plugins/qiskit/provider.py b/python/mqt/core/plugins/qiskit/provider.py index 2ee8de3c49..f7a09cef0e 100644 --- a/python/mqt/core/plugins/qiskit/provider.py +++ b/python/mqt/core/plugins/qiskit/provider.py @@ -19,7 +19,7 @@ from ...qdmi.driver import registered_device_ids from .backend import QDMIBackend -from .exceptions import UnsupportedDeviceError +from .exceptions import UnsupportedDeviceError, UnsupportedFormatError if TYPE_CHECKING: from collections.abc import Iterator @@ -95,7 +95,7 @@ def _iter_backends(self, name: str | None = None) -> Iterator[QDMIBackend]: for device_id in self.device_ids(): try: backend = self.get_backend_by_device_id(device_id) - except UnsupportedDeviceError: + except (UnsupportedDeviceError, UnsupportedFormatError): continue except (IndexError, RuntimeError, ValueError): warnings.warn( diff --git a/python/mqt/core/plugins/qiskit/serializers.py b/python/mqt/core/plugins/qiskit/serializers.py deleted file mode 100644 index dbb2e07d08..0000000000 --- a/python/mqt/core/plugins/qiskit/serializers.py +++ /dev/null @@ -1,383 +0,0 @@ -# Copyright (c) 2023 - 2026 Chair for Design Automation, TUM -# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH -# All rights reserved. -# -# SPDX-License-Identifier: MIT -# -# Licensed under the MIT License - -"""Registry of program serializers for QDMI program formats. - -A QDMI device accepts a program in one or more *program formats*, listed by -:class:`~mqt.core.qdmi.ProgramFormat`. A *program serializer* turns one Qiskit -:class:`~qiskit.circuit.QuantumCircuit` into one program in one such format. - -A format fixes the kind of payload it carries, so there are two signatures. A -text format takes a :class:`TextProgramSerializer`, which returns :class:`str`. -A binary format takes a :class:`BinaryProgramSerializer`, which returns -:class:`bytes`. :func:`~mqt.core.qdmi.is_binary_program_format` states which -kind a format carries. Two formats take no serializer at all, because a -serialized circuit is not what they carry; see :data:`NON_CIRCUIT_FORMATS`. - -MQT Core registers its own OpenQASM 2 and OpenQASM 3 serializers here. Every -other format belongs to the package that owns the device. Such a package -advertises its serializer through the ``mqt.core.qiskit.program_serializers`` -entry point group. The entry point name is the -:class:`~mqt.core.qdmi.ProgramFormat` member name, and the value points to the -serializer: - -```toml -[project.entry-points."mqt.core.qiskit.program_serializers"] -IQM_JSON = "iqm.qdmi.serializers:qiskit_to_iqm_json" -``` - -:func:`register_program_serializer` does the same at run time. A registration -takes precedence over an entry point for the same format. - -A device usually accepts several formats. :data:`PROGRAM_FORMAT_PREFERENCE` -records which one to use, from most to least preferred, and -:func:`preferred_program_formats` applies that order to the formats a device -reports. -""" - -from __future__ import annotations - -import warnings -from enum import Enum, auto -from importlib.metadata import entry_points -from typing import TYPE_CHECKING, Protocol - -from ...qdmi import ProgramFormat - -if TYPE_CHECKING: - from collections.abc import Callable, Iterable - from importlib.metadata import EntryPoint - - from qiskit.circuit import QuantumCircuit - - from .backend import QDMIBackend - -__all__ = [ - "ENTRY_POINT_GROUP", - "NON_CIRCUIT_FORMATS", - "PROGRAM_FORMAT_PREFERENCE", - "BinaryProgramSerializer", - "ProgramSerializer", - "TextProgramSerializer", - "preferred_program_formats", - "program_serializer", - "register_program_serializer", - "unregister_program_serializer", -] - - -def __dir__() -> list[str]: - return __all__ - - -#: Entry point group through which a package advertises its program serializers. -ENTRY_POINT_GROUP = "mqt.core.qiskit.program_serializers" - - -class TextProgramSerializer(Protocol): - """Serializes a circuit into a program format whose payload is text.""" - - def __call__(self, circuit: QuantumCircuit, backend: QDMIBackend, /) -> str: - """Serialize a circuit into a program string. - - Args: - circuit: The circuit to serialize. It has no unbound parameters. - backend: The backend that runs the circuit. Its ``device`` property - provides the site names and metadata a format may need, and its - ``target`` property provides the supported operations. - - Returns: - The program in the serializer's format. - - Raises: - UnsupportedOperationError: If the circuit contains an operation the - format cannot express. - TranslationError: If serialization fails for another reason. - """ - ... - - -class BinaryProgramSerializer(Protocol): - """Serializes a circuit into a program format whose payload is binary.""" - - def __call__(self, circuit: QuantumCircuit, backend: QDMIBackend, /) -> bytes: - """Serialize a circuit into program bytes. - - Args: - circuit: The circuit to serialize. It has no unbound parameters. - backend: The backend that runs the circuit. Its ``device`` property - provides the site names and metadata a format may need, and its - ``target`` property provides the supported operations. - - Returns: - The program in the serializer's format. - - Raises: - UnsupportedOperationError: If the circuit contains an operation the - format cannot express. - TranslationError: If serialization fails for another reason. - """ - ... - - -#: A serializer for one program format, text or binary. -ProgramSerializer = TextProgramSerializer | BinaryProgramSerializer - -#: The program formats that no program serializer can produce. A serializer -#: turns one Qiskit circuit into one program, and neither of these carries such -#: a program: ``CALIBRATION`` asks the device to run a calibration routine, and -#: ``BATCH_JOB`` carries a list of already-created jobs. This states what a -#: circuit can be serialized into, which is a question about this adapter -#: rather than about what -#: :meth:`~mqt.core.qdmi.Device.submit_job` accepts. -NON_CIRCUIT_FORMATS: frozenset[ProgramFormat] = frozenset({ - ProgramFormat.CALIBRATION, - ProgramFormat.BATCH_JOB, -}) - -#: The program formats in the order the backend prefers them, most preferred -#: first. A device-native format comes first, because a package that registers a -#: serializer for its own device's format wants that format used. 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, because it decides how the -#: program travels rather than what it may say. ``CALIBRATION`` and -#: ``BATCH_JOB`` are absent because a serialized circuit is not what they carry. -PROGRAM_FORMAT_PREFERENCE: tuple[ProgramFormat, ...] = ( - ProgramFormat.IQM_JSON, - ProgramFormat.CUSTOM1, - ProgramFormat.CUSTOM2, - ProgramFormat.CUSTOM3, - ProgramFormat.CUSTOM4, - ProgramFormat.CUSTOM5, - ProgramFormat.QIR_ADAPTIVE_MODULE, - ProgramFormat.QIR_ADAPTIVE_STRING, - ProgramFormat.QPY, - ProgramFormat.QASM3, - ProgramFormat.QIR_BASE_MODULE, - ProgramFormat.QIR_BASE_STRING, - ProgramFormat.QASM2, -) - - -class _LoadState(Enum): - """How far the registry has got with reading the entry points.""" - - NOT_STARTED = auto() - LOADING = auto() - LOADED = auto() - - -class _ProgramSerializerRegistry: - """The serializers for one process, and the entry points behind them. - - The registry reads :data:`ENTRY_POINT_GROUP` once, on the first lookup - rather than at import, because loading an entry point imports the package - that advertises it. The registry does not synchronize concurrent first use. - """ - - def __init__(self, discover: Callable[[], Iterable[EntryPoint]]) -> None: - """Initialize an empty registry. - - Args: - discover: Returns the entry points that advertise a serializer. - Injected so a test can supply its own without touching the - installed distributions. - """ - self._discover = discover - self._serializers: dict[ProgramFormat, ProgramSerializer] = {} - self._load_state = _LoadState.NOT_STARTED - - def register(self, fmt: ProgramFormat, serializer: ProgramSerializer, *, replace: bool = False) -> None: - """Add a serializer for one program format. - - Registering 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 the OpenQASM formats while the adapter is still - importing. - - Args: - fmt: The program format the serializer produces. - serializer: The serializer to add. - replace: Replace an existing serializer for the same format. - - Raises: - ValueError: If the format does not carry a serialized circuit, or if - the format already has a serializer and ``replace`` is false. - """ - if fmt in NON_CIRCUIT_FORMATS: - msg = f"{fmt.name} does not carry a serialized circuit, so it cannot have a program serializer." - raise ValueError(msg) - if not replace and fmt in self._serializers: - msg = f"A program serializer for {fmt.name} is already registered. Pass replace=True to override it." - raise ValueError(msg) - self._serializers[fmt] = serializer - - def unregister(self, fmt: ProgramFormat) -> None: - """Remove the serializer for one program format. - - Args: - fmt: The program format whose serializer to remove. A format without - a serializer is ignored. - """ - self._load_entry_points() - self._serializers.pop(fmt, None) - - def get(self, fmt: ProgramFormat) -> ProgramSerializer | None: - """Return the serializer for one program format. - - Args: - fmt: The program format to look up. - - Returns: - The serializer, or ``None`` if no package provides one. - """ - self._load_entry_points() - return self._serializers.get(fmt) - - def _load_entry_points(self) -> None: - """Read the entry points once and publish what they name. - - Loading an entry point imports a third-party module, which may call back - into this registry. Such a call sees the ``LOADING`` state and returns - without starting a second pass, so it observes the registrations made so - far and none of the discovery in flight. Publishing the discovered - serializers in one step at the end keeps that observation the same - whatever order the entry points arrive in. - """ - if self._load_state is not _LoadState.NOT_STARTED: - return - - self._load_state = _LoadState.LOADING - discovered: dict[ProgramFormat, ProgramSerializer] = {} - try: - for entry_point in self._discover(): - loaded = _ProgramSerializerRegistry._load_entry_point(entry_point) - if loaded is not None: - fmt, serializer = loaded - discovered.setdefault(fmt, serializer) - except BaseException: - # Discovery did not finish, so leave the registry cold. A later - # lookup tries again rather than reporting an empty result forever. - self._load_state = _LoadState.NOT_STARTED - raise - - # A registration made before or during discovery keeps precedence. - for fmt, serializer in discovered.items(): - self._serializers.setdefault(fmt, serializer) - self._load_state = _LoadState.LOADED - - @staticmethod - def _load_entry_point(entry_point: EntryPoint) -> tuple[ProgramFormat, ProgramSerializer] | None: - """Resolve one entry point into a format and its serializer. - - An entry point that names an unknown program format, names a format in - :data:`NON_CIRCUIT_FORMATS`, or fails to load produces a warning and is - skipped, so one broken package cannot make every other serializer - unreachable. - - Args: - entry_point: The entry point to resolve. - - Returns: - The format and its serializer, or ``None`` if the entry point is - unusable. - """ - try: - fmt = ProgramFormat[entry_point.name] - except KeyError: - warnings.warn( - f"Entry point '{entry_point.name}' in group '{ENTRY_POINT_GROUP}' does not name a program format " - f"and will be skipped.", - UserWarning, - stacklevel=2, - ) - return None - - if fmt in NON_CIRCUIT_FORMATS: - warnings.warn( - f"Entry point '{entry_point.name}' in group '{ENTRY_POINT_GROUP}' names a program format that " - f"does not carry a serialized circuit and will be skipped.", - UserWarning, - stacklevel=2, - ) - return None - - try: - serializer = entry_point.load() - except Exception as exc: # ruff:ignore[blind-except] One bad package must not break the others - warnings.warn( - f"Failed to load the program serializer for {fmt.name} from '{entry_point.value}': {exc}", - UserWarning, - stacklevel=2, - ) - return None - - return fmt, serializer - - -_REGISTRY = _ProgramSerializerRegistry(lambda: entry_points(group=ENTRY_POINT_GROUP)) - - -def register_program_serializer(fmt: ProgramFormat, serializer: ProgramSerializer, *, replace: bool = False) -> None: - """Register a serializer for one program format. - - Args: - fmt: The program format the serializer produces. - serializer: The serializer to register. It must return :class:`str` for - a text format and :class:`bytes` for a binary format. - replace: Replace an existing serializer for the same format. - - Raises: - ValueError: If the format does not carry a serialized circuit, or if the - format already has a serializer and ``replace`` is false. Raised by - the registry this function delegates to. - """ # ruff:ignore[docstring-extraneous-exception] The delegate raises it, and a caller must know - _REGISTRY.register(fmt, serializer, replace=replace) - - -def unregister_program_serializer(fmt: ProgramFormat) -> None: - """Remove the serializer for one program format. - - Args: - fmt: The program format whose serializer to remove. A format without a - serializer is ignored. - """ - _REGISTRY.unregister(fmt) - - -def program_serializer(fmt: ProgramFormat) -> ProgramSerializer | None: - """Return the serializer for one program format. - - Args: - fmt: The program format to look up. - - Returns: - The registered serializer, or ``None`` if no package provides one. - """ - return _REGISTRY.get(fmt) - - -def preferred_program_formats(formats: Iterable[ProgramFormat]) -> list[ProgramFormat]: - """Order the program formats a device reports by :data:`PROGRAM_FORMAT_PREFERENCE`. - - Args: - formats: The program formats the device accepts. - - Returns: - Those of the given formats that can carry a serialized circuit, most - preferred first. A format that :data:`PROGRAM_FORMAT_PREFERENCE` does - not name comes after every format it does name, in the order it was - given. - """ - ranks = {fmt: rank for rank, fmt in enumerate(PROGRAM_FORMAT_PREFERENCE)} - unranked = len(PROGRAM_FORMAT_PREFERENCE) - candidates = [fmt for fmt in formats if fmt not in NON_CIRCUIT_FORMATS] - return sorted(candidates, key=lambda fmt: ranks.get(fmt, unranked)) diff --git a/python/mqt/core/qdmi/__init__.pyi b/python/mqt/core/qdmi/__init__.pyi index 04ad90bf02..3e44595127 100644 --- a/python/mqt/core/qdmi/__init__.pyi +++ b/python/mqt/core/qdmi/__init__.pyi @@ -10,7 +10,7 @@ import enum from collections.abc import Sequence -from typing import overload +from typing import Final, overload from mqt.core.qdmi import driver as driver from mqt.core.qdmi import slurm as slurm @@ -40,6 +40,12 @@ class Job: def get_counts(self) -> dict[str, int]: """Returns the measurement counts from the job.""" + def get_results(self, result: Job.Result) -> bytes: + """Returns one result as exact bytes.""" + + def get_program_output(self) -> bytes: + """Returns the exact format-defined program output bytes.""" + def get_dense_statevector(self) -> list[complex]: """Returns the dense statevector from the job (typically only available from simulator devices).""" @@ -138,46 +144,94 @@ class Job: FAILED = 6 -class ProgramFormat(enum.Enum): - """Enumeration of program formats.""" + class Result(enum.Enum): + """One raw job result format.""" + + SHOTS = 0 - QASM2 = 0 + HIST_KEYS = 1 - QASM3 = 1 + HIST_VALUES = 2 - QIR_BASE_STRING = 2 + STATEVECTOR_DENSE = 3 - QIR_BASE_MODULE = 3 + PROBABILITIES_DENSE = 4 - QIR_ADAPTIVE_STRING = 4 + STATEVECTOR_SPARSE_KEYS = 5 - QIR_ADAPTIVE_MODULE = 5 + STATEVECTOR_SPARSE_VALUES = 6 - CALIBRATION = 6 + PROBABILITIES_SPARSE_KEYS = 7 - QPY = 7 + PROBABILITIES_SPARSE_VALUES = 8 - IQM_JSON = 8 + PROGRAM_OUTPUT = 9 - BATCH_JOB = 9 +class ProgramEncoding(enum.Enum): + """Program payload encoding.""" - CUSTOM1 = 999999995 + TEXT = 1 - CUSTOM2 = 999999996 + BINARY = 2 - CUSTOM3 = 999999997 +class ProgramFormat: + """The exact format, version, profile, and encoding of a payload.""" - CUSTOM4 = 999999998 + def __init__( + self, + format_id: str, + version: tuple[int, int, int], + profile: str = "", + encoding: ProgramEncoding = ProgramEncoding.TEXT, + ) -> None: ... + @property + def format_id(self) -> str: ... + @property + def version(self) -> tuple[int, int, int]: ... + @property + def profile(self) -> str: ... + @property + def encoding(self) -> ProgramEncoding: ... + def __eq__(self, arg: object, /) -> bool: ... + def __hash__(self) -> int: ... - CUSTOM5 = 999999999 + OPENQASM2: Final[ProgramFormat] = ... + """The canonical OpenQASM 2.0 text format.""" + + OPENQASM3: Final[ProgramFormat] = ... + """The canonical OpenQASM 3.0 text format.""" + + QIR21_BASE_TEXT: Final[ProgramFormat] = ... + """The canonical QIR 2.1 Base Profile text format.""" + + QIR21_BASE_BINARY: Final[ProgramFormat] = ... + """The canonical QIR 2.1 Base Profile binary format.""" + + QIR21_ADAPTIVE_TEXT: Final[ProgramFormat] = ... + """The canonical QIR 2.1 Adaptive Profile text format.""" + + QIR21_ADAPTIVE_BINARY: Final[ProgramFormat] = ... + """The canonical QIR 2.1 Adaptive Profile binary format.""" + +class ProgramFeature: + """One exact feature or constraint record for a program format.""" + + @property + def id(self) -> str: ... + @property + def value(self) -> int: ... + @property + def constraint_id(self) -> str: ... + @property + def constraint_value(self) -> int: ... + def __eq__(self, arg: object, /) -> bool: ... + def __hash__(self) -> int: ... def is_binary_program_format(program_format: ProgramFormat) -> bool: """Returns whether a program format carries a binary payload. - ``QIR_BASE_MODULE``, ``QIR_ADAPTIVE_MODULE``, and ``QPY`` hold bitcode or - another serialized object. Such a payload may contain a null byte and is not - text, so the device must receive it as exact bytes. Pass ``bytes`` to - :meth:`Device.submit_job` for these formats and ``str`` for the others. + Binary payloads may contain null bytes. Pass ``bytes`` to + :meth:`Device.submit_job` for binary descriptors and ``str`` for text. Args: program_format: The program format to classify. @@ -271,6 +325,9 @@ class Device: def supported_program_formats(self) -> list[ProgramFormat]: """Returns the list of program formats supported by the device.""" + def try_program_features(self, program_format: ProgramFormat) -> list[ProgramFeature] | None: + """Returns the complete optional capability list for an exact payload, or None when the metadata is unknown.""" + def child_devices(self) -> list[Device]: """Returns the direct child devices managed by this device.""" @@ -332,24 +389,6 @@ class Device: ) -> Job: """Submits an exact byte payload to the device.""" - def submit_calibration_job( - self, - program: str | bytes | None = None, - *, - custom1: str | bool | float | None = None, - custom2: str | bool | float | None = None, - custom3: str | bool | float | None = None, - custom4: str | bool | float | None = None, - custom5: str | bool | float | None = None, - ) -> Job: - """Triggers a calibration run on the device. - - QDMI does not require a program for a calibration run, so ``program`` is - optional and may be a string or bytes. When it is given, the device defines - what it means, which is usually a configuration for the run. A calibration run - executes no circuit, so it takes no shot count. - """ - def retrieve_job_by_id(self, job_id: str) -> Job: """Retrieves an existing job by its device-provided ID.""" diff --git a/src/qdmi/Client.cpp b/src/qdmi/Client.cpp index f960114667..8e8220d607 100644 --- a/src/qdmi/Client.cpp +++ b/src/qdmi/Client.cpp @@ -10,6 +10,7 @@ #include "qdmi/Client.hpp" +#include "qdmi/ProgramFormat.hpp" #include "qdmi/common/Common.hpp" #include "qdmi/common/Diagnostics.hpp" #include "qdmi/driver/Driver.hpp" @@ -38,20 +39,17 @@ namespace qdmi { namespace { -/// Rejects the formats that `submitJob` cannot carry. -/// A batch job's program is a list of job handles rather than a byte blob, so -/// this API cannot express it at all. A calibration run has its own entry -/// point, because its payload is optional and it takes no shot count. -void rejectUnsupportedProgramFormat(const QDMI_Program_Format format) { - if (format == QDMI_PROGRAM_FORMAT_BATCHJOB) { - throw std::invalid_argument( - "MQT Core does not support batch jobs. A batch job's program is a list " - "of job handles, which this API cannot express"); +void validateProgramFormatResult(const QDMI_Program_Format& format) { + if (!qdmi::isValidProgramFormat(format)) { + throw std::runtime_error( + "QDMI provider returned an invalid program format"); } - if (format == QDMI_PROGRAM_FORMAT_CALIBRATION) { - throw std::invalid_argument( - "Use submitCalibrationJob (submit_calibration_job in Python) to " - "trigger a calibration run"); +} + +void validateProgramFeatureResult(const QDMI_Program_Feature& feature) { + if (!qdmi::isValidProgramFeature(feature)) { + throw std::runtime_error( + "QDMI provider returned an invalid program feature"); } } } // namespace @@ -330,8 +328,44 @@ std::optional Device::getMinAtomDistance() const { } std::vector Device::getSupportedProgramFormats() const { - return queryProperty>( + auto formats = queryProperty>( QDMI_DEVICE_PROPERTY_SUPPORTEDPROGRAMFORMATS); + std::ranges::for_each(formats, validateProgramFormatResult); + return formats; +} + +std::optional> +Device::tryGetSupportedProgramFormats() const { + auto formats = queryProperty>>( + QDMI_DEVICE_PROPERTY_SUPPORTEDPROGRAMFORMATS); + if (formats) { + std::ranges::for_each(*formats, validateProgramFormatResult); + } + return formats; +} + +std::optional> +Device::tryGetProgramFeatures(const QDMI_Program_Format& format) const { + size_t size = 0U; + auto const result = QDMI_device_query_program_features(device_.get(), &format, + 0U, nullptr, &size); + if (result == QDMI_ERROR_NOTSUPPORTED) { + return std::nullopt; + } + qdmi::throwIfError(result, "Querying program feature size"); + if (size % sizeof(QDMI_Program_Feature) != 0U) { + throw std::runtime_error("Invalid program feature list size"); + } + std::vector features(size / + sizeof(QDMI_Program_Feature)); + if (size != 0U) { + qdmi::throwIfError( + QDMI_device_query_program_features(device_.get(), &format, size, + features.data(), nullptr), + "Querying program features"); + } + std::ranges::for_each(features, validateProgramFeatureResult); + return features; } std::vector Device::getChildDevices() const { @@ -375,7 +409,6 @@ Job Device::submitJob(const std::string& program, throw std::invalid_argument( "Binary program formats require exact-byte submission"); } - rejectUnsupportedProgramFormat(format); const auto bytes = std::as_bytes( std::span(program.c_str(), static_cast(program.size() + 1))); @@ -394,7 +427,6 @@ Job Device::submitJob(const std::string& program, throw std::invalid_argument( "Binary program formats require exact-byte submission"); } - rejectUnsupportedProgramFormat(format); const auto bytes = std::as_bytes( std::span(program.c_str(), static_cast(program.size() + 1))); @@ -408,7 +440,6 @@ Job Device::submitJob(const std::span program, const std::optional& custom3, const std::optional& custom4, const std::optional& custom5) const { - rejectUnsupportedProgramFormat(format); return submitJobImpl(format, program, numShots, custom1, custom2, custom3, custom4, custom5); @@ -421,15 +452,13 @@ Job Device::submitJob(const std::span program, const std::optional& custom3, const std::optional& custom4, const std::optional& custom5) const { - rejectUnsupportedProgramFormat(format); return submitJobImpl(format, program, std::nullopt, custom1, custom2, custom3, custom4, custom5); } Job Device::submitJobImpl( - const QDMI_Program_Format format, - const std::optional> program, + const QDMI_Program_Format format, const std::span program, const std::optional numShots, const std::optional& custom1, const std::optional& custom2, @@ -440,24 +469,20 @@ Job Device::submitJobImpl( qdmi::throwIfError(QDMI_device_create_job(device_.get(), &job), "Creating job"); Job jobWrapper{job, device_}; - qdmi::throwIfError(QDMI_job_set_parameter(jobWrapper, QDMI_JOB_PARAMETER_PROGRAMFORMAT, sizeof(format), &format), "Setting program format"); - if (program.has_value()) { - qdmi::throwIfError(QDMI_job_set_parameter(jobWrapper, - QDMI_JOB_PARAMETER_PROGRAM, - program->size(), program->data()), - "Setting program"); - } + qdmi::throwIfError(QDMI_job_set_parameter(jobWrapper, + QDMI_JOB_PARAMETER_PROGRAM, + program.size(), program.data()), + "Setting program"); if (numShots.has_value()) { qdmi::throwIfError(QDMI_job_set_parameter(jobWrapper, QDMI_JOB_PARAMETER_SHOTSNUM, sizeof(*numShots), &*numShots), "Setting number of shots"); } - if (custom1.has_value()) { setCustomJobParam(jobWrapper, QDMI_JOB_PARAMETER_CUSTOM1, *custom1); } @@ -478,32 +503,6 @@ Job Device::submitJobImpl( return jobWrapper; } -Job Device::submitCalibrationJob( - const std::optional> program, - const std::optional& custom1, - const std::optional& custom2, - const std::optional& custom3, - const std::optional& custom4, - const std::optional& custom5) const { - const auto payload = - program.has_value() && !program->empty() ? program : std::nullopt; - return submitJobImpl(QDMI_PROGRAM_FORMAT_CALIBRATION, payload, std::nullopt, - custom1, custom2, custom3, custom4, custom5); -} - -Job Device::submitCalibrationJob( - const std::string& program, - const std::optional& custom1, - const std::optional& custom2, - const std::optional& custom3, - const std::optional& custom4, - const std::optional& custom5) const { - const auto bytes = std::as_bytes( - std::span(program.c_str(), static_cast(program.size() + 1))); - return submitCalibrationJob(bytes, custom1, custom2, custom3, custom4, - custom5); -} - Job Device::retrieveJobById(const std::string_view jobId) const { const std::string id{jobId}; QDMI_Job job = nullptr; @@ -572,11 +571,11 @@ std::string Job::getId() const { qdmi::throwIfError(QDMI_job_query_property(job_.get(), QDMI_JOB_PROPERTY_ID, 0, nullptr, &size), "Querying job ID size"); - std::string id(size - 1, '\0'); + std::string id(size, '\0'); qdmi::throwIfError(QDMI_job_query_property(job_.get(), QDMI_JOB_PROPERTY_ID, size, id.data(), nullptr), "Querying job ID"); - return id; + return detail::decodeText(std::move(id), "Job ID"); } QDMI_Program_Format Job::getProgramFormat() const { @@ -585,6 +584,7 @@ QDMI_Program_Format Job::getProgramFormat() const { QDMI_JOB_PROPERTY_PROGRAMFORMAT, sizeof(format), &format, nullptr), "Querying program format"); + validateProgramFormatResult(format); return format; } @@ -613,12 +613,7 @@ std::string Job::getProgram() const { } const auto program = getProgramBytes(); - if (program.empty() || program.back() != std::byte{0}) { - throw std::invalid_argument( - "Cannot decode program as a null-terminated string; use " - "getProgramBytes() for binary payloads"); - } - return {reinterpret_cast(program.data()), program.size() - 1}; + return detail::decodeText(program, "Program"); } size_t Job::getNumShots() const { @@ -638,6 +633,20 @@ std::optional Job::getQueuePosition() const { return detail::queuePositionFromResult(result, queuePosition); } +std::vector Job::getResults(const QDMI_Job_Result result) const { + size_t size = 0U; + qdmi::throwIfError( + QDMI_job_get_results(job_.get(), result, 0U, nullptr, &size), + "Querying result size"); + std::vector value(size); + if (size != 0U) { + qdmi::throwIfError( + QDMI_job_get_results(job_.get(), result, size, value.data(), nullptr), + "Querying result"); + } + return value; +} + std::vector Job::getShots() const { size_t shotsSize = 0; qdmi::throwIfError(QDMI_job_get_results(job_.get(), QDMI_JOB_RESULT_SHOTS, 0, @@ -652,9 +661,17 @@ std::vector Job::getShots() const { qdmi::throwIfError(QDMI_job_get_results(job_.get(), QDMI_JOB_RESULT_SHOTS, shotsSize, shots.data(), nullptr), "Querying shots"); - shots.pop_back(); + shots = detail::decodeText(std::move(shots), "Shots result"); + + /// Nonempty rows delimit themselves; zero-output shots need the job count. + const size_t numShots = + shots.empty() ? getNumShots() + : static_cast(std::ranges::count(shots, ',')) + 1U; + return detail::parseShots(shots, numShots); +} - return detail::parseShots(shots, getNumShots()); +std::vector Job::getProgramOutput() const { + return getResults(QDMI_JOB_RESULT_PROGRAMOUTPUT); } std::map Job::getCounts() const { @@ -672,7 +689,7 @@ std::map Job::getCounts() const { qdmi::throwIfError(QDMI_job_get_results(job_.get(), QDMI_JOB_RESULT_HIST_KEYS, keysSize, keys.data(), nullptr), "Querying histogram keys"); - keys.pop_back(); + keys = detail::decodeText(std::move(keys), "Histogram keys result"); // Get the histogram values size_t valuesSize = 0; @@ -772,7 +789,7 @@ std::map> Job::getSparseStateVector() const { QDMI_job_get_results(job_.get(), QDMI_JOB_RESULT_STATEVECTOR_SPARSE_KEYS, keysSize, keys.data(), nullptr), "Querying sparse state vector keys"); - keys.pop_back(); + keys = detail::decodeText(std::move(keys), "Sparse state vector keys result"); size_t valuesSize = 0; qdmi::throwIfError(QDMI_job_get_results( @@ -832,7 +849,8 @@ std::map Job::getSparseProbabilities() const { job_.get(), QDMI_JOB_RESULT_PROBABILITIES_SPARSE_KEYS, keysSize, keys.data(), nullptr), "Querying sparse probabilities keys"); - keys.pop_back(); + keys = + detail::decodeText(std::move(keys), "Sparse probabilities keys result"); size_t valuesSize = 0; qdmi::throwIfError( diff --git a/src/qdmi/devices/dd/Device.cpp b/src/qdmi/devices/dd/Device.cpp index 16d61150b5..2f7009d499 100644 --- a/src/qdmi/devices/dd/Device.cpp +++ b/src/qdmi/devices/dd/Device.cpp @@ -21,6 +21,7 @@ #include "mlir/Dialect/QIR/Execution/JIT/Session.h" #include "mlir/Dialect/QIR/Execution/Runtime/Runtime.h" #include "mqt_ddsim_qdmi/device.h" +#include "qdmi/ProgramFormat.hpp" #include "qdmi/common/Common.hpp" #include @@ -56,6 +57,7 @@ namespace { constexpr uintptr_t OFFSET = 0x10000U; + template constexpr std::array iotaArray() { std::array result{}; std::iota(result.begin(), result.end(), OFFSET); @@ -175,12 +177,9 @@ makeOperationAddresses(const std::array& ops) { constexpr auto OPERATION_ADDRESSES = makeOperationAddresses(OPERATIONS); constexpr std::array SUPPORTED_PROGRAM_FORMATS = { - QDMI_PROGRAM_FORMAT_QASM2, - QDMI_PROGRAM_FORMAT_QASM3, - QDMI_PROGRAM_FORMAT_QIRBASESTRING, - QDMI_PROGRAM_FORMAT_QIRBASEMODULE, - QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING, - QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE, + qdmi::OPENQASM2, qdmi::OPENQASM3, + qdmi::QIR21_BASE_TEXT, qdmi::QIR21_BASE_BINARY, + qdmi::QIR21_ADAPTIVE_TEXT, qdmi::QIR21_ADAPTIVE_BINARY, }; [[nodiscard]] auto parseQASMToQCO(const std::string_view source) @@ -202,6 +201,38 @@ constexpr std::array SUPPORTED_PROGRAM_FORMATS = { } return QDMI_SUCCESS; } +constexpr std::array QASM3_FEATURES = { + QDMI_Program_Feature{ + .id = QDMI_PROGRAM_FEATURE_MID_CIRCUIT_MEASUREMENT, + .value = 0, + .constraint_id = {}, + .constraint_value = 0, + }, + QDMI_Program_Feature{ + .id = QDMI_PROGRAM_FEATURE_MEASURED_QUBIT_REUSE, + .value = 0, + .constraint_id = {}, + .constraint_value = 0, + }, + QDMI_Program_Feature{ + .id = QDMI_PROGRAM_FEATURE_MEASUREMENT_RESULT_USE, + .value = 0, + .constraint_id = {}, + .constraint_value = 0, + }, + QDMI_Program_Feature{ + .id = QDMI_PROGRAM_FEATURE_BOOLEAN_COMPUTATION, + .value = 0, + .constraint_id = {}, + .constraint_value = 0, + }, + QDMI_Program_Feature{ + .id = QDMI_PROGRAM_FEATURE_FORWARD_BRANCHING, + .value = 0, + .constraint_id = {}, + .constraint_value = 0, + }, +}; } // namespace @@ -248,7 +279,7 @@ auto Device::queryProperty(const QDMI_Device_Property prop, const size_t size, status_.load(), prop, size, value, sizeRet) ADD_SINGLE_VALUE_PROPERTY(QDMI_DEVICE_PROPERTY_QUBITSNUM, size_t, qubitsNum_, prop, size, value, sizeRet) - // This device never needs calibration + /// This device never needs calibration. ADD_SINGLE_VALUE_PROPERTY(QDMI_DEVICE_PROPERTY_NEEDSCALIBRATION, size_t, 0, prop, size, value, sizeRet) // This device does not support pulse-level control @@ -300,6 +331,40 @@ auto Device::decreaseRunningJobs() -> void { } // namespace qdmi::dd +auto MQT_DDSIM_QDMI_Device_Session_impl_d::queryProgramFeatures( + const QDMI_Program_Format& format, const size_t size, + QDMI_Program_Feature* value, size_t* sizeRet) const -> QDMI_STATUS { + if (!qdmi::isValidProgramFormat(format)) { + return QDMI_ERROR_INVALIDARGUMENT; + } + if (status_ != Status::INITIALIZED) { + return QDMI_ERROR_BADSTATE; + } + if (std::ranges::none_of(SUPPORTED_PROGRAM_FORMATS, [&](const auto& item) { + return qdmi::equal(format, item); + })) { + return QDMI_ERROR_NOTSUPPORTED; + } + if (!qdmi::equal(format, qdmi::OPENQASM3)) { + if (sizeRet != nullptr) { + *sizeRet = 0U; + } + return QDMI_SUCCESS; + } + const size_t required = sizeof(QASM3_FEATURES); + if (sizeRet != nullptr) { + *sizeRet = required; + } + if (value == nullptr) { + return QDMI_SUCCESS; + } + if (size < required) { + return QDMI_ERROR_INVALIDARGUMENT; + } + std::ranges::copy(QASM3_FEATURES, value); + return QDMI_SUCCESS; +} + auto MQT_DDSIM_QDMI_Device_Session_impl_d::init() -> QDMI_STATUS { if (status_ != Status::ALLOCATED) { return QDMI_ERROR_BADSTATE; @@ -421,43 +486,50 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::setParameter( switch (param) { case QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT: if (value != nullptr) { + if (size != sizeof(QDMI_Program_Format)) { + return QDMI_ERROR_INVALIDARGUMENT; + } const auto format = *static_cast(value); - if (IS_INVALID_ARGUMENT(format, QDMI_PROGRAM_FORMAT)) { + if (!qdmi::isValidProgramFormat(format)) { return QDMI_ERROR_INVALIDARGUMENT; } - if (std::ranges::find(SUPPORTED_PROGRAM_FORMATS, format) == - SUPPORTED_PROGRAM_FORMATS.end()) { + if (std::ranges::none_of(SUPPORTED_PROGRAM_FORMATS, + [&](const auto& supported) { + return qdmi::equal(format, supported); + })) { return QDMI_ERROR_NOTSUPPORTED; } + if (!qdmi::equal(format_, format)) { + hasProgram_ = false; + } format_ = format; + hasFormat_ = true; } return QDMI_SUCCESS; - case QDMI_DEVICE_JOB_PARAMETER_PROGRAM: - if (value != nullptr) { - const bool isTextProgramFormat = - format_ == QDMI_PROGRAM_FORMAT_QASM2 || - format_ == QDMI_PROGRAM_FORMAT_QASM3 || - format_ == QDMI_PROGRAM_FORMAT_QIRBASESTRING || - format_ == QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING; - if (isTextProgramFormat) { - // Text payloads include the trailing '\0' in `size`. - // Strip it so it is not counted in the stored string's size. - const std::span text{static_cast(value), size}; - if (text.empty() || text.back() != '\0') { - return QDMI_ERROR_INVALIDARGUMENT; - } - const auto contents = text.first(text.size() - 1); - if (std::ranges::find(contents, '\0') != contents.end()) { - return QDMI_ERROR_INVALIDARGUMENT; - } - program_ = std::string(contents.begin(), contents.end()); - } else { - // Binary payloads are stored exactly as received. - const std::span bytes(static_cast(value), size); - program_ = std::vector(bytes.begin(), bytes.end()); + case QDMI_DEVICE_JOB_PARAMETER_PROGRAM: { + if (value == nullptr) { + return QDMI_SUCCESS; + } + if (!hasFormat_) { + return QDMI_ERROR_BADSTATE; + } + if (qdmi::isBinaryProgramFormat(format_)) { + const std::span bytes{static_cast(value), size}; + program_ = std::vector(bytes.begin(), bytes.end()); + } else { + const std::span text{static_cast(value), size}; + if (text.back() != '\0') { + return QDMI_ERROR_INVALIDARGUMENT; + } + const auto contents = text.first(text.size() - 1U); + if (std::ranges::find(contents, '\0') != contents.end()) { + return QDMI_ERROR_INVALIDARGUMENT; } + program_ = std::string(contents.begin(), contents.end()); } + hasProgram_ = true; return QDMI_SUCCESS; + } case QDMI_DEVICE_JOB_PARAMETER_SHOTSNUM: if (value != nullptr) { numShots_ = *static_cast(value); @@ -476,6 +548,7 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::setParameter( return QDMI_ERROR_NOTSUPPORTED; } } + auto MQT_DDSIM_QDMI_Device_Job_impl_d::queryProperty( // NOLINTNEXTLINE(readability-non-const-parameter) const QDMI_Device_Job_Property prop, const size_t size, void* value, @@ -487,9 +560,17 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::queryProperty( const auto str = std::to_string(id_); ADD_STRING_PROPERTY(QDMI_DEVICE_JOB_PROPERTY_ID, str.c_str(), prop, size, value, sizeRet) - ADD_SINGLE_VALUE_PROPERTY(QDMI_DEVICE_JOB_PROPERTY_PROGRAMFORMAT, - QDMI_Program_Format, format_, prop, size, value, - sizeRet) + if (prop == QDMI_DEVICE_JOB_PROPERTY_PROGRAMFORMAT) { + if (!hasFormat_) { + return QDMI_ERROR_BADSTATE; + } + ADD_SINGLE_VALUE_PROPERTY(QDMI_DEVICE_JOB_PROPERTY_PROGRAMFORMAT, + QDMI_Program_Format, format_, prop, size, value, + sizeRet) + } + if (prop == QDMI_DEVICE_JOB_PROPERTY_PROGRAM && !hasProgram_) { + return QDMI_ERROR_BADSTATE; + } if (std::holds_alternative(program_)) { const auto& text = std::get(program_); ADD_STRING_PROPERTY(QDMI_DEVICE_JOB_PROPERTY_PROGRAM, text.c_str(), prop, @@ -603,6 +684,7 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::submitQIRProgramSampling() shots_.push_back(runtime.getMeasurements()); ++counts_[shots_.back()]; } + programOutput_ = output.str(); return true; }); } @@ -611,8 +693,8 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::submitQIRProgramStateExtraction() // State extraction stops the entry point at its first irreversible operation. // This preserves Base Profile semantics because measurements are terminal, // whereas Adaptive Profile measurements may feed later quantum control. - if (format_ != QDMI_PROGRAM_FORMAT_QIRBASEMODULE && - format_ != QDMI_PROGRAM_FORMAT_QIRBASESTRING) { + if (!qdmi::equal(format_, qdmi::QIR21_BASE_BINARY) && + !qdmi::equal(format_, qdmi::QIR21_BASE_TEXT)) { return QDMI_ERROR_NOTSUPPORTED; } return submitProgramAsync([this] { @@ -641,9 +723,12 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::submit() -> QDMI_STATUS { if (status_.load() != QDMI_JOB_STATUS_CREATED) { return QDMI_ERROR_BADSTATE; } + if (!hasFormat_ || !hasProgram_) { + return QDMI_ERROR_BADSTATE; + } status_.store(QDMI_JOB_STATUS_SUBMITTED); - if (format_ == QDMI_PROGRAM_FORMAT_QASM2 || - format_ == QDMI_PROGRAM_FORMAT_QASM3) { + if (qdmi::equal(format_, qdmi::OPENQASM2) || + qdmi::equal(format_, qdmi::OPENQASM3)) { return submitQASMProgram(); } return submitQIRProgram(); @@ -939,6 +1024,23 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::getResults(const QDMI_Job_Result result, return QDMI_ERROR_INVALIDARGUMENT; } return getProbabilities(size, data, sizeRet); + case QDMI_JOB_RESULT_PROGRAMOUTPUT: { + if (programOutput_.empty()) { + return QDMI_ERROR_NOTSUPPORTED; + } + const auto required = programOutput_.size(); + if (sizeRet != nullptr) { + *sizeRet = required; + } + if (data == nullptr) { + return QDMI_SUCCESS; + } + if (size < required) { + return QDMI_ERROR_INVALIDARGUMENT; + } + std::memcpy(data, programOutput_.data(), required); + return QDMI_SUCCESS; + } default: return QDMI_ERROR_NOTSUPPORTED; } @@ -1064,6 +1166,15 @@ int MQT_DDSIM_QDMI_device_session_query_device_property( return session->queryDeviceProperty(prop, size, value, size_ret); } +int MQT_DDSIM_QDMI_device_session_query_program_features( + MQT_DDSIM_QDMI_Device_Session session, const QDMI_Program_Format* format, + const size_t size, QDMI_Program_Feature* value, size_t* sizeRet) { + if (session == nullptr || format == nullptr) { + return QDMI_ERROR_INVALIDARGUMENT; + } + return session->queryProgramFeatures(*format, size, value, sizeRet); +} + int MQT_DDSIM_QDMI_device_session_query_site_property( MQT_DDSIM_QDMI_Device_Session session, MQT_DDSIM_QDMI_Site site, const QDMI_Site_Property prop, const size_t size, void* value, diff --git a/src/qdmi/devices/sc/Device.cpp b/src/qdmi/devices/sc/Device.cpp index 3b1b01ea2b..d675b1ff84 100644 --- a/src/qdmi/devices/sc/Device.cpp +++ b/src/qdmi/devices/sc/Device.cpp @@ -17,6 +17,7 @@ #include "mqt_sc_qdmi/constants.h" #include "mqt_sc_qdmi/device.h" #include "mqt_sc_qdmi/types.h" +#include "qdmi/ProgramFormat.hpp" #include "qdmi/common/Common.hpp" #include "qdmi/common/DeviceConfiguration.hpp" #include "qdmi/common/Diagnostics.hpp" @@ -422,6 +423,7 @@ int MQT_SC_QDMI_Device_Job_impl_d::setParameter( return QDMI_ERROR_NOTSUPPORTED; } // NOLINTNEXTLINE(readability-convert-member-functions-to-static) +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) int MQT_SC_QDMI_Device_Job_impl_d::queryProperty( const QDMI_Device_Job_Property property, const size_t size, void* value, size_t* /*sizeRet*/) { @@ -535,6 +537,25 @@ int MQT_SC_QDMI_device_session_query_device_property( ? QDMI_ERROR_INVALIDARGUMENT : session->queryDeviceProperty(property, size, value, sizeRet); } +int MQT_SC_QDMI_device_session_query_program_features( + MQT_SC_QDMI_Device_Session session, const QDMI_Program_Format* format, + [[maybe_unused]] const size_t size, + // NOLINTNEXTLINE(misc-const-correctness): QDMI C ABI output. + [[maybe_unused]] QDMI_Program_Feature* value, + // NOLINTNEXTLINE(misc-const-correctness): QDMI C ABI output. + [[maybe_unused]] size_t* sizeRet) { + if (session == nullptr || format == nullptr) { + return QDMI_ERROR_INVALIDARGUMENT; + } + if (!qdmi::isValidProgramFormat(*format)) { + return QDMI_ERROR_INVALIDARGUMENT; + } + if (session->status != + MQT_SC_QDMI_Device_Session_impl_d::Status::INITIALIZED) { + return QDMI_ERROR_BADSTATE; + } + return QDMI_ERROR_NOTSUPPORTED; +} int MQT_SC_QDMI_device_session_query_site_property( MQT_SC_QDMI_Device_Session session, MQT_SC_QDMI_Site site, const QDMI_Site_Property property, const size_t size, void* value, diff --git a/src/qdmi/driver/Driver.cpp b/src/qdmi/driver/Driver.cpp index dbcbfc8cc9..a83ed1d114 100644 --- a/src/qdmi/driver/Driver.cpp +++ b/src/qdmi/driver/Driver.cpp @@ -154,6 +154,7 @@ DynamicDeviceLibrary::DynamicDeviceLibrary(const std::string& libName, LOAD_DYNAMIC_SYMBOL(device_job_get_results) // device query interface LOAD_DYNAMIC_SYMBOL(device_session_query_device_property) + LOAD_DYNAMIC_SYMBOL(device_session_query_program_features) LOAD_DYNAMIC_SYMBOL(device_session_query_site_property) LOAD_DYNAMIC_SYMBOL(device_session_query_operation_property) // NOLINTEND(cppcoreguidelines-pro-type-reinterpret-cast) @@ -481,6 +482,14 @@ auto QDMI_Device_impl_d::querySiteProperty(QDMI_Site site, deviceSession_, site, prop, size, value, sizeRet); } +auto QDMI_Device_impl_d::queryProgramFeatures(const QDMI_Program_Format* format, + const size_t size, + QDMI_Program_Feature* value, + size_t* sizeRet) const -> int { + return library_->device_session_query_program_features(deviceSession_, format, + size, value, sizeRet); +} + auto QDMI_Device_impl_d::queryOperationProperty( QDMI_Operation operation, const size_t numSites, const QDMI_Site* sites, const size_t numParams, const double* params, QDMI_Operation_Property prop, @@ -494,10 +503,10 @@ namespace { [[nodiscard]] auto toDeviceJobParameter(const QDMI_Job_Parameter& param) -> QDMI_Device_Job_Parameter { switch (param) { - case QDMI_JOB_PARAMETER_PROGRAM: - return QDMI_DEVICE_JOB_PARAMETER_PROGRAM; case QDMI_JOB_PARAMETER_PROGRAMFORMAT: return QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT; + case QDMI_JOB_PARAMETER_PROGRAM: + return QDMI_DEVICE_JOB_PARAMETER_PROGRAM; case QDMI_JOB_PARAMETER_SHOTSNUM: return QDMI_DEVICE_JOB_PARAMETER_SHOTSNUM; case QDMI_JOB_PARAMETER_CUSTOM1: @@ -511,6 +520,9 @@ namespace { case QDMI_JOB_PARAMETER_CUSTOM5: return QDMI_DEVICE_JOB_PARAMETER_CUSTOM5; default: + if (qdmi::detail::isCustomValue(param)) { + return static_cast(param); + } return QDMI_DEVICE_JOB_PARAMETER_MAX; } } @@ -565,6 +577,9 @@ namespace { case QDMI_JOB_PROPERTY_CUSTOM5: return QDMI_DEVICE_JOB_PROPERTY_CUSTOM5; default: + if (qdmi::detail::isCustomValue(prop)) { + return static_cast(prop); + } return QDMI_DEVICE_JOB_PROPERTY_MAX; } } @@ -1002,6 +1017,17 @@ int QDMI_device_query_device_property(QDMI_Device device, return device->queryDeviceProperty(prop, size, value, sizeRet); } +int QDMI_device_query_program_features(QDMI_Device device, + const QDMI_Program_Format* format, + const size_t size, + QDMI_Program_Feature* value, + size_t* sizeRet) { + if (device == nullptr) { + return QDMI_ERROR_INVALIDARGUMENT; + } + return device->queryProgramFeatures(format, size, value, sizeRet); +} + int QDMI_device_query_site_property(QDMI_Device device, QDMI_Site site, QDMI_Site_Property prop, const size_t size, void* value, size_t* sizeRet) { diff --git a/test/python/plugins/qdmi_pennylane/helpers.py b/test/python/plugins/qdmi_pennylane/helpers.py index 2b8f41ee4c..76e4065e46 100644 --- a/test/python/plugins/qdmi_pennylane/helpers.py +++ b/test/python/plugins/qdmi_pennylane/helpers.py @@ -13,6 +13,7 @@ import math import re from collections import Counter +from types import SimpleNamespace from typing import TYPE_CHECKING, cast from unittest.mock import Mock @@ -55,7 +56,7 @@ def operation( def _standard_operations(program_format: ProgramFormat) -> list[QDMIDevice.Operation]: """Return the gate set used by execution tests.""" - qasm2 = program_format == ProgramFormat.QASM2 + qasm2 = program_format == ProgramFormat.OPENQASM2 return [ operation("id" if qasm2 else "i", 1), operation("x", 1), @@ -104,6 +105,7 @@ def __init__( coupling_map: Sequence[tuple[int, int]] | None = None, result_factory: Callable[[str, int], Sequence[str]] = bell_results, expose_shots: bool = True, + program_features: Sequence[object] | None = (), ) -> None: """Store the advertised capabilities and result behavior.""" self._operations = list(operations) @@ -112,6 +114,7 @@ def __init__( self._coupling_map = coupling_map self._result_factory = result_factory self._expose_shots = expose_shots + self._program_features = program_features self.submissions: list[tuple[str, ProgramFormat, int, Mapping[str, object]]] = [] self.events: list[str] = [] @@ -128,6 +131,17 @@ def supported_program_formats(self) -> list[ProgramFormat]: """Return the advertised program formats.""" return self._formats + def try_program_features(self, _program_format: ProgramFormat) -> list[object] | None: + """Return known feature records, or unknown metadata.""" + if self._program_features is None: + return None + return [ + SimpleNamespace(id=feature, value=0, constraint_id="", constraint_value=0) + if isinstance(feature, str) + else feature + for feature in self._program_features + ] + def qubits_num(self) -> int: """Return the device width.""" return self._qubits @@ -177,11 +191,12 @@ def cancel() -> None: def stub_device( *, - program_format: ProgramFormat = ProgramFormat.QASM3, + program_format: ProgramFormat = ProgramFormat.OPENQASM3, operations: Sequence[QDMIDevice.Operation] | None = None, qubits: int = 2, result_factory: Callable[[str, int], Sequence[str]] = bell_results, expose_shots: bool = True, + program_features: Sequence[object] | None = (), ) -> StubDevice: """Return a stub with the ordinary execution-test gate set.""" return StubDevice( @@ -190,6 +205,7 @@ def stub_device( qubits=qubits, result_factory=result_factory, expose_shots=expose_shots, + program_features=program_features, ) diff --git a/test/python/plugins/qdmi_pennylane/test_converter.py b/test/python/plugins/qdmi_pennylane/test_converter.py index bb6cfc5b8f..316f134a4a 100644 --- a/test/python/plugins/qdmi_pennylane/test_converter.py +++ b/test/python/plugins/qdmi_pennylane/test_converter.py @@ -29,10 +29,84 @@ PennyLaneValidationError, QDMIDevice, ) +from mqt.core.plugins.pennylane.converter import _ProgramConverter # ruff:ignore[import-private-name] from mqt.core.qdmi import ProgramFormat from .helpers import StubDevice, operation, patch_open_device +_DYNAMIC_FEATURES = frozenset({ + "mid-circuit-measurement", + "measured-qubit-reuse", + "measurement-result-use", + "boolean-computation", + "forward-branching", +}) + + +def test_qasm3_emits_measurement_feedback_and_reset() -> None: + """Encode PennyLane's one-shot MCM bundle in the declared output bits.""" + qdmi = StubDevice( + [operation("measure", 1), operation("reset", 1), operation("x", 1)], + [ProgramFormat.OPENQASM3], + program_features=tuple(_DYNAMIC_FEATURES), + ) + + def circuit(): + measurement = qp.measure(0, reset=True) + qp.cond(measurement, qp.PauliX)(1) + return qp.sample(wires=[0, 1]) + + tape = qp.tape.make_qscript(circuit)() + converted = _ProgramConverter( + qdmi, # ty: ignore[invalid-argument-type] + qp.wires.Wires([0, 1]), + ProgramFormat.OPENQASM3, + _DYNAMIC_FEATURES, + ).convert(tape) + + assert "bit[3] c;" in converted.payload + assert "c[2] = measure q[0];\nreset q[0];" in converted.payload + assert "if (c[2] == 1) { x q[1]; }" in converted.payload + assert converted.mcm_slot_by_uid[tape.operations[0].meas_uid] == 2 + + +@pytest.mark.parametrize( + "operations", + [ + [qp.ops.MidMeasure(qp.wires.Wires([0]), meas_uid="")], + [ + qp.ops.MidMeasure(qp.wires.Wires([0]), meas_uid="same"), + qp.ops.MidMeasure(qp.wires.Wires([1]), meas_uid="same"), + ], + ], +) +def test_qasm3_rejects_invalid_mid_measurement_ids(operations: list[qp.operation.Operator]) -> None: + """Require stable, unique IDs for mid-circuit measurement results.""" + qdmi = StubDevice([operation("measure", 1)], [ProgramFormat.OPENQASM3], program_features=tuple(_DYNAMIC_FEATURES)) + converter = _ProgramConverter( + qdmi, # ty: ignore[invalid-argument-type] + qp.wires.Wires([0, 1]), + ProgramFormat.OPENQASM3, + _DYNAMIC_FEATURES, + ) + + with pytest.raises(PennyLaneValidationError, match="unique nonempty measurement IDs"): + converter.convert(qp.tape.QuantumScript(operations)) + + +def test_qasm3_rejects_mid_measurement_without_payload_capabilities() -> None: + """Reject mid-circuit measurement unless the selected payload supports it.""" + qdmi = StubDevice([operation("measure", 1)], [ProgramFormat.OPENQASM3]) + converter = _ProgramConverter( + qdmi, # ty: ignore[invalid-argument-type] + qp.wires.Wires([0]), + ProgramFormat.OPENQASM3, + ) + tape = qp.tape.QuantumScript([qp.ops.MidMeasure(qp.wires.Wires([0]), meas_uid="measurement")]) + + with pytest.raises(PennyLaneUnsupportedOperationError, match="does not support mid-circuit measurement"): + converter.convert(tape) + def test_qasm3_prefers_and_resolves_braket_spellings(monkeypatch: pytest.MonkeyPatch) -> None: """Prefer QASM3 and emit only spellings advertised by a Braket-style device.""" @@ -43,7 +117,7 @@ def test_qasm3_prefers_and_resolves_braket_spellings(monkeypatch: pytest.MonkeyP operation("phaseshift", 1, 1), operation("xx", 2, 1), ], - [ProgramFormat.QASM2, ProgramFormat.QASM3], + [ProgramFormat.OPENQASM2, ProgramFormat.OPENQASM3], result_factory=lambda _program, shots: ["10"] * shots, ) patch_open_device(monkeypatch, qdmi) @@ -60,7 +134,7 @@ def circuit(): samples = circuit() payload, program_format, _shots, _parameters = qdmi.submissions[0] - assert program_format == ProgramFormat.QASM3 + assert program_format == ProgramFormat.OPENQASM3 assert payload == ( "OPENQASM 3.0;\n" "qubit[2] q;\n" @@ -69,14 +143,15 @@ def circuit(): "cnot q[0],q[1];\n" "phaseshift(0.25) q[1];\n" "xx(0.5) q[1],q[0];\n" - "c = measure q;\n" + "c[0] = measure q[0];\n" + "c[1] = measure q[1];\n" ) assert "include" not in payload assert "gate " not in payload assert "pragma" not in payload assert "inv @" not in payload - # The QDMI bit string "10" sets the highest-index site, which is wire - # "right". The requested measurement order puts that wire first. + # QDMI output slot zero is the rightmost bit. The requested measurement + # order places slot one before slot zero. np.testing.assert_array_equal(samples[0], [1, 0]) @@ -94,7 +169,7 @@ def test_qasm3_resolves_ddsim_aliases_and_inverse_gates(monkeypatch: pytest.Monk operation("ryy", 2, 1), operation("rzz", 2, 1), ], - [ProgramFormat.QASM3], + [ProgramFormat.OPENQASM3], ) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=2, shots=5) @@ -130,7 +205,7 @@ def test_qasm3_failure_does_not_fall_back_to_qasm2(monkeypatch: pytest.MonkeyPat # PennyLane's OpenQASM 2 serializer spells U3 as `u3`, and the device # advertises it, so a fallback to OpenQASM 2 would silently succeed. The # OpenQASM 3 operation table has no U3 row, so the QASM3 path must fail. - qdmi = StubDevice([operation("u3", 1, 3)], [ProgramFormat.QASM3, ProgramFormat.QASM2]) + qdmi = StubDevice([operation("u3", 1, 3)], [ProgramFormat.OPENQASM3, ProgramFormat.OPENQASM2]) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=1, shots=4) tape = qp.tape.QuantumScript([qp.U3(0.1, 0.2, 0.3, wires=0)], [qp.sample(wires=0)], shots=4) @@ -162,7 +237,7 @@ def test_qasm2_fallback_uses_pennylane_serializer(monkeypatch: pytest.MonkeyPatc """Use PennyLane's QASM2 serializer only when QASM3 is unavailable.""" qdmi = StubDevice( [operation("h", 1), operation("cx", 2), operation("rx", 1, 1)], - [ProgramFormat.QASM2], + [ProgramFormat.OPENQASM2], ) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=2, shots=10) @@ -177,7 +252,7 @@ def circuit(): circuit() payload, program_format, _shots, _parameters = qdmi.submissions[0] - assert program_format == ProgramFormat.QASM2 + assert program_format == ProgramFormat.OPENQASM2 assert payload == ( "OPENQASM 2.0;\n" 'include "qelib1.inc";\n' @@ -193,7 +268,7 @@ def circuit(): def test_qasm2_rejects_non_intersection_operation(monkeypatch: pytest.MonkeyPatch) -> None: """Reject an operation the serializer knows when the QDMI device does not.""" - qdmi = StubDevice([operation("h", 1)], [ProgramFormat.QASM2]) + qdmi = StubDevice([operation("h", 1)], [ProgramFormat.OPENQASM2]) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=2, shots=2) tape = qp.tape.QuantumScript([qp.CNOT(wires=[0, 1])], [qp.sample(wires=[0, 1])], shots=2) @@ -215,7 +290,7 @@ def circuit(): def test_qasm2_wraps_serializer_errors(monkeypatch: pytest.MonkeyPatch) -> None: """Expose serializer failures as focused translation errors.""" - qdmi = StubDevice([operation("h", 1)], [ProgramFormat.QASM2]) + qdmi = StubDevice([operation("h", 1)], [ProgramFormat.OPENQASM2]) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=1, shots=2) @@ -237,7 +312,7 @@ def circuit(): @pytest.mark.parametrize("parameter", [np.nan, np.inf, -np.inf]) def test_rejects_non_finite_parameters(monkeypatch: pytest.MonkeyPatch, parameter: float) -> None: """Reject non-finite bound parameters before submission.""" - qdmi = StubDevice([operation("rx", 1, 1)], [ProgramFormat.QASM3]) + qdmi = StubDevice([operation("rx", 1, 1)], [ProgramFormat.OPENQASM3]) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=1, shots=2) @@ -255,7 +330,7 @@ def test_validates_operation_topology(monkeypatch: pytest.MonkeyPatch) -> None: """Honor operation-specific QDMI site pairs.""" qdmi = StubDevice( [operation("cx", 2, site_pairs=[(0, 1)])], - [ProgramFormat.QASM3], + [ProgramFormat.OPENQASM3], qubits=3, ) patch_open_device(monkeypatch, qdmi) @@ -274,7 +349,7 @@ def circuit(): def test_reuses_session_contract_checks_without_skipping_input_validation(monkeypatch: pytest.MonkeyPatch) -> None: """Reuse valid gate locations across tapes, not inputs or other sessions.""" rx = operation("rx", 1, 1, sites=[0]) - qdmi = StubDevice([rx], [ProgramFormat.QASM3]) + qdmi = StubDevice([rx], [ProgramFormat.OPENQASM3]) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=2, shots=2) query_sites = Mock(wraps=rx.sites) @@ -300,7 +375,7 @@ def tape(angle: float, wire: int = 0): def test_rejects_operation_on_an_unadvertised_site(monkeypatch: pytest.MonkeyPatch) -> None: """Honor the single-qubit sites a QDMI operation advertises.""" - qdmi = StubDevice([operation("h", 1, sites=[0])], [ProgramFormat.QASM3]) + qdmi = StubDevice([operation("h", 1, sites=[0])], [ProgramFormat.OPENQASM3]) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=2, shots=2) @@ -319,7 +394,7 @@ def test_falls_back_to_the_device_coupling_map(monkeypatch: pytest.MonkeyPatch) """Use the device topology when an operation advertises no site pairs.""" qdmi = StubDevice( [operation("h", 1), operation("cx", 2)], - [ProgramFormat.QASM3], + [ProgramFormat.OPENQASM3], qubits=3, coupling_map=[(0, 1)], result_factory=lambda _program, shots: ["000"] * shots, @@ -349,7 +424,7 @@ def test_accepts_advertised_site_pairs_and_wider_operations(monkeypatch: pytest. """Accept an advertised site pair, and skip loci checks above two wires.""" qdmi = StubDevice( [operation("h", 1), operation("cx", 2, site_pairs=[(0, 1)]), operation("ccx", 3)], - [ProgramFormat.QASM3], + [ProgramFormat.OPENQASM3], qubits=3, result_factory=lambda _program, shots: ["000"] * shots, ) @@ -380,7 +455,7 @@ def test_rejects_contradictory_qdmi_metadata( monkeypatch: pytest.MonkeyPatch, advertised: object, expected: str ) -> None: """Reject a QDMI operation whose arity contradicts the operation table.""" - qdmi = StubDevice([advertised], [ProgramFormat.QASM3]) # ty: ignore[invalid-argument-type] + qdmi = StubDevice([advertised], [ProgramFormat.OPENQASM3]) # ty: ignore[invalid-argument-type] patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=1, shots=2) @@ -398,7 +473,7 @@ def test_measurement_without_wires_samples_every_device_wire(monkeypatch: pytest """Sample every device wire when the measurement names none.""" qdmi = StubDevice( [operation("x", 1)], - [ProgramFormat.QASM3], + [ProgramFormat.OPENQASM3], result_factory=lambda _program, shots: ["10"] * shots, ) patch_open_device(monkeypatch, qdmi) @@ -419,7 +494,7 @@ def test_converts_an_unpreprocessed_tape(monkeypatch: pytest.MonkeyPatch) -> Non """Convert a tape handed straight to the device, without preprocessing.""" qdmi = StubDevice( [operation("h", 1)], - [ProgramFormat.QASM3], + [ProgramFormat.OPENQASM3], result_factory=lambda _program, shots: ["00"] * shots, ) patch_open_device(monkeypatch, qdmi) diff --git a/test/python/plugins/qdmi_pennylane/test_device.py b/test/python/plugins/qdmi_pennylane/test_device.py index 2bf75edc11..2baf3516c5 100644 --- a/test/python/plugins/qdmi_pennylane/test_device.py +++ b/test/python/plugins/qdmi_pennylane/test_device.py @@ -14,6 +14,7 @@ import math from collections import Counter +from types import SimpleNamespace from typing import cast import numpy as np @@ -35,7 +36,7 @@ from mqt.core.qdmi import Job as QDMIJobHandle from mqt.core.qdmi import ProgramFormat -from .helpers import StubDevice, patch_open_device, rotation_results, stub_device +from .helpers import StubDevice, operation, patch_open_device, rotation_results, stub_device def test_uses_already_open_qdmi_device(monkeypatch: pytest.MonkeyPatch) -> None: @@ -248,7 +249,7 @@ def circuit(): def test_qasm2_diagonalizes_observable_once(monkeypatch: pytest.MonkeyPatch) -> None: """Do not duplicate the X-basis rotation in PennyLane's QASM2 serializer.""" - qdmi = stub_device(program_format=ProgramFormat.QASM2) + qdmi = stub_device(program_format=ProgramFormat.OPENQASM2) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=2, shots=10) @@ -257,10 +258,75 @@ def circuit(): return qp.expval(qp.PauliX(0)) assert np.isfinite(circuit()) - assert qdmi.submissions[0][1] == ProgramFormat.QASM2 + assert qdmi.submissions[0][1] == ProgramFormat.OPENQASM2 assert qdmi.submissions[0][0].count("ry(") == 1 +def test_one_shot_executes_measurement_feedback(monkeypatch: pytest.MonkeyPatch) -> None: + """Reconstruct terminal and raw mid-circuit samples from one output vector.""" + features = ( + "mid-circuit-measurement", + "measured-qubit-reuse", + "measurement-result-use", + "boolean-computation", + "forward-branching", + ) + qdmi = stub_device( + operations=[operation("measure", 1), operation("reset", 1), operation("x", 1)], + result_factory=lambda _program, shots: ["110"] * shots, + program_features=features, + ) + patch_open_device(monkeypatch, qdmi) + device = QDMIDevice("fake.qdmi", wires=2, shots=4) + + @qp.qnode(device, mcm_method="one-shot") + def circuit(): + measurement = qp.measure(0, reset=True) + qp.cond(measurement, qp.PauliX)(1) + return qp.sample(wires=[0, 1]), qp.sample(measurement) + + terminal, mid_circuit = circuit() + + np.testing.assert_array_equal(terminal, np.tile([0, 1], (4, 1))) + np.testing.assert_array_equal(mid_circuit, np.ones(4, dtype=np.int8)) + assert len(qdmi.submissions) == 4 + assert all("c[2] = measure q[0];" in submission[0] for submission in qdmi.submissions) + + +@pytest.mark.parametrize( + "invalid_features", + [ + [SimpleNamespace(id="forward-branching", value=1, constraint_id="", constraint_value=0)], + [SimpleNamespace(id="forward-branching", value=0, constraint_id="max-depth", constraint_value=1)], + [ + SimpleNamespace(id="forward-branching", value=0, constraint_id="", constraint_value=0), + SimpleNamespace(id="forward-branching", value=0, constraint_id="", constraint_value=0), + ], + [ + SimpleNamespace(id="forward-branching", value=0, constraint_id="", constraint_value=0), + SimpleNamespace(id="forward-branching", value=0, constraint_id="max-depth", constraint_value=1), + ], + ], +) +def test_one_shot_rejects_non_boolean_feature_records( + monkeypatch: pytest.MonkeyPatch, invalid_features: list[object] +) -> None: + """Ignore feature records whose representation the device does not understand.""" + features: list[object] = [ + "mid-circuit-measurement", + "measured-qubit-reuse", + "measurement-result-use", + "boolean-computation", + *invalid_features, + ] + qdmi = stub_device(operations=[operation("measure", 1), operation("reset", 1)], program_features=features) + patch_open_device(monkeypatch, qdmi) + + device = QDMIDevice("fake.qdmi", wires=2, shots=4) + + assert device.capabilities.supported_mcm_methods == [] + + def test_rejects_analytic_execution_before_submission(monkeypatch: pytest.MonkeyPatch) -> None: """Reject analytic tapes before a QDMI job is created.""" qdmi = stub_device() @@ -305,7 +371,7 @@ def test_validates_configuration_and_width(monkeypatch: pytest.MonkeyPatch) -> N def test_rejects_device_without_openqasm(monkeypatch: pytest.MonkeyPatch) -> None: """Reject unsupported program formats during construction.""" - qdmi = StubDevice([], [ProgramFormat.QIR_BASE_STRING]) + qdmi = StubDevice([], [ProgramFormat.QIR21_BASE_TEXT]) patch_open_device(monkeypatch, qdmi) with pytest.raises(PennyLaneUnsupportedFormatError, match="neither OpenQASM 3 nor OpenQASM 2"): diff --git a/test/python/plugins/qiskit/test_mock_backend.py b/test/python/plugins/qiskit/test_mock_backend.py index 7124a16cf8..a31fff9721 100644 --- a/test/python/plugins/qiskit/test_mock_backend.py +++ b/test/python/plugins/qiskit/test_mock_backend.py @@ -14,6 +14,7 @@ import secrets import string import warnings +from types import SimpleNamespace from typing import TYPE_CHECKING, ClassVar, NoReturn import pytest @@ -25,18 +26,19 @@ QDMIProvider, TranslationError, UnsupportedFormatError, - program_serializer, - register_program_serializer, - unregister_program_serializer, + UnsupportedOperationError, ) from mqt.core.qdmi import Job as QDMIJobHandle -from mqt.core.qdmi import ProgramFormat +from mqt.core.qdmi import ProgramEncoding, ProgramFormat if TYPE_CHECKING: - from collections.abc import Callable, Iterator, Sequence + from collections.abc import Callable, Sequence from qiskit.circuit import Instruction +CUSTOM1 = ProgramFormat("example.custom-one", (1, 0, 0)) +QPY = ProgramFormat("qiskit.qpy", (13, 0, 0), encoding=ProgramEncoding.BINARY) + class MockQDMIDevice: """Mock QDMI device for testing with configurable properties and job execution. @@ -211,6 +213,7 @@ def __init__( num_qubits: int = 5, operations: Sequence[str] | None = None, coupling_map: Sequence[tuple[int, int]] | None = None, + program_features: Sequence[object] = (), ) -> None: """Initialize a mock QDMI device. @@ -220,11 +223,13 @@ def __init__( num_qubits: Number of qubits. operations: List of operation names. Defaults to common gates. coupling_map: Coupling map as list of (control, target) pairs. None means all-to-all. + program_features: Optional features for the selected payload. """ self._name = name self._version = version self._num_qubits = num_qubits self._sites = [self.MockSite(i) for i in range(num_qubits)] + self._program_features = tuple(program_features) if operations is None: operations = ["h", "cz", "ry", "rz", "measure"] @@ -273,7 +278,20 @@ def coupling_map(self) -> list[tuple[MockSite, MockSite]] | None: @staticmethod def supported_program_formats() -> list[ProgramFormat]: """Return list of supported program formats.""" - return [ProgramFormat.QASM2, ProgramFormat.QASM3] + return [ProgramFormat.OPENQASM3, ProgramFormat.OPENQASM2] + + def try_program_features(self, _program_format: ProgramFormat) -> list[object]: + """Report the optional features configured for this device. + + Returns: + Feature-shaped test records. + """ + return [ + SimpleNamespace(id=feature, value=0, constraint_id="", constraint_value=0) + if isinstance(feature, str) + else feature + for feature in self._program_features + ] def submit_job(self, program: str, program_format: ProgramFormat, num_shots: int) -> MockJob: # ruff:ignore[unused-method-argument] """Submit a mock job to the device. @@ -462,15 +480,18 @@ def submit_job(program: str | bytes, program_format: ProgramFormat, num_shots: i def test_backend_serialization_without_supported_formats(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: """Backend should raise UnsupportedFormatError when the device reports no program format.""" - qc = QuantumCircuit(2) - qc.cz(0, 1) - qc.measure_all() - device = mock_qdmi_device_factory(num_qubits=2, operations=["cz", "measure"]) - backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] + device.supported_program_formats = list # ty: ignore[invalid-assignment] + with pytest.raises(UnsupportedFormatError, match="no payload descriptor"): + QDMIBackend(device) # ty: ignore[invalid-argument-type] + + +def test_backend_rejects_unaccepted_explicit_payload(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: + """Reject an explicit payload descriptor that the device does not accept.""" + device = mock_qdmi_device_factory() - with pytest.raises(UnsupportedFormatError, match="reports no supported program formats"): - backend._serialize_circuit(qc, []) # ruff:ignore[private-member-access] + with pytest.raises(UnsupportedFormatError, match="does not accept the requested payload descriptor"): + QDMIBackend(device, payload_descriptor=QPY) # ty: ignore[invalid-argument-type] def test_backend_qasm3_serialization_success(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: @@ -483,9 +504,9 @@ def test_backend_qasm3_serialization_success(mock_qdmi_device_factory: type[Mock device = mock_qdmi_device_factory(num_qubits=2, operations=["h", "cx", "measure"]) backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] - program, fmt = backend._serialize_circuit(qc, [ProgramFormat.QASM3]) # ruff:ignore[private-member-access] + program, fmt = backend._serialize_circuit(qc) # ruff:ignore[private-member-access] - assert fmt == ProgramFormat.QASM3 + assert fmt == ProgramFormat.OPENQASM3 assert isinstance(program, str) assert "OPENQASM 3" in program assert "h q[0]" in program @@ -502,9 +523,9 @@ def test_backend_qasm3_zero_initializes_classical_bits( device = mock_qdmi_device_factory(num_qubits=2, operations=["measure"]) backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] - program, fmt = backend._serialize_circuit(qc, [ProgramFormat.QASM3]) # ruff:ignore[private-member-access] + program, fmt = backend._serialize_circuit(qc) # ruff:ignore[private-member-access] - assert fmt == ProgramFormat.QASM3 + assert fmt == ProgramFormat.OPENQASM3 assert isinstance(program, str) assert "c[0] = false;" in program assert "c[1] = false;" in program @@ -519,26 +540,25 @@ def test_backend_qasm2_serialization_success(mock_qdmi_device_factory: type[Mock qc.measure_all() device = mock_qdmi_device_factory(num_qubits=2, operations=["h", "cx", "measure"]) - backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] + backend = QDMIBackend(device, payload_descriptor=ProgramFormat.OPENQASM2) # ty: ignore[invalid-argument-type] - program, fmt = backend._serialize_circuit(qc, [ProgramFormat.QASM2]) # ruff:ignore[private-member-access] + program, fmt = backend._serialize_circuit(qc) # ruff:ignore[private-member-access] - assert fmt == ProgramFormat.QASM2 + assert fmt == ProgramFormat.OPENQASM2 assert isinstance(program, str) assert "OPENQASM 2.0" in program assert "h q[0]" in program assert "cx q[0],q[1]" in program -def test_backend_respects_format_preference(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: - """The preference order decides the format, not the order the device reports.""" +def test_backend_respects_provider_format_preference(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: + """Use the first provider-preferred descriptor with a serializer.""" qc = QuantumCircuit(2) qc.h(0) qc.measure_all() device = mock_qdmi_device_factory(num_qubits=2, operations=["h", "measure"]) - # The device reports OpenQASM 2 first, but OpenQASM 3 outranks it. - device.supported_program_formats = lambda: [ProgramFormat.QASM2, ProgramFormat.QASM3] # ty: ignore[invalid-assignment] + device.supported_program_formats = lambda: [ProgramFormat.OPENQASM3, ProgramFormat.OPENQASM2] # ty: ignore[invalid-assignment] submissions = _record_submissions(device) backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] @@ -546,187 +566,164 @@ def test_backend_respects_format_preference(mock_qdmi_device_factory: type[MockQ assert len(submissions) == 1 program, fmt = submissions[0] - assert fmt == ProgramFormat.QASM3 + assert fmt == ProgramFormat.OPENQASM3 assert isinstance(program, str) assert "OPENQASM 3" in program -@pytest.fixture -def registered_serializer() -> Iterator[ProgramFormat]: - """Register a text program serializer for CUSTOM1 and remove it after the test. - - Yields: - The program format the serializer is registered for. - """ - - def serializer(circuit: QuantumCircuit, backend: QDMIBackend) -> str: # ruff:ignore[unused-function-argument] - return f"CUSTOM1 program for {circuit.name}" - - register_program_serializer(ProgramFormat.CUSTOM1, serializer) - yield ProgramFormat.CUSTOM1 - unregister_program_serializer(ProgramFormat.CUSTOM1) - - -def test_backend_uses_registered_serializer( - mock_qdmi_device_factory: type[MockQDMIDevice], registered_serializer: ProgramFormat -) -> None: - """Backend serializes through a registered serializer when the device supports its format.""" - device = mock_qdmi_device_factory(num_qubits=2, operations=["r", "cz", "measure"]) - device.supported_program_formats = lambda: [registered_serializer, ProgramFormat.QASM3] # ty: ignore[invalid-assignment] - submissions = _record_submissions(device) - +def test_backend_projects_selected_payload_control_flow(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: + """Expose exact payload features through Qiskit's native Target classes.""" + device = mock_qdmi_device_factory( + operations=["h", "measure"], + program_features=[ + "forward-branching", + "counted-iteration", + "conditional-loop", + "multiway-branching", + ], + ) backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] - qc = QuantumCircuit(2, name="bell") - qc.r(1.5708, 0.0, 0) - qc.cz(0, 1) - - backend.run(qc, shots=100) - assert submissions == [("CUSTOM1 program for bell", registered_serializer)] + assert {"if_else", "for_loop", "while_loop", "switch_case"} <= set(backend.target.operation_names) + assert backend.payload_descriptor == ProgramFormat.OPENQASM3 + circuit = QuantumCircuit(1, 1) + circuit.measure(0, 0) + with circuit.if_test((circuit.clbits[0], True)): + circuit.h(0) + assert backend.run(circuit, shots=1).result().success -def test_backend_prefers_registered_serializer_over_qasm( - mock_qdmi_device_factory: type[MockQDMIDevice], registered_serializer: ProgramFormat +@pytest.mark.parametrize( + ("vendor_format", "payload"), + [ + (CUSTOM1, "custom text"), + (QPY, b"custom\x00binary"), + (ProgramFormat.QIR21_ADAPTIVE_BINARY, b"adaptive qir"), + ], +) +def test_backend_uses_subclass_serializer( + mock_qdmi_device_factory: type[MockQDMIDevice], vendor_format: ProgramFormat, payload: str | bytes ) -> None: - """A registered serializer takes priority over the built-in OpenQASM serializers.""" - device = mock_qdmi_device_factory(num_qubits=2, operations=["r", "cz", "measure"]) - device.supported_program_formats = lambda: [ProgramFormat.QASM2, ProgramFormat.QASM3, registered_serializer] # ty: ignore[invalid-assignment] - submissions = _record_submissions(device) + """A backend subclass can own one exact text or binary format.""" - backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] - qc = QuantumCircuit(2, name="bell") - qc.r(1.5708, 0.0, 0) - qc.cz(0, 1) - qc.measure_all() - - backend.run(qc, shots=100) - - assert submissions == [("CUSTOM1 program for bell", registered_serializer)] + class CustomBackend(QDMIBackend): + """Backend that serializes one test format.""" + def _program_serializer( + self, program_format: ProgramFormat + ) -> Callable[[QuantumCircuit, QDMIBackend], str | bytes] | None: + if program_format == vendor_format: + return lambda _circuit, _backend: payload + return super()._program_serializer(program_format) -@pytest.fixture -def registered_binary_serializer() -> Iterator[tuple[ProgramFormat, bytes]]: - """Register a binary program serializer for QPY and remove it after the test. - - Yields: - The program format the serializer is registered for and the payload it - returns. - """ - payload = b"QPY\x00\x01binary program" - - def serializer(circuit: QuantumCircuit, backend: QDMIBackend) -> bytes: # ruff:ignore[unused-function-argument] - return payload - - register_program_serializer(ProgramFormat.QPY, serializer) - yield ProgramFormat.QPY, payload - unregister_program_serializer(ProgramFormat.QPY) - - -def test_backend_submits_binary_payload( - mock_qdmi_device_factory: type[MockQDMIDevice], registered_binary_serializer: tuple[ProgramFormat, bytes] -) -> None: - """A binary format reaches the device as the exact bytes the serializer returned.""" - fmt, payload = registered_binary_serializer - device = mock_qdmi_device_factory(num_qubits=2, operations=["h", "cz", "measure"]) - device.supported_program_formats = lambda: [fmt, ProgramFormat.QASM3] # ty: ignore[invalid-assignment] + device = mock_qdmi_device_factory(num_qubits=2, operations=["h", "measure"]) + device.supported_program_formats = lambda: [vendor_format, ProgramFormat.OPENQASM3] # ty: ignore[invalid-assignment] submissions = _record_submissions(device) + backend = CustomBackend(device) # ty: ignore[invalid-argument-type] + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.measure_all() - backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] - qc = QuantumCircuit(2) - qc.h(0) - qc.cz(0, 1) - qc.measure_all() - - backend.run(qc, shots=100) - - assert submissions == [(payload, fmt)] + backend.run(circuit, shots=100) + assert submissions == [(payload, vendor_format)] + if vendor_format == ProgramFormat.QIR21_ADAPTIVE_BINARY: + assert "if_else" in backend.target.operation_names -@pytest.fixture -def mistyped_serializer() -> Iterator[ProgramFormat]: - """Register a serializer that returns bytes for a text format. - - Yields: - The text program format the serializer is registered for. - """ - def serializer(circuit: QuantumCircuit, backend: QDMIBackend) -> bytes: # ruff:ignore[unused-function-argument] - return b"not a string" +def test_backend_rejects_unadvertised_control_flow(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: + """Reject control flow that the selected payload does not advertise.""" + device = mock_qdmi_device_factory(num_qubits=1, operations=["x", "measure"]) + backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] + circuit = QuantumCircuit(1, 1) + circuit.measure(0, 0) + with circuit.if_test((circuit.clbits[0], True)): + circuit.x(0) - register_program_serializer(ProgramFormat.CUSTOM2, serializer) - yield ProgramFormat.CUSTOM2 - unregister_program_serializer(ProgramFormat.CUSTOM2) + with pytest.raises(UnsupportedOperationError, match="Unsupported control flow operation: 'if_else'"): + backend.run(circuit) -def test_backend_rejects_wrong_payload_type( - mock_qdmi_device_factory: type[MockQDMIDevice], mistyped_serializer: ProgramFormat +@pytest.mark.parametrize( + "features", + [ + [SimpleNamespace(id="forward-branching", value=1, constraint_id="", constraint_value=0)], + [SimpleNamespace(id="forward-branching", value=0, constraint_id="max-depth", constraint_value=1)], + [ + SimpleNamespace(id="forward-branching", value=0, constraint_id="", constraint_value=0), + SimpleNamespace(id="forward-branching", value=0, constraint_id="", constraint_value=0), + ], + [ + SimpleNamespace(id="forward-branching", value=0, constraint_id="", constraint_value=0), + SimpleNamespace(id="forward-branching", value=0, constraint_id="max-depth", constraint_value=1), + ], + ], +) +def test_backend_rejects_non_boolean_feature_records( + mock_qdmi_device_factory: type[MockQDMIDevice], features: list[object] ) -> None: - """A serializer that returns the wrong payload type for its format fails.""" - qc = QuantumCircuit(2) - qc.h(0) - qc.measure_all() - - device = mock_qdmi_device_factory(num_qubits=2, operations=["h", "measure"]) + """Ignore feature records whose representation the backend does not understand.""" + device = mock_qdmi_device_factory(num_qubits=1, operations=["x", "measure"], program_features=features) backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] - with pytest.raises(TranslationError, match="returned bytes, but CUSTOM2 requires str"): - backend._serialize_circuit(qc, [mistyped_serializer]) # ruff:ignore[private-member-access] + assert "if_else" not in backend.target.operation_names -@pytest.fixture -def replaced_qasm3_serializer() -> Iterator[str]: - """Replace the built-in OpenQASM 3 serializer and restore it after the test. - - Yields: - The program the replacement returns. - """ - program = "OPENQASM 3.0; // replaced" - original = program_serializer(ProgramFormat.QASM3) - assert original is not None - - def serializer(circuit: QuantumCircuit, backend: QDMIBackend) -> str: # ruff:ignore[unused-function-argument] - return program +@pytest.mark.parametrize( + ("vendor_format", "payload", "expected_type"), + [(CUSTOM1, b"not text", "str"), (QPY, "not binary", "bytes")], +) +def test_backend_rejects_wrong_subclass_payload_type( + mock_qdmi_device_factory: type[MockQDMIDevice], + vendor_format: ProgramFormat, + payload: str | bytes, + expected_type: str, +) -> None: + """A vendor serializer must return the payload type its format requires.""" - register_program_serializer(ProgramFormat.QASM3, serializer, replace=True) - yield program - register_program_serializer(ProgramFormat.QASM3, original, replace=True) + class MistypedBackend(QDMIBackend): + """Backend whose test serializer returns the wrong payload type.""" + def _program_serializer( + self, program_format: ProgramFormat + ) -> Callable[[QuantumCircuit, QDMIBackend], str | bytes] | None: + if program_format == vendor_format: + return lambda _circuit, _backend: payload + return super()._program_serializer(program_format) -def test_backend_uses_replaced_qasm3_serializer( - mock_qdmi_device_factory: type[MockQDMIDevice], replaced_qasm3_serializer: str -) -> None: - """Replacing the built-in OpenQASM 3 serializer changes what the backend submits.""" device = mock_qdmi_device_factory(num_qubits=2, operations=["h", "measure"]) - submissions = _record_submissions(device) + device.supported_program_formats = lambda: [vendor_format] # ty: ignore[invalid-assignment] + backend = MistypedBackend(device) # ty: ignore[invalid-argument-type] - backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] - qc = QuantumCircuit(2) - qc.h(0) - qc.measure_all() + with pytest.raises(TranslationError, match=rf"requires {expected_type}"): + backend._serialize_circuit(QuantumCircuit(2)) # ruff:ignore[private-member-access] - backend.run(qc, shots=100) - assert submissions == [(replaced_qasm3_serializer, ProgramFormat.QASM3)] +def test_job_uses_backend_result_decoder(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: + """The backend that owns a vendor result format also decodes its counts.""" + class ResultBackend(QDMIBackend): + """Backend with a vendor result decoder.""" -def test_backend_rejects_device_without_program_payload(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: - """A device that only accepts CALIBRATION has no format a circuit can go into.""" - qc = QuantumCircuit(2) - qc.h(0) - qc.measure_all() + def _decode_counts(self, job: QDMIJobHandle) -> dict[str, int]: # ruff:ignore[no-self-use] + return {"10": job.num_shots} device = mock_qdmi_device_factory(num_qubits=2, operations=["h", "measure"]) - backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] + backend = ResultBackend(device) # ty: ignore[invalid-argument-type] + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.measure_all() + + counts = backend.run(circuit, shots=17).result().get_counts() - with pytest.raises(UnsupportedFormatError, match="No program serializer for any format the device supports"): - backend._serialize_circuit(qc, [ProgramFormat.CALIBRATION]) # ruff:ignore[private-member-access] + assert counts == {"10": 17} @pytest.mark.parametrize( ("qasm_module_name", "program_format"), [ - ("qasm3", ProgramFormat.QASM3), - ("qasm2", ProgramFormat.QASM2), + ("qasm3", ProgramFormat.OPENQASM3), + ("qasm2", ProgramFormat.OPENQASM2), ], ) def test_backend_qasm_serialization_failure( @@ -750,10 +747,10 @@ def failing_dumps(circuit: object) -> NoReturn: # ruff:ignore[unused-function-a qc.measure_all() device = mock_qdmi_device_factory(num_qubits=2, operations=["cz", "measure"]) - backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] + backend = QDMIBackend(device, payload_descriptor=program_format) # ty: ignore[invalid-argument-type] - with pytest.raises(TranslationError, match=f"Failed to serialize the circuit to {qasm_module_name.upper()}"): - backend._serialize_circuit(qc, [program_format]) # ruff:ignore[private-member-access] + with pytest.raises(TranslationError, match="Failed to serialize"): + backend._serialize_circuit(qc) # ruff:ignore[private-member-access] def test_backend_unsupported_format_error(mock_qdmi_device_factory: type[MockQDMIDevice]) -> None: @@ -763,11 +760,9 @@ def test_backend_unsupported_format_error(mock_qdmi_device_factory: type[MockQDM qc.measure_all() device = mock_qdmi_device_factory(num_qubits=2, operations=["cz", "measure"]) - backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] - - # MQT Core ships no QPY serializer, and no package registered one - with pytest.raises(UnsupportedFormatError, match="No program serializer for any format the device supports"): - backend._serialize_circuit(qc, [ProgramFormat.QPY]) # ruff:ignore[private-member-access] + device.supported_program_formats = lambda: [QPY] # ty: ignore[invalid-assignment] + with pytest.raises(UnsupportedFormatError, match="no payload descriptor"): + QDMIBackend(device) # ty: ignore[invalid-argument-type] def test_map_operation_returns_none_for_unknown() -> None: diff --git a/test/python/plugins/qiskit/test_serializers.py b/test/python/plugins/qiskit/test_serializers.py deleted file mode 100644 index 0a340f486c..0000000000 --- a/test/python/plugins/qiskit/test_serializers.py +++ /dev/null @@ -1,321 +0,0 @@ -# Copyright (c) 2023 - 2026 Chair for Design Automation, TUM -# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH -# All rights reserved. -# -# SPDX-License-Identifier: MIT -# -# Licensed under the MIT License - -"""Tests for the program serializer registry.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -import pytest - -from mqt.core.plugins.qiskit import serializers -from mqt.core.qdmi import ProgramFormat - -if TYPE_CHECKING: - from collections.abc import Callable, Sequence - from importlib.metadata import EntryPoint - - from qiskit.circuit import QuantumCircuit - - from mqt.core.plugins.qiskit import QDMIBackend - - -def _serializer(circuit: QuantumCircuit, backend: QDMIBackend) -> str: # ruff:ignore[unused-function-argument] - """Returns a fixed program. - - Args: - circuit: The circuit to serialize. - backend: The backend that runs the circuit. - - Returns: - A fixed program string. - """ - return "program" - - -def _registry(entry_points: Sequence[EntryPoint] = ()) -> serializers._ProgramSerializerRegistry: - """Returns a registry of its own that discovers one fixed set of entry points. - - Each registry owns its serializers and its load state, so a test never - touches what another test sees. - - Args: - entry_points: The entry points the registry should discover. - """ - return serializers._ProgramSerializerRegistry(lambda: entry_points) # ruff:ignore[private-member-access] - - -class _FakeEntryPoint: - """An entry point that yields a fixed object, or raises.""" - - def __init__( - self, - name: str, - value: str, - target: object | None = None, - on_load: Callable[[], None] | None = None, - ) -> None: - """Initialize the entry point. - - Args: - name: Entry point name, expected to be a program format name. - value: Entry point value, used in diagnostics. - target: What loading returns. Loading raises when this is None. - on_load: Called while loading, standing in for the import side - effects of the package that advertises the entry point. - """ - self.name = name - self.value = value - self._target = target - self._on_load = on_load - - def load(self) -> object: - """Returns the target, or raises if there is none. - - Raises: - ImportError: If the entry point has no target. - """ - if self._on_load is not None: - self._on_load() - if self._target is None: - msg = "no such module" - raise ImportError(msg) - return self._target - - -def _entry_point( - name: str, - value: str, - target: object | None = None, - on_load: Callable[[], None] | None = None, -) -> EntryPoint: - """Returns a stand-in entry point, typed as the real thing for the registry. - - Args: - name: Entry point name, expected to be a program format name. - value: Entry point value, used in diagnostics. - target: What loading returns. Loading raises when this is None. - on_load: Called while loading, standing in for import side effects. - """ - return cast("EntryPoint", _FakeEntryPoint(name, value, target, on_load)) - - -def test_register_and_look_up_a_serializer() -> None: - """A registered serializer is the one the lookup returns.""" - registry = _registry() - registry.register(ProgramFormat.CUSTOM1, _serializer) - - assert registry.get(ProgramFormat.CUSTOM1) is _serializer - - -def test_second_serializer_for_one_format_is_rejected() -> None: - """Two serializers for one format need an explicit override.""" - registry = _registry() - registry.register(ProgramFormat.CUSTOM1, _serializer) - - with pytest.raises(ValueError, match="already registered"): - registry.register(ProgramFormat.CUSTOM1, _serializer) - - -def test_replace_overrides_an_existing_serializer() -> None: - """``replace=True`` puts the new serializer in place of the old one.""" - - def other(circuit: QuantumCircuit, backend: QDMIBackend) -> str: # ruff:ignore[unused-function-argument] - return "other program" - - registry = _registry() - registry.register(ProgramFormat.CUSTOM1, _serializer) - registry.register(ProgramFormat.CUSTOM1, other, replace=True) - - assert registry.get(ProgramFormat.CUSTOM1) is other - - -def test_unregistering_an_unknown_format_does_nothing() -> None: - """Removing a serializer that was never registered is a no-op.""" - registry = _registry() - registry.unregister(ProgramFormat.CUSTOM1) - - assert registry.get(ProgramFormat.CUSTOM1) is None - - -@pytest.mark.parametrize("fmt", [ProgramFormat.CALIBRATION, ProgramFormat.BATCH_JOB]) -def test_non_circuit_format_is_rejected(fmt: ProgramFormat) -> None: - """A format that does not carry a serialized circuit cannot have a serializer.""" - with pytest.raises(ValueError, match="does not carry a serialized circuit"): - _registry().register(fmt, _serializer) - - -def test_module_functions_share_one_registry(monkeypatch: pytest.MonkeyPatch) -> None: - """The public functions read and write the same process-wide registry.""" - monkeypatch.setattr(serializers, "_REGISTRY", _registry()) - serializers.register_program_serializer(ProgramFormat.CUSTOM1, _serializer) - - assert serializers.program_serializer(ProgramFormat.CUSTOM1) is _serializer - - serializers.unregister_program_serializer(ProgramFormat.CUSTOM1) - - assert serializers.program_serializer(ProgramFormat.CUSTOM1) is None - - -def test_serializer_is_discovered_from_entry_point() -> None: - """The registry loads a serializer advertised through the entry point group.""" - registry = _registry([_entry_point("CUSTOM2", "pkg.mod:serializer", _serializer)]) - - assert registry.get(ProgramFormat.CUSTOM2) is _serializer - - -def test_entry_points_are_read_once() -> None: - """A second lookup does not read the entry points again.""" - calls = 0 - - def discover() -> list[EntryPoint]: - nonlocal calls - calls += 1 - return [_entry_point("CUSTOM2", "pkg.mod:serializer", _serializer)] - - registry = serializers._ProgramSerializerRegistry(discover) # ruff:ignore[private-member-access] - registry.get(ProgramFormat.CUSTOM2) - registry.get(ProgramFormat.CUSTOM2) - - assert calls == 1 - - -def test_entry_point_with_unknown_format_warns() -> None: - """An entry point that does not name a program format is skipped.""" - registry = _registry([_entry_point("NOT_A_FORMAT", "pkg.mod:serializer", _serializer)]) - - with pytest.warns(UserWarning, match="does not name a program format"): - assert registry.get(ProgramFormat.CUSTOM2) is None - - -def test_entry_point_for_non_circuit_format_warns() -> None: - """An entry point for a format that carries no serialized circuit is skipped.""" - registry = _registry([_entry_point("CALIBRATION", "pkg.mod:serializer", _serializer)]) - - with pytest.warns(UserWarning, match="does not carry a serialized circuit"): - assert registry.get(ProgramFormat.CALIBRATION) is None - - -def test_entry_point_that_fails_to_load_warns() -> None: - """A serializer that cannot be imported is skipped without hiding the others.""" - registry = _registry([ - _entry_point("CUSTOM2", "broken.mod:serializer"), - _entry_point("CUSTOM3", "pkg.mod:serializer", _serializer), - ]) - - with pytest.warns(UserWarning, match="Failed to load the program serializer for CUSTOM2"): - assert registry.get(ProgramFormat.CUSTOM2) is None - - assert registry.get(ProgramFormat.CUSTOM3) is _serializer - - -def test_runtime_registration_beats_an_entry_point() -> None: - """A serializer registered at run time wins over one from an entry point.""" - - def other(circuit: QuantumCircuit, backend: QDMIBackend) -> str: # ruff:ignore[unused-function-argument] - return "other program" - - registry = _registry([_entry_point("CUSTOM2", "pkg.mod:serializer", _serializer)]) - registry.register(ProgramFormat.CUSTOM2, other) - - assert registry.get(ProgramFormat.CUSTOM2) is other - - -def test_registration_during_discovery_wins() -> None: - """A serializer registered while an entry point loads keeps precedence.""" - - def other(circuit: QuantumCircuit, backend: QDMIBackend) -> str: # ruff:ignore[unused-function-argument] - return "other program" - - def register_other() -> None: - registry.register(ProgramFormat.CUSTOM2, other) - - registry = _registry([_entry_point("CUSTOM2", "pkg.mod:serializer", _serializer, register_other)]) - - assert registry.get(ProgramFormat.CUSTOM2) is other - - -def test_lookup_during_discovery_does_not_start_a_second_pass() -> None: - """A re-entrant lookup returns without reading the entry points again.""" - calls = 0 - seen: list[object] = [] - - def look_up_again() -> None: - seen.append(registry.get(ProgramFormat.CUSTOM2)) - - def discover() -> list[EntryPoint]: - nonlocal calls - calls += 1 - return [_entry_point("CUSTOM2", "pkg.mod:serializer", _serializer, look_up_again)] - - registry = serializers._ProgramSerializerRegistry(discover) # ruff:ignore[private-member-access] - - assert registry.get(ProgramFormat.CUSTOM2) is _serializer - # Discovery ran once, and the re-entrant lookup saw no half-built result. - assert calls == 1 - assert seen == [None] - - -def test_discovery_is_retried_after_it_aborts() -> None: - """A failure that stops discovery leaves the registry cold rather than empty.""" - attempts = 0 - - def discover() -> list[EntryPoint]: - nonlocal attempts - attempts += 1 - if attempts == 1: - msg = "distribution metadata is unreadable" - raise RuntimeError(msg) - return [_entry_point("CUSTOM2", "pkg.mod:serializer", _serializer)] - - registry = serializers._ProgramSerializerRegistry(discover) # ruff:ignore[private-member-access] - - with pytest.raises(RuntimeError, match="unreadable"): - registry.get(ProgramFormat.CUSTOM2) - - assert registry.get(ProgramFormat.CUSTOM2) is _serializer - assert attempts == 2 - - -def test_preferred_program_formats_orders_a_shuffled_list() -> None: - """The preference tuple decides the order, not the order the device reports.""" - reported = [ - ProgramFormat.QASM2, - ProgramFormat.QPY, - ProgramFormat.IQM_JSON, - ProgramFormat.QASM3, - ProgramFormat.QIR_BASE_STRING, - ] - - assert serializers.preferred_program_formats(reported) == [ - ProgramFormat.IQM_JSON, - ProgramFormat.QPY, - ProgramFormat.QASM3, - ProgramFormat.QIR_BASE_STRING, - ProgramFormat.QASM2, - ] - - -def test_preferred_program_formats_drops_non_circuit_formats() -> None: - """A format that carries no serialized circuit cannot be serialized into.""" - reported = [ProgramFormat.CALIBRATION, ProgramFormat.QASM3, ProgramFormat.BATCH_JOB] - - assert serializers.preferred_program_formats(reported) == [ProgramFormat.QASM3] - - -def test_preferred_program_formats_puts_unnamed_formats_last(monkeypatch: pytest.MonkeyPatch) -> None: - """A format the preference tuple does not name keeps its reported position at the end.""" - monkeypatch.setattr(serializers, "PROGRAM_FORMAT_PREFERENCE", (ProgramFormat.QASM3,)) - reported = [ProgramFormat.QPY, ProgramFormat.QASM2, ProgramFormat.QASM3] - - assert serializers.preferred_program_formats(reported) == [ - ProgramFormat.QASM3, - ProgramFormat.QPY, - ProgramFormat.QASM2, - ] diff --git a/test/python/qdmi/test_qdmi.py b/test/python/qdmi/test_qdmi.py index 9fef03e6f5..6fc430c747 100644 --- a/test/python/qdmi/test_qdmi.py +++ b/test/python/qdmi/test_qdmi.py @@ -24,14 +24,13 @@ CustomProperty, Device, Job, + ProgramEncoding, ProgramFormat, is_binary_program_format, ) from mqt.core.qdmi.driver import ( DeviceDefinition, open_device, - register_device, - register_device_if_absent, registered_device_ids, ) @@ -166,10 +165,11 @@ def test_device_coupling_map(device: Device) -> None: def test_device_needs_calibration(device: Device) -> None: - """Test that the device needs calibration is an integer.""" - needs_cal = device.needs_calibration() - if needs_cal is not None: - assert isinstance(needs_cal, int) + """Test that the optional calibration age is a non-negative integer.""" + needs_calibration = device.needs_calibration() + if needs_calibration is not None: + assert isinstance(needs_calibration, int) + assert needs_calibration >= 0 def test_device_queue_length(device: Device) -> None: @@ -482,12 +482,12 @@ def test_device_submit_job_returns_valid_job(ddsim_device: Device) -> None: c = measure q; """ - job = ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3, num_shots=100) + job = ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3, num_shots=100) # Job should have a non-empty ID assert len(job.id) > 0 # The program format should be preserved - assert job.program_format == ProgramFormat.QASM3 + assert job.program_format == ProgramFormat.OPENQASM3 # The program should be preserved assert job.program == qasm3_program assert job.program_bytes == qasm3_program.encode() + b"\0" @@ -495,53 +495,61 @@ def test_device_submit_job_returns_valid_job(ddsim_device: Device) -> None: assert job.num_shots == 100 -def test_program_format_includes_batch_job() -> None: - """Expose every standard QDMI program format.""" - assert ProgramFormat.BATCH_JOB.value == 9 +def test_program_format_is_an_immutable_exact_value() -> None: + """Treat every program-format field as immutable exact identity.""" + qasm3 = ProgramFormat("openqasm", (3, 0, 0)) + assert qasm3 == ProgramFormat.OPENQASM3 + assert qasm3 != object() + assert hash(qasm3) == hash(ProgramFormat.OPENQASM3) + assert qasm3.format_id == "openqasm" + assert qasm3.version == (3, 0, 0) + assert not qasm3.profile + assert qasm3.encoding == ProgramEncoding.TEXT + with pytest.raises(ValueError, match="version must not be zero"): + ProgramFormat("example.invalid", (0, 0, 0)) + with pytest.raises(AttributeError): + qasm3.profile = "other" # ty: ignore[invalid-assignment] def test_is_binary_program_format() -> None: """Classify the program formats that require exact-byte submission.""" - binary = {ProgramFormat.QIR_BASE_MODULE, ProgramFormat.QIR_ADAPTIVE_MODULE, ProgramFormat.QPY} - for fmt in ProgramFormat: - assert is_binary_program_format(fmt) == (fmt in binary), fmt.name + formats = ( + ProgramFormat.OPENQASM2, + ProgramFormat.OPENQASM3, + ProgramFormat.QIR21_BASE_TEXT, + ProgramFormat.QIR21_BASE_BINARY, + ProgramFormat.QIR21_ADAPTIVE_TEXT, + ProgramFormat.QIR21_ADAPTIVE_BINARY, + ) + binary = {ProgramFormat.QIR21_BASE_BINARY, ProgramFormat.QIR21_ADAPTIVE_BINARY} + for fmt in formats: + assert is_binary_program_format(fmt) == (fmt in binary) + + +def test_program_features_are_scoped_to_an_exact_payload(ddsim_device: Device) -> None: + """Keep optional execution features separate for each accepted payload.""" + features = ddsim_device.try_program_features(ProgramFormat.OPENQASM3) + assert features is not None + assert "forward-branching" in {feature.id for feature in features} + assert all(not feature.constraint_id for feature in features) + assert all(feature.constraint_value == 0 for feature in features) + assert features[0] != object() + with pytest.raises(AttributeError): + features[0].value = 1 # ty: ignore[invalid-assignment] + assert ddsim_device.try_program_features(ProgramFormat("openqasm", (3, 1, 0))) is None @pytest.mark.parametrize("program", [b"OPENQASM 3.0;", b"OPENQASM 3.0;\0garbage\0", "OPENQASM 3.0;\0garbage"]) def test_device_rejects_invalid_text_payloads(ddsim_device: Device, program: str | bytes) -> None: """Reject payloads that do not satisfy QDMI's text contract.""" with pytest.raises(ValueError, match=r"Setting program: Invalid argument\."): - ddsim_device.submit_job(program, ProgramFormat.QASM3, num_shots=1) + ddsim_device.submit_job(program, ProgramFormat.OPENQASM3, num_shots=1) def test_device_rejects_text_for_binary_format(ddsim_device: Device) -> None: """Require exact byte submission for known binary formats.""" with pytest.raises(ValueError, match="require exact-byte submission"): - ddsim_device.submit_job("not bitcode", ProgramFormat.QIR_BASE_MODULE, num_shots=1) - - -def test_device_rejects_batch_jobs(ddsim_device: Device) -> None: - """State that MQT Core does not support batch jobs.""" - with pytest.raises(ValueError, match="does not support batch jobs"): - ddsim_device.submit_job(b"", ProgramFormat.BATCH_JOB, num_shots=1) - - -def test_device_sends_calibration_runs_elsewhere(ddsim_device: Device) -> None: - """Point a calibration run at its own entry point.""" - with pytest.raises(ValueError, match="submit_calibration_job"): - ddsim_device.submit_job(b"", ProgramFormat.CALIBRATION, num_shots=1) - - -@pytest.mark.parametrize("program", [None, "configuration", b"", b"\x01\x02"]) -def test_calibration_job_reaches_the_device(ddsim_device: Device, program: str | bytes | None) -> None: - """Let the device decide about a calibration run, with or without a payload. - - The DD simulator needs no calibration and declines the format itself. What - matters is that the client no longer refuses before asking, so the failure - is a device error rather than a `ValueError` about the argument. - """ - with pytest.raises(RuntimeError, match="Setting program format"): - ddsim_device.submit_calibration_job(program) + ddsim_device.submit_job("not bitcode", ProgramFormat.QIR21_BASE_BINARY, num_shots=1) def test_device_executes_qir_program(ddsim_device: Device) -> None: @@ -557,9 +565,9 @@ def test_device_executes_qir_program(ddsim_device: Device) -> None: """ target = CompilerTarget.from_device(ddsim_device) program = compile_program(qasm3_program, output=OutputFormat.QIR_BASE, target=target) - assert ProgramFormat.QIR_BASE_STRING in ddsim_device.supported_program_formats() + assert ProgramFormat.QIR21_BASE_TEXT in ddsim_device.supported_program_formats() - job = ddsim_device.submit_job(program.llvm_ir, ProgramFormat.QIR_BASE_STRING, num_shots=1024) + job = ddsim_device.submit_job(program.llvm_ir, ProgramFormat.QIR21_BASE_TEXT, num_shots=1024) job.wait() assert job.check() == Job.Status.DONE @@ -594,7 +602,7 @@ def test_device_executes_controlled_qir_with_exact_phase(ddsim_device: Device) - target = CompilerTarget.from_device(ddsim_device) program = compile_program(circuit, output=OutputFormat.QIR_BASE, target=target) - job = ddsim_device.submit_job(program.llvm_ir, ProgramFormat.QIR_BASE_STRING, num_shots=0) + job = ddsim_device.submit_job(program.llvm_ir, ProgramFormat.QIR21_BASE_TEXT, num_shots=0) job.wait() assert job.get_dense_statevector() == pytest.approx(expected) @@ -613,9 +621,9 @@ def test_device_executes_binary_qir_program(ddsim_device: Device) -> None: """ program = compile_program(qasm3_program, output=OutputFormat.QIR_BASE) program_bytes = program.to_bitcode() - assert ProgramFormat.QIR_BASE_MODULE in ddsim_device.supported_program_formats() + assert ProgramFormat.QIR21_BASE_BINARY in ddsim_device.supported_program_formats() - job = ddsim_device.submit_job(program_bytes, ProgramFormat.QIR_BASE_MODULE, num_shots=10) + job = ddsim_device.submit_job(program_bytes, ProgramFormat.QIR21_BASE_BINARY, num_shots=10) assert job.program_bytes == program_bytes with pytest.raises(ValueError, match="binary program"): _ = job.program @@ -627,20 +635,20 @@ def test_device_executes_binary_qir_program(ddsim_device: Device) -> None: def test_device_submit_job_handles_custom_parameters(ddsim_device: Device) -> None: """Test that submit_job forwards custom job parameters to DDSIM.""" - job = ddsim_device.submit_job("OPENQASM 3.0; qubit q; bit c = measure q;", ProgramFormat.QASM3, 1, custom1=7) + job = ddsim_device.submit_job("OPENQASM 3.0; qubit q; bit c = measure q;", ProgramFormat.OPENQASM3, 1, custom1=7) job.wait() assert job.check() == Job.Status.DONE with pytest.raises(ValueError, match=r"Setting custom parameter: Invalid argument\."): - ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.QASM3, 1, custom1="value") + ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.OPENQASM3, 1, custom1="value") with pytest.raises(RuntimeError, match=r"Setting custom parameter: Not supported\."): - ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.QASM3, 1, custom2="value") + ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.OPENQASM3, 1, custom2="value") with pytest.raises(RuntimeError, match=r"Setting custom parameter: Not supported\."): - ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.QASM3, 1, custom3="value") + ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.OPENQASM3, 1, custom3="value") with pytest.raises(RuntimeError, match=r"Setting custom parameter: Not supported\."): - ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.QASM3, 1, custom4="value") + ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.OPENQASM3, 1, custom4="value") with pytest.raises(RuntimeError, match=r"Setting custom parameter: Not supported\."): - ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.QASM3, 1, custom5="value") + ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.OPENQASM3, 1, custom5="value") def test_device_submit_job_preserves_num_shots(ddsim_device: Device) -> None: @@ -653,9 +661,9 @@ def test_device_submit_job_preserves_num_shots(ddsim_device: Device) -> None: """ # Submit jobs with different shot counts - job1 = ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3, num_shots=10) - job2 = ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3, num_shots=100) - job3 = ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3, num_shots=1000) + job1 = ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3, num_shots=10) + job2 = ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3, num_shots=100) + job3 = ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3, num_shots=1000) assert job1.num_shots == 10 assert job2.num_shots == 100 @@ -671,7 +679,7 @@ def test_device_submit_job_without_shots(ddsim_device: Device) -> None: c[0] = measure q[0]; """ - job = ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3) + job = ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3) assert job.num_shots == 1024 @@ -697,7 +705,7 @@ def submitted_job(ddsim_device: Device) -> Job: bit[1] c; c[0] = measure q[0]; """ - return ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3, num_shots=10) + return ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3, num_shots=10) def test_job_ids_are_unique(ddsim_device: Device) -> None: @@ -709,8 +717,8 @@ def test_job_ids_are_unique(ddsim_device: Device) -> None: c[0] = measure q[0]; """ - job1 = ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3, num_shots=10) - job2 = ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3, num_shots=10) + job1 = ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3, num_shots=10) + job2 = ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3, num_shots=10) assert job1.id != job2.id @@ -735,6 +743,14 @@ def test_job_custom_property_and_result_unsupported(submitted_job: Job) -> None: assert result_value is None +def test_job_raw_results_are_exact_bytes(submitted_job: Job) -> None: + """Expose the C result bytes without interpretation.""" + assert submitted_job.wait() + values = submitted_job.get_results(Job.Result.HIST_VALUES) + assert isinstance(values, bytes) + assert values + + def test_job_status_progresses(submitted_job: Job) -> None: """Test that job status progresses to completion.""" initial_status = submitted_job.check() @@ -798,7 +814,7 @@ def test_job_shots_match_counts(submitted_job: Job) -> None: def test_empty_program_has_empty_shot_strings(ddsim_device: Device) -> None: """Preserve shot cardinality when the program has no output bits.""" - job = ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.QASM3, num_shots=4) + job = ddsim_device.submit_job("OPENQASM 3.0;", ProgramFormat.OPENQASM3, num_shots=4) job.wait() assert job.get_shots() == [""] * 4 @@ -816,18 +832,18 @@ def simulator_job(ddsim_device: Device) -> Job: h q[0]; cx q[0], q[1]; """ - return ddsim_device.submit_job(qasm3_program, ProgramFormat.QASM3, num_shots=0) + return ddsim_device.submit_job(qasm3_program, ProgramFormat.OPENQASM3, num_shots=0) def test_empty_qasm_program_has_empty_results(ddsim_device: Device) -> None: """Return empty results for an empty QASM program.""" program = "OPENQASM 3.0;" - sample_job = ddsim_device.submit_job(program, ProgramFormat.QASM3, num_shots=4) + sample_job = ddsim_device.submit_job(program, ProgramFormat.OPENQASM3, num_shots=4) sample_job.wait() assert sample_job.get_counts() == {} - state_job = ddsim_device.submit_job(program, ProgramFormat.QASM3, num_shots=0) + state_job = ddsim_device.submit_job(program, ProgramFormat.OPENQASM3, num_shots=0) state_job.wait() assert state_job.get_dense_statevector() == [] assert state_job.get_dense_probabilities() == [] @@ -893,40 +909,6 @@ def test_simulator_job_get_sparse_probabilities_returns_valid_probabilities(simu assert sparse_probabilities["11"] == pytest.approx(0.5) -def test_register_device_does_not_load_nonexistent_library() -> None: - """Registration stores metadata and opening performs native loading.""" - library_path = Path("/nonexistent/lib.so") - definition = DeviceDefinition("python.missing", library_path, "PREFIX") - assert definition.device_id == "python.missing" - assert definition.library_path == library_path - assert definition.prefix == "PREFIX" - register_device(definition) - with pytest.raises(RuntimeError): - open_device("python.missing") - - -def test_register_device_if_absent_only_ignores_existing_id() -> None: - """Idempotent registration still validates duplicate definitions.""" - definition = DeviceDefinition("python.if-absent", "/nonexistent/device.so", "PREFIX") - assert register_device_if_absent(definition) - assert not register_device_if_absent(definition) - with pytest.raises(ValueError, match="library must not be empty"): - register_device_if_absent(DeviceDefinition("python.if-absent", "", "PREFIX")) - - -def test_registered_device_ids_include_runtime_registrations_in_order() -> None: - """Stable-ID enumeration is ordered and does not load native libraries.""" - ids_before = registered_device_ids() - register_device(DeviceDefinition("python.enumeration.first", "/nonexistent/first.so", "FIRST")) - register_device(DeviceDefinition("python.enumeration.second", "/nonexistent/second.so", "SECOND")) - - assert registered_device_ids() == [ - *ids_before, - "python.enumeration.first", - "python.enumeration.second", - ] - - def test_open_device_rejects_unknown_id() -> None: """Opening requires a stable registered ID.""" with pytest.raises(IndexError, match="Unknown QDMI device ID"): diff --git a/test/qdmi/devices/dd/concurrency_test.cpp b/test/qdmi/devices/dd/concurrency_test.cpp index 0cd02d134a..8fc245683e 100644 --- a/test/qdmi/devices/dd/concurrency_test.cpp +++ b/test/qdmi/devices/dd/concurrency_test.cpp @@ -32,7 +32,7 @@ TEST(Concurrency, ConcurrentStatevectorReads) { const qdmi_test::SessionGuard s{}; qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_STATE), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); @@ -63,7 +63,7 @@ TEST(Concurrency, ConcurrentStatevectorReads) { TEST(Concurrency, ConcurrentHistogramReads) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 1024), QDMI_SUCCESS); @@ -102,7 +102,7 @@ TEST(Concurrency, ConcurrentHistogramReads) { TEST(Concurrency, ConcurrentCheckDuringRun) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); constexpr size_t shots = 4096; @@ -135,9 +135,9 @@ TEST(Concurrency, ConcurrentQIRJobsOwnTheirRuntimeState) { for (size_t i = 0; i < numJobs; ++i) { auto job = std::make_unique(session.session); - ASSERT_EQ(qdmi_test::setProgram(job->job, QDMI_PROGRAM_FORMAT_QIRBASESTRING, - program), - QDMI_SUCCESS); + ASSERT_EQ( + qdmi_test::setProgram(job->job, qdmi_test::QIR21_BASE_TEXT, program), + QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(job->job, shots), QDMI_SUCCESS); ASSERT_EQ(MQT_DDSIM_QDMI_device_job_submit(job->job), QDMI_SUCCESS); jobs.emplace_back(std::move(job)); diff --git a/test/qdmi/devices/dd/device_properties_test.cpp b/test/qdmi/devices/dd/device_properties_test.cpp index 28cde42a10..dbfa3e83bc 100644 --- a/test/qdmi/devices/dd/device_properties_test.cpp +++ b/test/qdmi/devices/dd/device_properties_test.cpp @@ -137,16 +137,66 @@ TEST(DeviceProperties, SupportedProgramFormats) { QDMI_SUCCESS); const std::vector expected = { - QDMI_PROGRAM_FORMAT_QASM2, - QDMI_PROGRAM_FORMAT_QASM3, - QDMI_PROGRAM_FORMAT_QIRBASESTRING, - QDMI_PROGRAM_FORMAT_QIRBASEMODULE, - QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING, - QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE, + qdmi_test::OPENQASM2, qdmi_test::OPENQASM3, + qdmi_test::QIR21_BASE_TEXT, qdmi_test::QIR21_BASE_BINARY, + qdmi_test::QIR21_ADAPTIVE_TEXT, qdmi_test::QIR21_ADAPTIVE_BINARY, }; EXPECT_EQ(formats, expected); } +TEST(DeviceProperties, ProgramFeaturesValidateFormatStateAndBuffer) { + MQT_DDSIM_QDMI_Device_Session uninitialized = nullptr; + ASSERT_EQ(MQT_DDSIM_QDMI_device_session_alloc(&uninitialized), QDMI_SUCCESS); + EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + uninitialized, &qdmi_test::OPENQASM3, 0, nullptr, nullptr), + QDMI_ERROR_BADSTATE); + MQT_DDSIM_QDMI_device_session_free(uninitialized); + + const qdmi_test::SessionGuard session{}; + constexpr QDMI_Program_Format invalid{}; + EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + session.session, &invalid, 0, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); + + constexpr QDMI_Program_Format unsupported{ + .version = QDMI_MAKE_VERSION(1, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_BINARY, + .id = "qiskit.qpy", + .profile = "", + }; + EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + session.session, &unsupported, 0, nullptr, nullptr), + QDMI_ERROR_NOTSUPPORTED); + + auto noncanonical = qdmi_test::OPENQASM3; + noncanonical.profile[1] = 'x'; + EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + session.session, &noncanonical, 0, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); + + size_t size = 1U; + EXPECT_EQ( + MQT_DDSIM_QDMI_device_session_query_program_features( + session.session, &qdmi_test::QIR21_BASE_BINARY, 0, nullptr, &size), + QDMI_SUCCESS); + EXPECT_EQ(size, 0U); + + ASSERT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + session.session, &qdmi_test::OPENQASM3, 0, nullptr, &size), + QDMI_SUCCESS); + ASSERT_GT(size, sizeof(QDMI_Program_Feature)); + std::vector features(size / + sizeof(QDMI_Program_Feature)); + EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + session.session, &qdmi_test::OPENQASM3, + size - sizeof(QDMI_Program_Feature), features.data(), nullptr), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + session.session, &qdmi_test::OPENQASM3, size, features.data(), + nullptr), + QDMI_SUCCESS); +} + TEST(DeviceProperties, QubitsNumAvailable) { const qdmi_test::SessionGuard s{}; size_t nq = 0; diff --git a/test/qdmi/devices/dd/device_status_test.cpp b/test/qdmi/devices/dd/device_status_test.cpp index a06bc7df8d..22773c95a4 100644 --- a/test/qdmi/devices/dd/device_status_test.cpp +++ b/test/qdmi/devices/dd/device_status_test.cpp @@ -37,7 +37,7 @@ TEST(DeviceStatus, TransitionsBusyThenIdleAfterJob) { // Submit a job to force BUSY, then wait for the return to IDLE. const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_HEAVY_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 16384), QDMI_SUCCESS); diff --git a/test/qdmi/devices/dd/error_handling_test.cpp b/test/qdmi/devices/dd/error_handling_test.cpp index b0270a6b41..d72550337c 100644 --- a/test/qdmi/devices/dd/error_handling_test.cpp +++ b/test/qdmi/devices/dd/error_handling_test.cpp @@ -34,6 +34,9 @@ TEST_F(ErrorHandling, NullptrArguments) { EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_device_property( nullptr, QDMI_DEVICE_PROPERTY_NAME, 0, nullptr, nullptr), QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + nullptr, &qdmi_test::OPENQASM3, 0, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); EXPECT_EQ( MQT_DDSIM_QDMI_device_session_query_site_property( nullptr, nullptr, QDMI_SITE_PROPERTY_INDEX, 0, nullptr, nullptr), @@ -63,6 +66,9 @@ TEST_F(ErrorHandling, NullptrArguments) { QDMI_ERROR_INVALIDARGUMENT); const qdmi_test::SessionGuard s{}; + EXPECT_EQ(MQT_DDSIM_QDMI_device_session_query_program_features( + s.session, nullptr, 0, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); EXPECT_EQ(MQT_DDSIM_QDMI_device_session_create_device_job(s.session, nullptr), QDMI_ERROR_INVALIDARGUMENT); @@ -74,7 +80,7 @@ TEST_F(ErrorHandling, NullptrArguments) { TEST_F(ErrorHandling, GetResultsBeforeDone) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_HEAVY_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 16384), QDMI_SUCCESS); @@ -120,13 +126,13 @@ TEST_F(ErrorHandling, MaxEnums) { EXPECT_EQ(MQT_DDSIM_QDMI_device_job_query_property( j.job, QDMI_DEVICE_JOB_PROPERTY_MAX, 0, nullptr, nullptr), QDMI_ERROR_INVALIDARGUMENT); - constexpr QDMI_Program_Format maxFmt = QDMI_PROGRAM_FORMAT_MAX; + constexpr QDMI_Program_Format maxFmt{}; EXPECT_EQ(MQT_DDSIM_QDMI_device_job_set_parameter( j.job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, sizeof(QDMI_Program_Format), &maxFmt), QDMI_ERROR_INVALIDARGUMENT); - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 16), QDMI_SUCCESS); @@ -237,7 +243,7 @@ TEST_F(ErrorHandling, CustomEnums) { j.job, QDMI_DEVICE_JOB_PARAMETER_CUSTOM5, 0, nullptr), QDMI_ERROR_NOTSUPPORTED); - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 16), QDMI_SUCCESS); @@ -293,7 +299,7 @@ TEST_F(ErrorHandling, MalformedProgramFailsForBothModes) { // Sampling mode { const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_MALFORMED), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 128), QDMI_SUCCESS); @@ -305,7 +311,7 @@ TEST_F(ErrorHandling, MalformedProgramFailsForBothModes) { // Statevector mode { const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_MALFORMED), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); @@ -324,7 +330,7 @@ c[0] = measure q[0]; )qasm"; const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, program), + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, program), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 1), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::submitAndWait(j.job, 0), QDMI_SUCCESS); diff --git a/test/qdmi/devices/dd/helpers/test_utils.cpp b/test/qdmi/devices/dd/helpers/test_utils.cpp index c1431de37c..89d8900331 100644 --- a/test/qdmi/devices/dd/helpers/test_utils.cpp +++ b/test/qdmi/devices/dd/helpers/test_utils.cpp @@ -94,26 +94,22 @@ int setProgram(MQT_DDSIM_QDMI_Device_Job job, const QDMI_Program_Format fmt, if (job == nullptr) { return QDMI_ERROR_INVALIDARGUMENT; } - int rc = MQT_DDSIM_QDMI_device_job_set_parameter( - job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, sizeof(QDMI_Program_Format), - &fmt); - if (rc != QDMI_SUCCESS && rc != QDMI_ERROR_NOTSUPPORTED) { - return rc; - } // Text payloads include the trailing '\0' per the QDMI wire convention. // Binary payloads ship the exact byte count. // The `+1` is safe here because every existing call to `setProgram` with a // text format passes a `program` with a string literal or `std::string`, both // of which guarantee `'\0'` at `data()[size()]`. - const bool isTextProgramFormat = fmt == QDMI_PROGRAM_FORMAT_QASM2 || - fmt == QDMI_PROGRAM_FORMAT_QASM3 || - fmt == QDMI_PROGRAM_FORMAT_QIRBASESTRING || - fmt == QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING; + const bool isTextProgramFormat = fmt.encoding == QDMI_PROGRAM_ENCODING_TEXT; const auto bytesToSend = isTextProgramFormat ? program.size() + 1 : program.size(); - rc = MQT_DDSIM_QDMI_device_job_set_parameter( - job, QDMI_DEVICE_JOB_PARAMETER_PROGRAM, bytesToSend, program.data()); - return rc; + const void* const programData = program.data(); + const auto result = MQT_DDSIM_QDMI_device_job_set_parameter( + job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, sizeof(fmt), &fmt); + if (result != QDMI_SUCCESS) { + return result; + } + return MQT_DDSIM_QDMI_device_job_set_parameter( + job, QDMI_DEVICE_JOB_PARAMETER_PROGRAM, bytesToSend, programData); } int setShots(MQT_DDSIM_QDMI_Device_Job job, const size_t shots) { diff --git a/test/qdmi/devices/dd/helpers/test_utils.hpp b/test/qdmi/devices/dd/helpers/test_utils.hpp index e15a4ca198..5f02172a4d 100644 --- a/test/qdmi/devices/dd/helpers/test_utils.hpp +++ b/test/qdmi/devices/dd/helpers/test_utils.hpp @@ -22,8 +22,50 @@ #include #include +[[nodiscard]] inline bool operator==(const QDMI_Program_Format& lhs, + const QDMI_Program_Format& rhs) noexcept { + return QDMI_program_format_equal(&lhs, &rhs) != 0; +} + namespace qdmi_test { +inline constexpr QDMI_Program_Format OPENQASM2{ + .version = QDMI_MAKE_VERSION(2, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "openqasm", + .profile = "", +}; +inline constexpr QDMI_Program_Format OPENQASM3{ + .version = QDMI_MAKE_VERSION(3, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "openqasm", + .profile = "", +}; +inline constexpr QDMI_Program_Format QIR21_BASE_TEXT{ + .version = QDMI_MAKE_VERSION(2, 1, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "qir", + .profile = "base", +}; +inline constexpr QDMI_Program_Format QIR21_BASE_BINARY{ + .version = QDMI_MAKE_VERSION(2, 1, 0), + .encoding = QDMI_PROGRAM_ENCODING_BINARY, + .id = "qir", + .profile = "base", +}; +inline constexpr QDMI_Program_Format QIR21_ADAPTIVE_TEXT{ + .version = QDMI_MAKE_VERSION(2, 1, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "qir", + .profile = "adaptive", +}; +inline constexpr QDMI_Program_Format QIR21_ADAPTIVE_BINARY{ + .version = QDMI_MAKE_VERSION(2, 1, 0), + .encoding = QDMI_PROGRAM_ENCODING_BINARY, + .id = "qir", + .profile = "adaptive", +}; + struct SessionGuard { MQT_DDSIM_QDMI_Device_Session session{nullptr}; SessionGuard(); diff --git a/test/qdmi/devices/dd/job_lifecycle_test.cpp b/test/qdmi/devices/dd/job_lifecycle_test.cpp index bf9be9282f..d10432cc63 100644 --- a/test/qdmi/devices/dd/job_lifecycle_test.cpp +++ b/test/qdmi/devices/dd/job_lifecycle_test.cpp @@ -24,7 +24,7 @@ using testing::AnyOf; TEST(JobLifecycle, SubmitAndWaitSampling) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 256), QDMI_SUCCESS); @@ -34,7 +34,7 @@ TEST(JobLifecycle, SubmitAndWaitSampling) { TEST(JobLifecycle, SubmitAndWaitStatevector) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_STATE), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); @@ -47,7 +47,7 @@ TEST(JobLifecycle, WaitInvalidBeforeSubmitAndIdempotentAfterDone) { // wait before submit is invalid EXPECT_EQ(MQT_DDSIM_QDMI_device_job_wait(j.job, 0), QDMI_ERROR_BADSTATE); // now run a quick job - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 64), QDMI_SUCCESS); @@ -59,7 +59,7 @@ TEST(JobLifecycle, WaitInvalidBeforeSubmitAndIdempotentAfterDone) { TEST(JobLifecycle, WaitTimeoutPath) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); // More shots to increase runtime slightly @@ -83,7 +83,7 @@ TEST(JobLifecycle, CancelFromCreatedAndFromRunningAndFromDone) { { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 4096), QDMI_SUCCESS); @@ -95,7 +95,7 @@ TEST(JobLifecycle, CancelFromCreatedAndFromRunningAndFromDone) { { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 1), QDMI_SUCCESS); @@ -110,7 +110,7 @@ TEST(JobLifecycle, FreeWhileRunningWaitsForCompletion) { MQT_DDSIM_QDMI_Device_Job job = nullptr; ASSERT_EQ(MQT_DDSIM_QDMI_device_session_create_device_job(s.session, &job), QDMI_SUCCESS); - ASSERT_EQ(qdmi_test::setProgram(job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(job, qdmi_test::OPENQASM3, qdmi_test::QASM3_HEAVY_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(job, 4096), QDMI_SUCCESS); diff --git a/test/qdmi/devices/dd/job_parameters_test.cpp b/test/qdmi/devices/dd/job_parameters_test.cpp index e127c70a7b..81b937ef27 100644 --- a/test/qdmi/devices/dd/job_parameters_test.cpp +++ b/test/qdmi/devices/dd/job_parameters_test.cpp @@ -28,7 +28,7 @@ TEST(JobParameters, SetAndQueryBasics) { const qdmi_test::JobGuard j{s.session}; // Program format QASM3 - constexpr QDMI_Program_Format fmt = QDMI_PROGRAM_FORMAT_QASM3; + constexpr QDMI_Program_Format fmt = qdmi_test::OPENQASM3; ASSERT_EQ(MQT_DDSIM_QDMI_device_job_set_parameter( j.job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, sizeof(QDMI_Program_Format), &fmt), @@ -56,7 +56,7 @@ TEST(JobParameters, SetAndQueryBasics) { sizeof(QDMI_Program_Format), &fmtOut, &size), QDMI_SUCCESS); EXPECT_EQ(size, sizeof(QDMI_Program_Format)); - EXPECT_EQ(fmtOut, QDMI_PROGRAM_FORMAT_QASM3); + EXPECT_EQ(fmtOut, qdmi_test::OPENQASM3); size_t shotsOut = 0; ASSERT_EQ(MQT_DDSIM_QDMI_device_job_query_property( @@ -95,7 +95,7 @@ TEST(JobParameters, RejectsUnterminatedTextProgram) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - constexpr QDMI_Program_Format fmt = QDMI_PROGRAM_FORMAT_QASM3; + constexpr QDMI_Program_Format fmt = qdmi_test::OPENQASM3; ASSERT_EQ(MQT_DDSIM_QDMI_device_job_set_parameter( j.job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, sizeof(QDMI_Program_Format), &fmt), @@ -112,7 +112,7 @@ TEST(JobParameters, RejectsInteriorNullInTextProgram) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - constexpr QDMI_Program_Format fmt = QDMI_PROGRAM_FORMAT_QASM3; + constexpr QDMI_Program_Format fmt = qdmi_test::OPENQASM3; ASSERT_EQ(MQT_DDSIM_QDMI_device_job_set_parameter( j.job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, sizeof(QDMI_Program_Format), &fmt), @@ -129,14 +129,14 @@ TEST(JobParameters, ProgramFormatSupport) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - // Supported + /// Supported descriptors. for (QDMI_Program_Format fmt : { - QDMI_PROGRAM_FORMAT_QASM2, - QDMI_PROGRAM_FORMAT_QASM3, - QDMI_PROGRAM_FORMAT_QIRBASESTRING, - QDMI_PROGRAM_FORMAT_QIRBASEMODULE, - QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING, - QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE, + qdmi_test::OPENQASM2, + qdmi_test::OPENQASM3, + qdmi_test::QIR21_BASE_TEXT, + qdmi_test::QIR21_BASE_BINARY, + qdmi_test::QIR21_ADAPTIVE_TEXT, + qdmi_test::QIR21_ADAPTIVE_BINARY, }) { EXPECT_EQ(MQT_DDSIM_QDMI_device_job_set_parameter( j.job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, @@ -144,22 +144,24 @@ TEST(JobParameters, ProgramFormatSupport) { QDMI_SUCCESS); } - // Unsupported → NOTSUPPORTED - for (QDMI_Program_Format fmt : { - QDMI_PROGRAM_FORMAT_CALIBRATION, - QDMI_PROGRAM_FORMAT_QPY, - QDMI_PROGRAM_FORMAT_IQMJSON, - QDMI_PROGRAM_FORMAT_CUSTOM1, - QDMI_PROGRAM_FORMAT_CUSTOM2, - QDMI_PROGRAM_FORMAT_CUSTOM3, - QDMI_PROGRAM_FORMAT_CUSTOM4, - QDMI_PROGRAM_FORMAT_CUSTOM5, - }) { - EXPECT_EQ(MQT_DDSIM_QDMI_device_job_set_parameter( - j.job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, - sizeof(QDMI_Program_Format), &fmt), - QDMI_ERROR_NOTSUPPORTED); - } + /// An exact but unsupported descriptor is rejected. + QDMI_Program_Format unsupported{ + .version = QDMI_MAKE_VERSION(1, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_BINARY, + .id = "qiskit.qpy", + .profile = "", + }; + EXPECT_EQ(MQT_DDSIM_QDMI_device_job_set_parameter( + j.job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, + sizeof(QDMI_Program_Format), &unsupported), + QDMI_ERROR_NOTSUPPORTED); + + auto invalid = qdmi_test::OPENQASM3; + invalid.id[sizeof("openqasm")] = 'x'; + EXPECT_EQ(MQT_DDSIM_QDMI_device_job_set_parameter( + j.job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, + sizeof(QDMI_Program_Format), &invalid), + QDMI_ERROR_INVALIDARGUMENT); } TEST(JobParameters, SamplingSeed) { diff --git a/test/qdmi/devices/dd/results_probabilities_test.cpp b/test/qdmi/devices/dd/results_probabilities_test.cpp index 820fbb967f..362eecc146 100644 --- a/test/qdmi/devices/dd/results_probabilities_test.cpp +++ b/test/qdmi/devices/dd/results_probabilities_test.cpp @@ -24,7 +24,7 @@ TEST(ResultsProbabilities, DenseSumToOneAndBufferTooSmall) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_STATE), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); @@ -52,7 +52,7 @@ TEST(ResultsProbabilities, DenseSumToOneAndBufferTooSmall) { TEST(ResultsProbabilities, SparseSumToOneAndBufferTooSmall) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_STATE), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); diff --git a/test/qdmi/devices/dd/results_sampling_test.cpp b/test/qdmi/devices/dd/results_sampling_test.cpp index 26942b14d8..3d7650895e 100644 --- a/test/qdmi/devices/dd/results_sampling_test.cpp +++ b/test/qdmi/devices/dd/results_sampling_test.cpp @@ -148,13 +148,13 @@ class QIRHistogramTestString : public HistogramTest {}; } // namespace TEST_F(HistogramTest, QASM3Program) { - constexpr QDMI_Program_Format format = QDMI_PROGRAM_FORMAT_QASM3; + constexpr QDMI_Program_Format format = qdmi_test::OPENQASM3; constexpr std::string_view program = qdmi_test::QASM3_BELL_SAMPLING; checkHistogram(runProgram(format, program)); } TEST_F(HistogramTest, QASM3ProgramWithoutMeasurements) { - constexpr QDMI_Program_Format format = QDMI_PROGRAM_FORMAT_QASM3; + constexpr QDMI_Program_Format format = qdmi_test::OPENQASM3; constexpr std::string_view program = R"qasm(OPENQASM 3.0; include "stdgates.inc"; qubit[2] q; @@ -165,7 +165,7 @@ cx q[0], q[1]; } TEST_F(HistogramTest, QASM2Program) { - constexpr QDMI_Program_Format format = QDMI_PROGRAM_FORMAT_QASM2; + constexpr QDMI_Program_Format format = qdmi_test::OPENQASM2; constexpr std::string_view program = qdmi_test::QASM2_BELL_SAMPLING; checkHistogram(runProgram(format, program)); } @@ -182,58 +182,58 @@ c0[0] = measure q[0]; c0[1] = measure q[1]; c1 = measure q[2]; )qasm"; - const auto [keys, values] = runProgram(QDMI_PROGRAM_FORMAT_QASM3, program); + const auto [keys, values] = runProgram(qdmi_test::OPENQASM3, program); EXPECT_EQ(keys, std::vector{"101"}); EXPECT_EQ(values, std::vector{NUM_SHOTS}); } TEST_F(QIRHistogramTestModule, BaseStatic) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRBASEMODULE; + constexpr auto format = qdmi_test::QIR21_BASE_BINARY; checkHistogram(runProgram(format, getProgram("BellPairStatic.ll"))); } TEST_F(QIRHistogramTestString, BaseStatic) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRBASESTRING; + constexpr auto format = qdmi_test::QIR21_BASE_TEXT; checkHistogram( runProgram(format, qdmi_test::getQIRProgram("BellPairStatic.ll"))); } TEST_F(QIRHistogramTestModule, BaseDynamic) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRBASEMODULE; + constexpr auto format = qdmi_test::QIR21_BASE_BINARY; checkHistogram(runProgram(format, getProgram("BellPairDynamic.ll"))); } TEST_F(QIRHistogramTestString, BaseDynamic) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRBASESTRING; + constexpr auto format = qdmi_test::QIR21_BASE_TEXT; checkHistogram( runProgram(format, qdmi_test::getQIRProgram("BellPairDynamic.ll"))); } TEST_F(QIRHistogramTestModule, Adaptive) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE; + constexpr auto format = qdmi_test::QIR21_ADAPTIVE_BINARY; checkHistogram(runProgram(format, getProgram("BellPairAdaptive.ll"))); } TEST_F(QIRHistogramTestString, Adaptive) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING; + constexpr auto format = qdmi_test::QIR21_ADAPTIVE_TEXT; checkHistogram( runProgram(format, qdmi_test::getQIRProgram("BellPairAdaptive.ll"))); } TEST_F(QIRHistogramTestModule, AdaptiveRecordOutputs) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE; + constexpr auto format = qdmi_test::QIR21_ADAPTIVE_BINARY; checkSmokeHistogram( runProgram(format, getProgram("AdaptiveRecordOutputs.ll"))); } TEST_F(QIRHistogramTestString, AdaptiveRecordOutputs) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING; + constexpr auto format = qdmi_test::QIR21_ADAPTIVE_TEXT; checkSmokeHistogram( runProgram(format, qdmi_test::getQIRProgram("AdaptiveRecordOutputs.ll"))); } TEST_F(HistogramTest, SeedReproducesQASMSampling) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QASM3; + constexpr auto format = qdmi_test::OPENQASM3; constexpr std::string_view program = qdmi_test::QASM3_BELL_SAMPLING; std::vector first; std::vector second; @@ -244,7 +244,7 @@ TEST_F(HistogramTest, SeedReproducesQASMSampling) { } TEST_F(QIRHistogramTestString, SeedReproducesQIRSampling) { - constexpr auto format = QDMI_PROGRAM_FORMAT_QIRBASESTRING; + constexpr auto format = qdmi_test::QIR21_BASE_TEXT; const auto program = qdmi_test::getQIRProgram("BellPairStatic.ll"); std::vector first; std::vector second; @@ -269,7 +269,7 @@ a[1] = measure q[0]; if (a[1]) { x q[1]; } b[0] = measure q[1]; )qasm"; - const auto [keys, values] = runProgram(QDMI_PROGRAM_FORMAT_QASM3, program, 7); + const auto [keys, values] = runProgram(qdmi_test::OPENQASM3, program, 7); EXPECT_EQ(keys, (std::vector{"0000", "0110"})); EXPECT_EQ(std::accumulate(values.begin(), values.end(), size_t{0}), NUM_SHOTS); @@ -278,9 +278,8 @@ b[0] = measure q[1]; TEST(ResultsSampling, EmptyQASM3YieldsEmptyHistogram) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ( - qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, "OPENQASM 3.0;"), - QDMI_SUCCESS); + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, "OPENQASM 3.0;"), + QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 4), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::submitAndWait(j.job, 0), QDMI_SUCCESS); @@ -301,10 +300,35 @@ TEST(ResultsSampling, EmptyQASM3YieldsEmptyHistogram) { } } +TEST(ResultsSampling, QIRProgramOutputIsAvailable) { + const qdmi_test::SessionGuard session{}; + const qdmi_test::JobGuard job{session.session}; + ASSERT_EQ(qdmi_test::setProgram( + job.job, qdmi_test::QIR21_ADAPTIVE_TEXT, + qdmi_test::getQIRProgram("AdaptiveRecordOutputs.ll")), + QDMI_SUCCESS); + ASSERT_EQ(qdmi_test::setShots(job.job, 1), QDMI_SUCCESS); + ASSERT_EQ(qdmi_test::submitAndWait(job.job, 0), QDMI_SUCCESS); + + const auto size = + qdmi_test::querySize(job.job, QDMI_JOB_RESULT_PROGRAMOUTPUT); + ASSERT_GT(size, 0U); + std::string output(size, '\0'); + EXPECT_EQ(MQT_DDSIM_QDMI_device_job_get_results( + job.job, QDMI_JOB_RESULT_PROGRAMOUTPUT, size - 1U, + output.data(), nullptr), + QDMI_ERROR_INVALIDARGUMENT); + ASSERT_EQ(MQT_DDSIM_QDMI_device_job_get_results(job.job, + QDMI_JOB_RESULT_PROGRAMOUTPUT, + size, output.data(), nullptr), + QDMI_SUCCESS); + EXPECT_TRUE(output.starts_with("HEADER\tschema_id\t")); +} + TEST(ResultsSampling, BufferTooSmallErrors) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 512), QDMI_SUCCESS); @@ -318,6 +342,10 @@ TEST(ResultsSampling, BufferTooSmallErrors) { shotsTooSmall.data(), nullptr), QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_DDSIM_QDMI_device_job_get_results( + j.job, QDMI_JOB_RESULT_PROGRAMOUTPUT, 0, nullptr, nullptr), + QDMI_ERROR_NOTSUPPORTED); + if (const size_t ks = qdmi_test::querySize(j.job, QDMI_JOB_RESULT_HIST_KEYS); ks > 0) { std::vector tooSmall(ks - 1); @@ -341,7 +369,7 @@ TEST(ResultsSampling, BufferTooSmallErrors) { TEST(ResultsSampling, StateAndProbRequestsAreInvalidWhenShotsPositive) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 32), QDMI_SUCCESS); diff --git a/test/qdmi/devices/dd/results_statevector_test.cpp b/test/qdmi/devices/dd/results_statevector_test.cpp index d697b6af1b..9694399122 100644 --- a/test/qdmi/devices/dd/results_statevector_test.cpp +++ b/test/qdmi/devices/dd/results_statevector_test.cpp @@ -48,27 +48,26 @@ void expectBellState(const QDMI_Program_Format format, } // namespace TEST(ResultsStatevector, QASM2YieldsBellState) { - expectBellState(QDMI_PROGRAM_FORMAT_QASM2, qdmi_test::QASM2_BELL_STATE); + expectBellState(qdmi_test::OPENQASM2, qdmi_test::QASM2_BELL_STATE); } TEST(ResultsStatevector, QASM2IgnoresFinalMeasurements) { - expectBellState(QDMI_PROGRAM_FORMAT_QASM2, qdmi_test::QASM2_BELL_SAMPLING); + expectBellState(qdmi_test::OPENQASM2, qdmi_test::QASM2_BELL_SAMPLING); } TEST(ResultsStatevector, QASM3YieldsBellState) { - expectBellState(QDMI_PROGRAM_FORMAT_QASM3, qdmi_test::QASM3_BELL_STATE); + expectBellState(qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_STATE); } TEST(ResultsStatevector, QASM3IgnoresFinalMeasurements) { - expectBellState(QDMI_PROGRAM_FORMAT_QASM3, qdmi_test::QASM3_BELL_SAMPLING); + expectBellState(qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_SAMPLING); } TEST(ResultsStatevector, EmptyQASM3YieldsEmptyResults) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ( - qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, "OPENQASM 3.0;"), - QDMI_SUCCESS); + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, "OPENQASM 3.0;"), + QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::submitAndWait(j.job, 0), QDMI_SUCCESS); @@ -96,7 +95,7 @@ TEST(ResultsStatevector, EmptyQASM3YieldsEmptyResults) { TEST(ResultsStatevector, DenseNormalizedAndBufferTooSmall) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_STATE), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); @@ -124,7 +123,7 @@ TEST(ResultsStatevector, DenseNormalizedAndBufferTooSmall) { TEST(ResultsStatevector, SparseNormalizedAndBufferTooSmall) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_STATE), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); @@ -161,7 +160,7 @@ TEST(ResultsStatevector, SparseNormalizedAndBufferTooSmall) { TEST(ResultsStatevector, SamplingRequestsInvalidWithShotsZero) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; - ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::OPENQASM3, qdmi_test::QASM3_BELL_STATE), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); @@ -182,9 +181,8 @@ TEST(ResultsStatevector, QIRBaseStringYieldsBellState) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; const auto program = qdmi_test::getQIRProgram("BellPairStatic.ll"); - ASSERT_EQ( - qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QIRBASESTRING, program), - QDMI_SUCCESS); + ASSERT_EQ(qdmi_test::setProgram(j.job, qdmi_test::QIR21_BASE_TEXT, program), + QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::submitAndWait(j.job, 0), QDMI_SUCCESS); diff --git a/test/qdmi/devices/sc/test_device.cpp b/test/qdmi/devices/sc/test_device.cpp index 37e5e7bbb9..3f24360c30 100644 --- a/test/qdmi/devices/sc/test_device.cpp +++ b/test/qdmi/devices/sc/test_device.cpp @@ -503,7 +503,12 @@ TEST_F(ScQDMISpecificationTest, JobSetParameter) { } TEST_F(ScQDMIJobSpecificationTest, JobSetParameter) { - QDMI_Program_Format value = QDMI_PROGRAM_FORMAT_QASM2; + QDMI_Program_Format value{ + .version = QDMI_MAKE_VERSION(2, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "openqasm", + .profile = "", + }; EXPECT_THAT(MQT_SC_QDMI_device_job_set_parameter( job, QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT, sizeof(QDMI_Program_Format), &value), @@ -619,6 +624,41 @@ TEST_F(ScQDMISpecificationTest, QueryDeviceProperty) { MQT_SC_QDMI_device_session_free(uninitializedSession); } +TEST_F(ScQDMISpecificationTest, QueryProgramFeatures) { + constexpr QDMI_Program_Format format{ + .version = QDMI_MAKE_VERSION(2, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "openqasm", + .profile = "", + }; + MQT_SC_QDMI_Device_Session uninitializedSession = nullptr; + ASSERT_EQ(MQT_SC_QDMI_device_session_alloc(&uninitializedSession), + QDMI_SUCCESS); + EXPECT_EQ(MQT_SC_QDMI_device_session_query_program_features( + nullptr, &format, 0U, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_SC_QDMI_device_session_query_program_features( + session, nullptr, 0U, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_SC_QDMI_device_session_query_program_features( + session, &format, 0U, nullptr, nullptr), + QDMI_ERROR_NOTSUPPORTED); + EXPECT_EQ(MQT_SC_QDMI_device_session_query_program_features( + uninitializedSession, &format, 0U, nullptr, nullptr), + QDMI_ERROR_BADSTATE); + + constexpr QDMI_Program_Format malformed{}; + EXPECT_EQ(MQT_SC_QDMI_device_session_query_program_features( + session, &malformed, 0U, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); + auto noncanonical = format; + noncanonical.id[sizeof("openqasm")] = 'x'; + EXPECT_EQ(MQT_SC_QDMI_device_session_query_program_features( + session, &noncanonical, 0U, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); + MQT_SC_QDMI_device_session_free(uninitializedSession); +} + TEST_F(ScQDMISpecificationTest, QuerySiteProperty) { MQT_SC_QDMI_Site site = querySites(session).front(); EXPECT_EQ( diff --git a/test/qdmi/driver/session_device.cpp b/test/qdmi/driver/session_device.cpp index e59af21d81..136da55b55 100644 --- a/test/qdmi/driver/session_device.cpp +++ b/test/qdmi/driver/session_device.cpp @@ -15,8 +15,10 @@ #include #include #include +#include #include #include +#include struct QDMI_Child_Device_impl_d {}; @@ -35,7 +37,11 @@ struct QDMI_Device_Session_impl_d { struct QDMI_Device_Job_impl_d { QDMI_Device_Session session = nullptr; bool retrieved = false; - QDMI_Program_Format format = QDMI_PROGRAM_FORMAT_MAX; + bool submitted = false; + std::string id = "session-job"; + QDMI_Program_Format format{}; + size_t shots = 0U; + std::vector program; }; namespace { @@ -147,6 +153,23 @@ auto queryValue(const T& result, const size_t size, void* value, std::memcpy(value, &result, sizeof(T)); return QDMI_SUCCESS; } + +auto queryBytes(const std::span result, const size_t size, + void* value, size_t* sizeRet) -> int { + if (sizeRet != nullptr) { + *sizeRet = result.size(); + } + if (value == nullptr) { + return QDMI_SUCCESS; + } + if (size < result.size()) { + return QDMI_ERROR_INVALIDARGUMENT; + } + if (!result.empty()) { + std::memcpy(value, result.data(), result.size()); + } + return QDMI_SUCCESS; +} } // namespace // QDMI requires these exported C symbols to use the configured device prefix. @@ -299,6 +322,12 @@ extern "C" int TEST_SESSION_QDMI_device_session_query_device_property( return queryString(name, size, value, sizeRet); } +extern "C" int TEST_SESSION_QDMI_device_session_query_program_features( + QDMI_Device_Session /*session*/, const QDMI_Program_Format* /*format*/, + size_t /*size*/, void* /*value*/, size_t* /*sizeRet*/) { + return QDMI_ERROR_NOTSUPPORTED; +} + extern "C" int TEST_SESSION_QDMI_device_session_query_site_property( QDMI_Device_Session /*session*/, QDMI_Site /*site*/, QDMI_Site_Property /*property*/, size_t /*size*/, void* /*value*/, @@ -348,76 +377,158 @@ extern "C" int TEST_SESSION_QDMI_device_session_retrieve_device_job_by_id( *jobId == '\0' || job == nullptr) { return QDMI_ERROR_INVALIDARGUMENT; } - if (std::strcmp(jobId, "session-job") != 0) { + if (std::strcmp(jobId, "session-job") != 0 && + std::strcmp(jobId, "malformed-text-job") != 0) { return QDMI_ERROR_NOTFOUND; } // The QDMI C API transfers this allocation through an opaque raw handle. // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - *job = new (std::nothrow) + auto* retrieved = new (std::nothrow) QDMI_Device_Job_impl_d{.session = session, .retrieved = true}; - return *job == nullptr ? QDMI_ERROR_OUTOFMEM : QDMI_SUCCESS; + if (retrieved == nullptr) { + return QDMI_ERROR_OUTOFMEM; + } + retrieved->id = jobId; + retrieved->format = { + .version = QDMI_MAKE_VERSION(3U, 0U, 0U), + .encoding = QDMI_PROGRAM_ENCODING_TEXT, + .id = "openqasm", + .profile = "", + }; + retrieved->program = { + std::byte{'x'}, + std::byte{0}, + std::byte{'y'}, + std::byte{0}, + }; + *job = retrieved; + return QDMI_SUCCESS; } extern "C" int TEST_SESSION_QDMI_device_job_set_parameter( QDMI_Device_Job job, const QDMI_Device_Job_Parameter parameter, const size_t size, const void* value) { - if (job == nullptr) { + if (job == nullptr || (value != nullptr && size == 0U)) { return QDMI_ERROR_INVALIDARGUMENT; } - if (job->retrieved) { + if (job->retrieved || job->submitted) { return QDMI_ERROR_BADSTATE; } if (parameter == QDMI_DEVICE_JOB_PARAMETER_PROGRAMFORMAT) { - if (value == nullptr || size != sizeof(job->format)) { + if (value == nullptr || size != sizeof(QDMI_Program_Format)) { return QDMI_ERROR_INVALIDARGUMENT; } - std::memcpy(&job->format, value, size); + job->format = *static_cast(value); + return QDMI_SUCCESS; } - if (parameter == QDMI_DEVICE_JOB_PARAMETER_SHOTSNUM && - job->format == QDMI_PROGRAM_FORMAT_CUSTOM1) { - return QDMI_ERROR_NOTSUPPORTED; + if (parameter == QDMI_DEVICE_JOB_PARAMETER_PROGRAM) { + if (value == nullptr) { + return QDMI_SUCCESS; + } + const std::span bytes{static_cast(value), size}; + job->program.assign(bytes.begin(), bytes.end()); + return QDMI_SUCCESS; } - return QDMI_SUCCESS; + if (parameter == QDMI_DEVICE_JOB_PARAMETER_SHOTSNUM) { + if (job->format.encoding == QDMI_PROGRAM_ENCODING_BINARY) { + return QDMI_ERROR_NOTSUPPORTED; + } + if (value == nullptr || size != sizeof(size_t)) { + return QDMI_ERROR_INVALIDARGUMENT; + } + job->shots = *static_cast(value); + return QDMI_SUCCESS; + } + return QDMI_ERROR_NOTSUPPORTED; } extern "C" int TEST_SESSION_QDMI_device_job_query_property( QDMI_Device_Job job, const QDMI_Device_Job_Property prop, const size_t size, void* value, size_t* sizeRet) { - if (job == nullptr || job->session == nullptr || - (prop != QDMI_DEVICE_JOB_PROPERTY_ID && - prop != QDMI_DEVICE_JOB_PROPERTY_QUEUEPOSITION)) { + if (job == nullptr || job->session == nullptr) { return QDMI_ERROR_INVALIDARGUMENT; } if (prop == QDMI_DEVICE_JOB_PROPERTY_QUEUEPOSITION) { return QDMI_ERROR_NOTSUPPORTED; } - return queryString("session-job", size, value, sizeRet); + if (prop == QDMI_DEVICE_JOB_PROPERTY_ID) { + return queryString(job->id, size, value, sizeRet); + } + if (prop == QDMI_DEVICE_JOB_PROPERTY_SHOTSNUM && !job->retrieved) { + return queryValue(job->shots, size, value, sizeRet); + } + if (prop == QDMI_DEVICE_JOB_PROPERTY_PROGRAMFORMAT) { + return queryValue(job->format, size, value, sizeRet); + } + if (prop == QDMI_DEVICE_JOB_PROPERTY_PROGRAM) { + return queryBytes(job->program, size, value, sizeRet); + } + return QDMI_ERROR_NOTSUPPORTED; } extern "C" int TEST_SESSION_QDMI_device_job_submit(QDMI_Device_Job job) { if (job == nullptr || job->session == nullptr) { return QDMI_ERROR_INVALIDARGUMENT; } - return job->retrieved ? QDMI_ERROR_BADSTATE : QDMI_SUCCESS; + if (job->retrieved || job->submitted) { + return QDMI_ERROR_BADSTATE; + } + if (job->program.empty()) { + return QDMI_ERROR_BADSTATE; + } + job->submitted = true; + return QDMI_SUCCESS; } extern "C" int TEST_SESSION_QDMI_device_job_cancel(QDMI_Device_Job /*job*/) { return QDMI_ERROR_NOTSUPPORTED; } -extern "C" int TEST_SESSION_QDMI_device_job_check(QDMI_Device_Job /*job*/, - QDMI_Job_Status* /*status*/) { - return QDMI_ERROR_NOTSUPPORTED; +extern "C" int TEST_SESSION_QDMI_device_job_check(QDMI_Device_Job job, + QDMI_Job_Status* status) { + if (job == nullptr || status == nullptr) { + return QDMI_ERROR_INVALIDARGUMENT; + } + *status = job->submitted || job->retrieved ? QDMI_JOB_STATUS_DONE + : QDMI_JOB_STATUS_CREATED; + return QDMI_SUCCESS; } -extern "C" int TEST_SESSION_QDMI_device_job_wait(QDMI_Device_Job /*job*/, +extern "C" int TEST_SESSION_QDMI_device_job_wait(QDMI_Device_Job job, size_t /*timeout*/) { - return QDMI_ERROR_NOTSUPPORTED; + return job != nullptr && (job->submitted || job->retrieved) + ? QDMI_SUCCESS + : QDMI_ERROR_BADSTATE; } extern "C" int TEST_SESSION_QDMI_device_job_get_results( - QDMI_Device_Job /*job*/, QDMI_Job_Result /*result*/, size_t /*size*/, - void* /*value*/, size_t* /*sizeRet*/) { + QDMI_Device_Job job, const QDMI_Job_Result result, const size_t size, + void* value, size_t* sizeRet) { + if (job == nullptr) { + return QDMI_ERROR_INVALIDARGUMENT; + } + if (!job->retrieved && !job->submitted) { + return QDMI_ERROR_BADSTATE; + } + if (result == QDMI_JOB_RESULT_SHOTS) { + return queryString("10,01", size, value, sizeRet); + } + if (result == QDMI_JOB_RESULT_HIST_KEYS) { + constexpr std::array MALFORMED_KEYS{ + std::byte{'1'}, + std::byte{0}, + std::byte{'0'}, + std::byte{0}, + }; + return queryBytes(MALFORMED_KEYS, size, value, sizeRet); + } + if (result == QDMI_JOB_RESULT_HIST_VALUES) { + constexpr std::array VALUES{1U, 1U}; + return queryBytes(std::as_bytes(std::span{VALUES}), size, value, sizeRet); + } + if (result == QDMI_JOB_RESULT_PROGRAMOUTPUT) { + return queryBytes(job->program, size, value, sizeRet); + } return QDMI_ERROR_NOTSUPPORTED; } diff --git a/test/qdmi/driver/test_driver.cpp b/test/qdmi/driver/test_driver.cpp index cb14aecea3..4e8937705c 100644 --- a/test/qdmi/driver/test_driver.cpp +++ b/test/qdmi/driver/test_driver.cpp @@ -9,6 +9,7 @@ */ #include "qdmi/Client.hpp" +#include "qdmi/ProgramFormat.hpp" #include "qdmi/driver/Driver.hpp" #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -459,10 +461,7 @@ TEST_P(DriverTest, JobSetParameter) { } TEST_P(DriverJobTest, JobSetParameter) { - EXPECT_THAT(QDMI_job_set_parameter(job, QDMI_JOB_PARAMETER_PROGRAM, - sizeof(QDMI_Program_Format), nullptr), - testing::AnyOf(QDMI_SUCCESS, QDMI_ERROR_NOTSUPPORTED)); - const QDMI_Program_Format value = QDMI_PROGRAM_FORMAT_QASM2; + const QDMI_Program_Format value = qdmi::OPENQASM2; EXPECT_THAT(QDMI_job_set_parameter(job, QDMI_JOB_PARAMETER_PROGRAMFORMAT, sizeof(QDMI_Program_Format), &value), testing::AnyOf(QDMI_SUCCESS, QDMI_ERROR_NOTSUPPORTED)); @@ -479,6 +478,10 @@ TEST_P(DriverJobTest, JobSetParameter) { EXPECT_THAT(QDMI_job_set_parameter(job, param, 0, nullptr), testing::AnyOf(QDMI_SUCCESS, QDMI_ERROR_NOTSUPPORTED)); } + constexpr auto unnamedCustom = + std::bit_cast(QDMI_CUSTOM_ENUM_VALUE_MIN + 42); + EXPECT_EQ(QDMI_job_set_parameter(job, unnamedCustom, 0, nullptr), + QDMI_ERROR_NOTSUPPORTED); EXPECT_EQ(QDMI_job_set_parameter(job, QDMI_JOB_PARAMETER_MAX, 0, nullptr), QDMI_ERROR_INVALIDARGUMENT); } @@ -496,19 +499,19 @@ TEST_P(DriverJobTest, JobQueryProperty) { EXPECT_THAT(QDMI_job_query_property(job, QDMI_JOB_PROPERTY_PROGRAM, 0, nullptr, nullptr), - testing::AnyOf(QDMI_SUCCESS, QDMI_ERROR_NOTSUPPORTED)); + testing::AnyOf(QDMI_ERROR_BADSTATE, QDMI_ERROR_NOTSUPPORTED)); - QDMI_Program_Format value = QDMI_PROGRAM_FORMAT_QASM2; + QDMI_Program_Format value = qdmi::OPENQASM2; auto result = QDMI_job_set_parameter(job, QDMI_JOB_PARAMETER_PROGRAMFORMAT, sizeof(QDMI_Program_Format), &value); EXPECT_THAT(result, testing::AnyOf(QDMI_SUCCESS, QDMI_ERROR_NOTSUPPORTED)); if (result == QDMI_SUCCESS) { - value = QDMI_PROGRAM_FORMAT_MAX; + value = {}; EXPECT_EQ(QDMI_job_query_property(job, QDMI_JOB_PROPERTY_PROGRAMFORMAT, sizeof(QDMI_Program_Format), &value, nullptr), QDMI_SUCCESS); - EXPECT_EQ(value, QDMI_PROGRAM_FORMAT_QASM2); + EXPECT_TRUE(qdmi::equal(value, qdmi::OPENQASM2)); } size_t numShots = 1; result = QDMI_job_set_parameter(job, QDMI_JOB_PARAMETER_SHOTSNUM, @@ -535,6 +538,10 @@ TEST_P(DriverJobTest, JobQueryProperty) { EXPECT_EQ(QDMI_job_query_property(job, property, 0, nullptr, nullptr), QDMI_ERROR_NOTSUPPORTED); } + constexpr auto unnamedCustom = + std::bit_cast(QDMI_CUSTOM_ENUM_VALUE_MIN + 42); + EXPECT_EQ(QDMI_job_query_property(job, unnamedCustom, 0, nullptr, nullptr), + QDMI_ERROR_NOTSUPPORTED); } TEST_P(DriverTest, JobSubmit) { @@ -543,7 +550,7 @@ TEST_P(DriverTest, JobSubmit) { TEST_P(DriverJobTest, JobSubmit) { EXPECT_THAT(QDMI_job_submit(job), - testing::AnyOf(QDMI_SUCCESS, QDMI_ERROR_NOTSUPPORTED)); + testing::AnyOf(QDMI_ERROR_BADSTATE, QDMI_ERROR_NOTSUPPORTED)); } TEST_P(DriverTest, JobCancel) { @@ -1391,8 +1398,7 @@ TEST(DeviceRegistrationTest, FreshJobRetainsItsDeviceSession) { std::optional job; { auto const device = qdmi::Session::openDevice("test.session-overrides"); - job.emplace( - device.submitJob("OPENQASM 2.0;", QDMI_PROGRAM_FORMAT_QASM2, 1)); + job.emplace(device.submitJob("OPENQASM 2.0;", qdmi::OPENQASM2, 1)); } ASSERT_TRUE(job.has_value()); @@ -1409,9 +1415,9 @@ TEST(DeviceRegistrationTest, CustomBinaryJobDoesNotRequireShots) { constexpr std::array payload{std::byte{0}, std::byte{1}}; EXPECT_NO_THROW(std::ignore = - device.submitJob(payload, QDMI_PROGRAM_FORMAT_CUSTOM1)); - EXPECT_THROW(std::ignore = device.submitJob( - payload, QDMI_PROGRAM_FORMAT_CUSTOM1, size_t{1}), + device.submitJob(payload, qdmi::QIR21_BASE_BINARY)); + EXPECT_THROW(std::ignore = device.submitJob(payload, qdmi::QIR21_BASE_BINARY, + size_t{1}), std::runtime_error); } @@ -1419,10 +1425,10 @@ TEST(DeviceRegistrationTest, TextJobDoesNotRequireShots) { registerSessionTestDevice(); const auto device = qdmi::Session::openDevice("test.session-overrides"); - EXPECT_NO_THROW(std::ignore = device.submitJob("OPENQASM 2.0;", - QDMI_PROGRAM_FORMAT_QASM2)); + EXPECT_NO_THROW(std::ignore = + device.submitJob("OPENQASM 2.0;", qdmi::OPENQASM2)); EXPECT_THROW(std::ignore = device.submitJob(std::string{"binary"}, - QDMI_PROGRAM_FORMAT_QPY), + qdmi::QIR21_BASE_BINARY), std::invalid_argument); } @@ -1490,6 +1496,27 @@ TEST(DeviceRegistrationTest, RetrievesExistingJobs) { const auto retrievedJob = device.retrieveJobById("session-job"); EXPECT_EQ(retrievedJob.getId(), "session-job"); + EXPECT_THROW(std::ignore = retrievedJob.getNumShots(), std::runtime_error); + EXPECT_EQ(retrievedJob.getShots(), (std::vector{"10", "01"})); + EXPECT_EQ(retrievedJob.getProgramOutput(), + (std::vector{std::byte{'x'}, std::byte{0}, + std::byte{'y'}, std::byte{0}})); +} + +TEST(DeviceRegistrationTest, RejectsMalformedTextResultFraming) { + registerSessionTestDevice(); + const auto device = qdmi::Session::openDevice("test.session-overrides"); + const auto job = device.retrieveJobById("session-job"); + + EXPECT_THROW(std::ignore = job.getCounts(), std::invalid_argument); +} + +TEST(DeviceRegistrationTest, RejectsEmbeddedNullInTextProgram) { + registerSessionTestDevice(); + const auto device = qdmi::Session::openDevice("test.session-overrides"); + const auto job = device.retrieveJobById("malformed-text-job"); + + EXPECT_THROW(std::ignore = job.getProgram(), std::invalid_argument); } TEST(DeviceRegistrationTest, FreshChildDeviceRetainsItsRootSession) { diff --git a/test/qdmi/test_client.cpp b/test/qdmi/test_client.cpp index dc42231615..e0a7a8d4a7 100644 --- a/test/qdmi/test_client.cpp +++ b/test/qdmi/test_client.cpp @@ -9,6 +9,7 @@ */ #include "qdmi/Client.hpp" +#include "qdmi/ProgramFormat.hpp" #include "qdmi/common/Common.hpp" #include @@ -17,6 +18,7 @@ #include #include +#include #include #include #include @@ -111,7 +113,7 @@ bit[1] c; h q[0]; c[0] = measure q[0]; )"; - return device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10); + return device.submitJob(qasm3Program, OPENQASM3, 10); } }; @@ -128,7 +130,7 @@ qubit[2] q; h q[0]; cx q[0], q[1]; )"; - return device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 0); + return device.submitJob(qasm3Program, OPENQASM3, 0); } }; #endif @@ -396,6 +398,25 @@ TEST(QDMITest, DevicePropertyToString) { EXPECT_STREQ(qdmi::toString(QDMI_DEVICE_PROPERTY_CUSTOM5), "CUSTOM5"); } +TEST(QDMITest, DevicePropertyCustomRange) { + constexpr auto interiorCustom = + std::bit_cast(QDMI_CUSTOM_ENUM_VALUE_MIN + 42); + constexpr auto gap = std::bit_cast( + static_cast(QDMI_DEVICE_PROPERTY_MAX) + 1); + + EXPECT_STREQ(qdmi::toString(interiorCustom), "CUSTOM"); + EXPECT_STREQ(qdmi::toString(QDMI_DEVICE_PROPERTY_CUSTOM_MAX), "CUSTOM"); + EXPECT_FALSE(IS_INVALID_ARGUMENT(QDMI_DEVICE_PROPERTY_NEEDSCALIBRATION, + QDMI_DEVICE_PROPERTY)); + EXPECT_FALSE(IS_INVALID_ARGUMENT(interiorCustom, QDMI_DEVICE_PROPERTY)); + EXPECT_FALSE(IS_INVALID_ARGUMENT(QDMI_DEVICE_PROPERTY_CUSTOM_MAX, + QDMI_DEVICE_PROPERTY)); + EXPECT_TRUE( + IS_INVALID_ARGUMENT(QDMI_DEVICE_PROPERTY_MAX, QDMI_DEVICE_PROPERTY)); + EXPECT_TRUE(IS_INVALID_ARGUMENT(gap, QDMI_DEVICE_PROPERTY)); + EXPECT_TRUE(IS_INVALID_ARGUMENT(-1, QDMI_DEVICE_PROPERTY)); +} + TEST(QDMITest, SessionPropertyToString) { EXPECT_STREQ(qdmi::toString(QDMI_SESSION_PROPERTY_DEVICES), "DEVICES"); } @@ -432,56 +453,9 @@ TEST(QDMITest, ThrowIfError) { } TEST(QDMITest, BinaryProgramFormatClassification) { - // The switch below states the expected classification of every program - // format. It has no default case, so a format added to QDMI later produces - // an unhandled-enumerator warning instead of an unnoticed classification. - constexpr auto expected = [](const QDMI_Program_Format format) -> bool { - switch (format) { - case QDMI_PROGRAM_FORMAT_QIRBASEMODULE: - case QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE: - case QDMI_PROGRAM_FORMAT_QPY: - return true; - case QDMI_PROGRAM_FORMAT_QASM2: - case QDMI_PROGRAM_FORMAT_QASM3: - case QDMI_PROGRAM_FORMAT_QIRBASESTRING: - case QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING: - case QDMI_PROGRAM_FORMAT_CALIBRATION: - case QDMI_PROGRAM_FORMAT_IQMJSON: - case QDMI_PROGRAM_FORMAT_BATCHJOB: - case QDMI_PROGRAM_FORMAT_CUSTOM1: - case QDMI_PROGRAM_FORMAT_CUSTOM2: - case QDMI_PROGRAM_FORMAT_CUSTOM3: - case QDMI_PROGRAM_FORMAT_CUSTOM4: - case QDMI_PROGRAM_FORMAT_CUSTOM5: - return false; - } - return false; - }; - - // Every program format QDMI defines. A format added to QDMI must be added - // here as well so that the loop below covers it. - constexpr std::array formats{ - QDMI_PROGRAM_FORMAT_QASM2, - QDMI_PROGRAM_FORMAT_QASM3, - QDMI_PROGRAM_FORMAT_QIRBASESTRING, - QDMI_PROGRAM_FORMAT_QIRBASEMODULE, - QDMI_PROGRAM_FORMAT_QIRADAPTIVESTRING, - QDMI_PROGRAM_FORMAT_QIRADAPTIVEMODULE, - QDMI_PROGRAM_FORMAT_CALIBRATION, - QDMI_PROGRAM_FORMAT_QPY, - QDMI_PROGRAM_FORMAT_IQMJSON, - QDMI_PROGRAM_FORMAT_BATCHJOB, - QDMI_PROGRAM_FORMAT_CUSTOM1, - QDMI_PROGRAM_FORMAT_CUSTOM2, - QDMI_PROGRAM_FORMAT_CUSTOM3, - QDMI_PROGRAM_FORMAT_CUSTOM4, - QDMI_PROGRAM_FORMAT_CUSTOM5, - }; - - for (const auto format : formats) { - EXPECT_EQ(qdmi::isBinaryProgramFormat(format), expected(format)) - << "program format " << static_cast(format); - } + EXPECT_FALSE(qdmi::isBinaryProgramFormat(OPENQASM3)); + EXPECT_FALSE(qdmi::isBinaryProgramFormat(QIR21_ADAPTIVE_TEXT)); + EXPECT_TRUE(qdmi::isBinaryProgramFormat(QIR21_ADAPTIVE_BINARY)); } TEST_P(DeviceTest, Name) { @@ -544,6 +518,69 @@ TEST_P(DeviceTest, MinAtomDistance) { TEST_P(DeviceTest, SupportedProgramFormats) { EXPECT_NO_THROW(std::ignore = device.getSupportedProgramFormats()); + EXPECT_TRUE(device.tryGetSupportedProgramFormats().has_value()); +} + +TEST(DeviceProgramFormatsTest, DistinguishesUnsupportedFromKnownEmpty) { + constexpr std::string_view deviceId = "test.unsupported-program-formats"; + static_cast(Driver::get().registerDeviceIfAbsent({ + .id = std::string(deviceId), + .library = MQT_CORE_QDMI_SLURM_TEST_DEVICE, + .prefix = "TEST_SESSION", + })); + + const auto unsupported = Session::openDevice(deviceId); + EXPECT_EQ(unsupported.tryGetSupportedProgramFormats(), std::nullopt); + EXPECT_THROW(std::ignore = unsupported.getSupportedProgramFormats(), + std::runtime_error); + + const auto knownEmpty = Session::openDevice("mqt.sc.default"); + const auto formats = knownEmpty.tryGetSupportedProgramFormats(); + ASSERT_TRUE(formats.has_value()); + EXPECT_TRUE(formats->empty()); + EXPECT_TRUE(knownEmpty.getSupportedProgramFormats().empty()); +} + +TEST_F(DDSimulatorDeviceTest, SupportedProgramFormatsAreKnownAndNonempty) { + const auto formats = device.tryGetSupportedProgramFormats(); + ASSERT_TRUE(formats.has_value()); + EXPECT_FALSE(formats->empty()); + EXPECT_NE(std::ranges::find_if(*formats, + [](const auto& format) { + return equal(format, QIR21_ADAPTIVE_TEXT); + }), + formats->end()); +} + +TEST_F(DDSimulatorDeviceTest, ReportsProgramFeaturesForExactFormats) { + const auto qasm3Features = device.tryGetProgramFeatures(OPENQASM3); + ASSERT_TRUE(qasm3Features.has_value()); + std::vector ids; + ids.reserve(qasm3Features->size()); + for (const auto& feature : *qasm3Features) { + ids.emplace_back(feature.id); + EXPECT_EQ(feature.value, 0U); + EXPECT_EQ(feature.constraint_id[0], '\0'); + EXPECT_EQ(feature.constraint_value, 0U); + } + EXPECT_THAT(ids, testing::UnorderedElementsAre( + QDMI_PROGRAM_FEATURE_MID_CIRCUIT_MEASUREMENT, + QDMI_PROGRAM_FEATURE_MEASURED_QUBIT_REUSE, + QDMI_PROGRAM_FEATURE_MEASUREMENT_RESULT_USE, + QDMI_PROGRAM_FEATURE_BOOLEAN_COMPUTATION, + QDMI_PROGRAM_FEATURE_FORWARD_BRANCHING)); + + const auto qirFeatures = device.tryGetProgramFeatures(QIR21_BASE_BINARY); + ASSERT_TRUE(qirFeatures.has_value()); + EXPECT_TRUE(qirFeatures->empty()); + + constexpr QDMI_Program_Format unsupported{ + .version = QDMI_MAKE_VERSION(1, 0, 0), + .encoding = QDMI_PROGRAM_ENCODING_BINARY, + .id = "qiskit.qpy", + .profile = "", + }; + EXPECT_EQ(device.tryGetProgramFeatures(unsupported), std::nullopt); } TEST_P(DeviceTest, ChildDevices) { @@ -835,11 +872,10 @@ h q[0]; cx q[0], q[1]; c = measure q;)"; - const auto job = - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 100); + const auto job = device.submitJob(qasm3Program, OPENQASM3, 100); EXPECT_FALSE(job.getId().empty()); - EXPECT_EQ(job.getProgramFormat(), QDMI_PROGRAM_FORMAT_QASM3); + EXPECT_TRUE(equal(job.getProgramFormat(), OPENQASM3)); EXPECT_STREQ(job.getProgram().c_str(), qasm3Program.c_str()); EXPECT_EQ(job.getNumShots(), 100); EXPECT_TRUE(job.wait()); @@ -849,59 +885,9 @@ c = measure q;)"; TEST_F(DDSimulatorDeviceTest, SubmitJobRejectsIncompatiblePayloadKinds) { const std::string textProgram = "OPENQASM 3.0;"; - EXPECT_THROW(std::ignore = device.submitJob( - textProgram, QDMI_PROGRAM_FORMAT_QIRBASEMODULE, 0), - std::invalid_argument); -} - -TEST_F(DDSimulatorDeviceTest, SubmitJobRejectsBatchJobs) { - // A batch job's program is a list of job handles, which the byte-span API - // cannot express, so MQT Core states that it does not support them. - constexpr std::array bytes{std::byte{0}}; - EXPECT_THROW(std::ignore = - device.submitJob(bytes, QDMI_PROGRAM_FORMAT_BATCHJOB, 0), + device.submitJob(textProgram, QIR21_BASE_BINARY, 0), std::invalid_argument); - EXPECT_THROW(std::ignore = device.submitJob(std::string{}, - QDMI_PROGRAM_FORMAT_BATCHJOB, 0), - std::invalid_argument); -} - -TEST_F(DDSimulatorDeviceTest, SubmitJobSendsCalibrationRunsElsewhere) { - // A calibration run takes no shot count and an optional payload, so it has - // its own entry point rather than a special case in `submitJob`. - EXPECT_THROW(std::ignore = device.submitJob( - std::string{}, QDMI_PROGRAM_FORMAT_CALIBRATION, 0), - std::invalid_argument); -} - -TEST_F(DDSimulatorDeviceTest, CalibrationJobReachesTheDevice) { - // The DD simulator needs no calibration and rejects the format itself. What - // matters is that the client no longer refuses before asking: the failure - // comes from the device, as a runtime error rather than an argument error. - EXPECT_THROW(std::ignore = device.submitCalibrationJob(), std::runtime_error); - EXPECT_THROW(std::ignore = device.submitCalibrationJob("configuration"), - std::runtime_error); - - constexpr std::array payload{std::byte{1}, std::byte{2}}; - EXPECT_THROW(std::ignore = device.submitCalibrationJob(payload), - std::runtime_error); - - constexpr std::byte emptyPayloadStorage{}; - const std::span emptyPayload{&emptyPayloadStorage, size_t{0}}; - EXPECT_THROW(std::ignore = device.submitCalibrationJob(emptyPayload), - std::runtime_error); - - EXPECT_NO_THROW({ - try { - std::ignore = device.submitCalibrationJob(); - } catch (const std::invalid_argument&) { - FAIL() << "the client rejected the calibration run before the device saw " - "it"; - } catch (const std::runtime_error&) { // NOLINT(bugprone-empty-catch) - // The device declined, which is its decision to make. - } - }); } TEST_F(DDSimulatorDeviceTest, SubmitJobCustomSupportedTypes) { @@ -911,24 +897,22 @@ TEST_F(DDSimulatorDeviceTest, SubmitJobCustomSupportedTypes) { try { switch (which) { case 1: - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10, custom); + device.submitJob(qasm3Program, OPENQASM3, 10, custom); break; case 2: - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10, - std::nullopt, custom); + device.submitJob(qasm3Program, OPENQASM3, 10, std::nullopt, custom); break; case 3: - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10, - std::nullopt, std::nullopt, custom); + device.submitJob(qasm3Program, OPENQASM3, 10, std::nullopt, + std::nullopt, custom); break; case 4: - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10, - std::nullopt, std::nullopt, std::nullopt, custom); + device.submitJob(qasm3Program, OPENQASM3, 10, std::nullopt, + std::nullopt, std::nullopt, custom); break; case 5: - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10, - std::nullopt, std::nullopt, std::nullopt, std::nullopt, - custom); + device.submitJob(qasm3Program, OPENQASM3, 10, std::nullopt, + std::nullopt, std::nullopt, std::nullopt, custom); break; default: throw std::invalid_argument("Invalid 'which' value"); @@ -956,16 +940,13 @@ bit[1] c; c[0] = measure q[0]; )"; - const auto job1 = - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10); + const auto job1 = device.submitJob(qasm3Program, OPENQASM3, 10); EXPECT_EQ(job1.getNumShots(), 10); - const auto job2 = - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 100); + const auto job2 = device.submitJob(qasm3Program, OPENQASM3, 100); EXPECT_EQ(job2.getNumShots(), 100); - const auto job3 = - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 1000); + const auto job3 = device.submitJob(qasm3Program, OPENQASM3, 1000); EXPECT_EQ(job3.getNumShots(), 1000); } @@ -976,8 +957,7 @@ qubit[1] q; bit[1] c; c[0] = measure q[0]; )"; - const auto job2 = - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10); + const auto job2 = device.submitJob(qasm3Program, OPENQASM3, 10); EXPECT_NE(job.getId(), job2.getId()); } @@ -1064,8 +1044,7 @@ qubit[1] q; bit[1] c; c[0] = measure q[0]; )"; - const auto jobToCancel = - device.submitJob(qasm3Program, QDMI_PROGRAM_FORMAT_QASM3, 10); + const auto jobToCancel = device.submitJob(qasm3Program, OPENQASM3, 10); // Fast-executing jobs (like the DD simulator) may complete before // cancel is called, which should throw an exception. diff --git a/test/slurm/bell_job.py b/test/slurm/bell_job.py index e16cca6cff..5d841ea954 100644 --- a/test/slurm/bell_job.py +++ b/test/slurm/bell_job.py @@ -37,7 +37,7 @@ def main() -> None: job_id = os.environ["SLURM_JOB_ID"] device = slurm.open_device_from_license() - job = device.submit_job(BELL_PROGRAM, ProgramFormat.QASM2, SHOTS) + job = device.submit_job(BELL_PROGRAM, ProgramFormat.OPENQASM2, SHOTS) if not job.wait(60): msg = "DDSIM did not complete the Bell circuit within 60 seconds" raise RuntimeError(msg)