From 77dc9b4723635abc5b2bb84fae27ab5929c51f60 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Wed, 19 Aug 2026 16:02:10 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Support=20symbolic=20Qiskit=20p?= =?UTF-8?q?arameters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- .agent/plans/qiskit-circuit-translation.md | 67 ++- .agent/plans/qiskit-symbolic-parameters.md | 292 ++++++++++ CHANGELOG.md | 3 +- bindings/mlir/qiskit/Qiskit2_5.cpp | 595 ++++++++++++++++++-- bindings/mlir/qiskit/QiskitExport.cpp | 386 +++++++++++-- bindings/mlir/qiskit/QiskitImport.cpp | 389 ++++++++++--- bindings/mlir/qiskit/QiskitTranslation.h | 39 +- docs/mlir/python_compiler_collection.md | 21 +- mlir/include/mlir/Dialect/Utils/Utils.h | 3 + mlir/lib/Compiler/CMakeLists.txt | 1 + mlir/lib/Compiler/Programs.cpp | 5 +- test/python/test_mlir_qiskit_translation.py | 581 ++++++++++++++++++- 12 files changed, 2168 insertions(+), 214 deletions(-) create mode 100644 .agent/plans/qiskit-symbolic-parameters.md diff --git a/.agent/plans/qiskit-circuit-translation.md b/.agent/plans/qiskit-circuit-translation.md index fd1d94fdf8..9822824301 100644 --- a/.agent/plans/qiskit-circuit-translation.md +++ b/.agent/plans/qiskit-circuit-translation.md @@ -12,13 +12,13 @@ translation does not create a `QuantumComputation`. The existing `mqt.core.load`, `qiskit_to_mqt`, and `mqt_to_qiskit` APIs remain independent and retain their wider Qiskit compatibility. -The direct translation supports Qiskit `>=2.5.0,<2.6.0`. Import covers numeric -standard gates and modifiers, global phase, canonical registers, measurement, -reset, barrier, recursive custom definitions, and structured control flow with -classical-bit and register conditions and supported constant expressions. -Standalone classical variables are rejected. Export covers the flat -constructible subset. Validation completes before the destination program is -created. +The direct translation supports Qiskit `>=2.5.0,<2.6.0`. Import covers standard +gates and modifiers with supported numeric or symbolic parameters, global phase, +canonical registers, measurement, reset, barrier, recursive custom definitions, +and structured control flow with classical-bit and register conditions and +supported expressions. Standalone classical variables are rejected. Export +covers the flat constructible subset. Validation completes before the +destination program is created. ## Progress @@ -43,10 +43,10 @@ created. - Qiskit's header function tables and `qk_import()` state are local to one translation unit. All `Qk*` types, functions, and table access therefore stay in `Qiskit2_5.cpp`. -- A custom instruction's definition can still contain its original parameter - object after the call site receives a numeric value. The version-specific - reader binds call parameters to definition parameters before the generic - importer reads the definition. +- A custom instruction's definition exposes the symbols and expressions bound at + its call site. The symbolic-parameter translation validates those values + against the current global and lexical identities; it needs no separate + formal-parameter substitution scheme. - Qiskit 2.5 provides native inspection for structured control flow and classical expressions but does not provide the corresponding constructors. Import can represent these structures in SCF and Arith. Export must reject @@ -69,10 +69,12 @@ created. extension. Rationale: nanobind owns Python object lifetimes and stable-ABI configuration, while source properties keep Qiskit's private headers and extension macro local. Date/Author: 2026-08-12 / Codex. -- Decision: Reject free compile-time parameters and arbitrary unitaries. - Rationale: neither has a complete compiler representation and round-trip - contract in this change. Lexically bound loop parameters remain local values. - Date/Author: 2026-08-12 / Codex. +- Decision: Reject free compile-time parameters and arbitrary unitaries in the + original change. Rationale: neither had a complete compiler representation and + round-trip contract at that time. The free-parameter decision is superseded by + `.agent/plans/qiskit-symbolic-parameters.md`; the arbitrary-unitary decision + is unchanged here. Date/Author: 2026-08-12, partially superseded 2026-08-18 / + Codex. - Decision: Expand unknown instructions through their definitions. Rationale: recursive expansion supports composite gates without adding custom-operation semantics to QC. Expansion is bounded by a depth of 64 and 10 million @@ -160,11 +162,11 @@ the final diff must all pass. The importer opens the source circuit through the selected version reader. It then validates the full reachable instruction graph. Validation checks numeric -parameters, gate and modifier arity, canonical registers, classical expression -types, control-flow mappings, custom-definition cycles, definition arity, -definition depth, and the operation budget. Arbitrary unitaries and unsupported -operations fail during this pass. Only then does the importer allocate an MLIR -context and module. +and supported symbolic parameters, gate and modifier arity, canonical registers, +classical expression types, control-flow mappings, custom-definition cycles, +definition arity, definition depth, and the operation budget. Arbitrary +unitaries, unsupported expressions, and other unsupported operations fail during +this pass. Only then does the importer allocate an MLIR context and module. The importer creates leading anonymous allocations for loose resources and one named allocation for each canonical register. Classical bit references map each @@ -175,7 +177,8 @@ classical-expression trees contain constants but no variables. Loop induction parameters remain lexically bound values. The exporter borrows the program module, checks the single entry function, -rejects function inputs and structured or runtime classical execution, and +accepts named `f64` inputs and supported Arith and Math expression graphs, +rejects other input types and structured or runtime classical execution, and collects flat operations and allocation attributes. It validates the recovered register layout before it selects a Qiskit writer or allocates a circuit. @@ -226,19 +229,20 @@ Acceptance requires: - C++ tests for borrowed module access, checked ownership transfer, shared gate descriptors, and existing OpenQASM translation. -- Table-driven Qiskit gate import and export with constructible numeric - modifiers, global phase, canonical registers, measurement, reset, and barrier; - import also accepts other finite numeric modifiers. -- Recursive numeric custom definitions plus missing, cyclic, mismatched, and - overly deep definitions. +- Table-driven Qiskit gate import and export with finite numeric modifiers, + global phase, canonical registers, measurement, reset, and barrier. +- Recursive parameterized custom definitions plus missing, cyclic, mismatched, + and overly deep definitions. - Nested structured control flow, loop-bound parameters, and representative Boolean, unsigned integer, and floating-point expressions. -- Early rejection of free symbols, arbitrary unitaries, aliases, and interleaved - registers and standalone classical variables without source mutation. +- Supported free symbols and parameter expressions, plus early rejection of + unsupported expressions, arbitrary unitaries, aliases, interleaved registers, + and standalone classical variables without source mutation. - Successful import of a circuit with `circ.layout`, with layout metadata absent from the compiler program. -- Flat export rejection for structured programs and runtime inputs, unsupported - version dispatch, lazy Qiskit import, and unchanged existing converter tests. +- Flat export rejection for structured programs and unsupported runtime input + types or expression graphs, unsupported version dispatch, lazy Qiskit import, + and unchanged existing converter tests. - Python 3.10 regular-ABI and Python 3.12 or newer stable-ABI builds where those interpreters are available, generated stubs, documentation, repository lint, and `git diff --check`. @@ -265,7 +269,8 @@ The vendored Qiskit 2.5.0 snapshot includes its Apache-2.0 license, `PROVENANCE.json`, `API_SURFACE.json`, and per-header SHA-256 hashes. It is a private build input and is not installed as an MQT C++ interface. -The focused validation produced these final summaries: +The original #2031 validation produced these final summaries. The symbolic +parameter ExecPlan records the later symbolic validation: [ PASSED ] 234 tests. # compiler program and pipeline tests [ PASSED ] 291 tests. # QC and OpenQASM translation tests diff --git a/.agent/plans/qiskit-symbolic-parameters.md b/.agent/plans/qiskit-symbolic-parameters.md new file mode 100644 index 0000000000..01a2cabd29 --- /dev/null +++ b/.agent/plans/qiskit-symbolic-parameters.md @@ -0,0 +1,292 @@ +# Support symbolic Qiskit parameter expressions + +This ExecPlan is a living document. The sections `Progress`, +`Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must +stay current as the implementation changes. + +This ExecPlan must be maintained in accordance with `.agent/PLANS.md` from the +repository root. + +## Purpose / Big Picture + +Users can import Qiskit circuits whose gates and global phase use free +parameters or real-valued parameter expressions. The compiler represents each +free parameter as a named `f64` function input and represents arithmetic with +frontend-neutral Arith and Math dialect operations. Users can export the +program, bind the reconstructed Qiskit parameters, and obtain the same numeric +circuit. Lexically bound `for`-loop values remain distinct from free parameters, +even when their displayed names match. + +This work completes issue #2067. It extends the Qiskit circuit translation +introduced by #2031 and builds on the CBit representation from #2158. It must +not weaken the existing preflight checks, mutate input circuits, or expose a +partially constructed output circuit after a failure. Exact +`ParameterVectorElement` provenance is a separate follow-up. + +## Progress + +- [x] (2026-08-18 11:26Z) Stack the direct-symbol implementation on the current + #2136 branch and preserve both changes through the overlapping Qiskit + files. +- [x] (2026-08-18 11:26Z) Confirm that Qiskit 2.5 records parameter expressions + as a postfix `_qpy_replay` sequence and provides C constructors for the + supported arithmetic operations. +- [x] (2026-08-18 14:45Z) Normalize Qiskit numbers, symbols, and supported + expressions into one bounded, frontend-neutral C++ tree in the + version-specific translation. +- [x] (2026-08-18 14:45Z) Materialize normalized expressions as `f64` Arith and + Math SSA values on import, with symbol lookup by identity rather than + name. +- [x] (2026-08-18 14:45Z) Reconstruct normalized expressions from supported + compiler SSA on export and materialize shared Qiskit parameter objects + through the Qiskit C API. +- [x] (2026-08-18 14:45Z) Permit parameterized custom definitions when all + symbols resolve, and preserve lexical identity through nested control + flow. +- [x] (2026-08-19 13:40Z) Split exact `ParameterVectorElement` provenance into a + follow-up and reject vector elements explicitly in this scalar-symbol + layer. +- [x] (2026-08-18 14:45Z) Add contract tests, update the support table, and + update this plan. +- [x] (2026-08-18 15:22Z) Fold pull request #2150 into the existing Qiskit + changelog entry without changing its wording. +- [x] (2026-08-18 13:51Z) Rebase on the current #2136 head and run the Release, + C++, Python 3.13, Qiskit 2.5.0 stable-ABI, documentation, stub, and lint + validation. +- [x] (2026-08-18 14:59Z) Rebase the symbolic commit onto the `main` merge of + #2136. Retain the merged controlled-unitary helper and the symbolic + parameter reconstruction path in the only conflicting file. +- [x] (2026-08-19 14:15Z) Port the scalar-symbol layer onto the exact current + #2158 head and rerun the focused Python and complete compiler suites. +- [x] (2026-08-19 19:50Z) Rebase the validated scalar-symbol commit onto `main` + after #2158 merged, rebuild the Python bindings, rerun all 157 Qiskit + translation tests, and pass the focused formatting and diff checks. +- [x] (2026-08-19 20:01Z) Reject named `f64` inputs that do not occur in any + exported parameter tree, add the source-unchanged regression, rebuild, and + pass all 158 Qiskit translation tests. + +## Surprises & Discoveries + +- Observation: Qiskit 2.5 has no public expression-tree reader that works + without an optional SymPy installation. Its own parameter-expression code + records a stable postfix replay sequence in `_qpy_replay`. Evidence: nested + expressions expose `OPReplay` records with `op`, `lhs`, and `rhs`; reverse + subtraction, division, and power use distinct opcodes. +- Observation: Qiskit rejects two free parameters with the same name in one + circuit, but it permits a lexically bound loop parameter and a distinct free + parameter to share a name. Evidence: the existing name-keyed local map + incorrectly captured the free parameter in such a loop body. +- Observation: Qiskit can construct parameter objects that share a UUID but + disagree on their name. The importer must compare canonical scalar symbol + metadata and reject such aliases before creating a module. +- Observation: a custom gate's definition already contains the actual symbols or + expressions supplied at its call site. The importer does not need a separate + formal-parameter substitution scheme. It must validate the definition against + the current global and lexical identities. +- Observation: an expression can convert to a number while still tracking free + parameters. The version-specific reader must inspect `parameters` before it + treats a value as a numeric constant. +- Observation: treating `ParameterVectorElement` as an ordinary standalone + symbol changes positional binding order. This layer therefore rejects vector + elements instead of inferring semantics from names such as `theta[10]`. +- Observation: Merely collecting a named function argument does not preserve it + in Qiskit. The writer only creates parameters reached from emitted gate or + global-phase expression trees, so an unused input would otherwise disappear. + +## Decision Log + +- Decision: Use one immutable, copyable scalar expression tree at the generic + reader/writer boundary. Rationale: Qiskit-specific replay objects remain in + `Qiskit2_5.cpp`, while import and export share one frontend-neutral contract. + Date/Author: 2026-08-18 / Codex. +- Decision: Support finite numbers, symbols, add, subtract, multiply, divide, + power, negate, sine, cosine, tangent, inverse sine, inverse cosine, inverse + tangent, exponential, logarithm, absolute value, and real conjugation. + Rationale: Arith, Math, and Qiskit's 2.5 C API represent this real-valued + subset directly. Operations without matching compiler semantics fail with a + precise diagnostic. Date/Author: 2026-08-18 / Codex. +- Decision: Key all parameters by their Qiskit identity during import and by + their compiler SSA value during export. Use `mqt.input_name` for the public + scalar name. Rationale: identity prevents lexical capture without storing a + frontend object in MLIR. Date/Author: 2026-08-18 / Codex. +- Decision: Preserve symbol sharing but do not preserve Qiskit's original UUID + across a round trip. Rationale: the compiler input is the frontend-neutral + identity. The writer creates exactly one Qiskit symbol for each input and + reuses it throughout gates and global phase. Date/Author: 2026-08-18 / Codex. +- Decision: Bound normalized expression depth and node count before compiler or + circuit construction. Rationale: the existing definition and control-flow + readers are bounded, and parameter replay must have the same fail-closed + behavior for adversarial input. Date/Author: 2026-08-18 / Codex. +- Decision: Reject `ParameterVectorElement` input in this PR and implement exact + vector provenance as a stacked follow-up. Rationale: scalar symbols complete + issue #2067, while vector identity, allocation bounds, sparse indices, and + vector-level binding form an independently reviewable contract. Date/Author: + 2026-08-19 / Codex. +- Decision: Require every named `f64` input identity to occur in the normalized + parameter trees that will be emitted. Rationale: Qiskit circuits cannot + declare an otherwise unused parameter, so failing before writer allocation + avoids silently changing the public parameter set. Date/Author: 2026-08-19 / + Codex. + +## Outcomes & Retrospective + +The scalar implementation is complete. Shared direct symbols, bounded real +expression trees, parameterized definitions, identity-safe loop bindings, and +global phase passed the original focused validation. Unused named inputs now +fail before writer allocation rather than disappearing. The split branch builds +and passes all 158 Qiskit translation tests after #2158 merged. + +## Context and Orientation + +`bindings/mlir/qiskit/QiskitTranslation.h` defines the normalized objects shared +by the generic translation and one Qiskit-version adapter. +`bindings/mlir/qiskit/Qiskit2_5.cpp` is the only file that reads Python +parameter objects, `_qpy_replay`, or calls Qiskit's `qk_param_*` C functions. + +`bindings/mlir/qiskit/QiskitImport.cpp` validates a complete source circuit, +creates a QC program, inserts one named `f64` entry argument per free symbol, +and lowers normalized expressions to SSA values. +`bindings/mlir/qiskit/QiskitExport.cpp` performs the reverse preflight: it +recognizes a supported `f64` SSA expression graph, builds normalized +expressions, and only then asks a version-specific writer to allocate a Qiskit +circuit. + +The importer uses `mqt.input_name`, declared in +`mlir/include/mlir/Dialect/Utils/Utils.h`, for the stable public name of each +compiler input. The compiler representation uses `arith.addf`, `arith.subf`, +`arith.mulf`, `arith.divf`, and `arith.negf`, plus matching real-valued Math +dialect operations. A local `for` induction parameter is a temporary SSA value +keyed by the loop parameter's Qiskit identity. It is not a function input. + +## Plan of Work + +First, replace the number-or-symbol `Parameter` value in `QiskitTranslation.h` +with an immutable expression node. Keep the node copyable because instructions, +modifiers, and global phase own values. In `Qiskit2_5.cpp`, normalize a number +or direct symbol immediately. For a parameter expression, replay `_qpy_replay` +into a bounded stack. Normalize reverse binary opcodes by swapping their +operands. Reject malformed stacks, non-finite constants, unsupported functions, +excessive depth, and excessive node count before returning to generic import. +Read a `for` parameter through the public control-flow operation so its UUID is +preserved. + +Next, change `QiskitImport.cpp` to validate every tree leaf by identity and to +emit each supported node as an `f64` Arith or Math value. Register the Math +dialect in the import context. Key both local and global parameter maps by +identity. Remove the numeric-only custom-definition check in the version +adapter; the existing recursive definition preflight then validates its actual +symbols and expressions against the same maps. + +Then change `QiskitExport.cpp` to recognize compiler inputs, finite constants, +and the supported Arith and Math operations recursively. Cache each SSA result +so a shared compiler subexpression remains shared in the normalized tree. +Represent inverse angles through expression negation and combine all global +phase contributions through expression addition. Complete this preflight before +the writer allocates a destination circuit. In `Qiskit2_5.cpp`, recursively +construct `QkParam` values and reuse one cached Qiskit symbol for each compiler +input identity. + +Finally, add focused Python regressions for direct and shared symbols, nested +binary and unary expressions, reverse operators, partial binding, global phase, +parameterized custom definitions, lexical name collisions, supported manual MLIR +expression export, explicit vector-element rejection, and fail-closed +unsupported input. Update only the support table and concise surrounding text. +Mark the prior numeric-only decision in +`.agent/plans/qiskit-circuit-translation.md` as superseded by this plan. Keep +changelog prose unchanged and add pull request #2150 to the existing Qiskit +translation entry. + +## Concrete Steps + +Run all commands from the repository root. Build the changed binding after each +production batch: + + cmake --build build/release --target mqt-core-mlir-bindings --parallel 2 + +Run the focused translation tests in a synchronized environment that builds and +installs the current worktree for parent and child processes: + + uvx nox -s tests-3.13 -- -q -o addopts= test/python/test_mlir_qiskit_translation.py + +Build the MLIR reference documentation and the complete Sphinx documentation: + + cmake --build --preset release --target mlir-doc + uvx nox --non-interactive -s docs + +Finish with generated-stub verification, repository lint, and whitespace +validation: + + uvx nox -s stubs + uvx nox -s lint + git diff --check + +## Validation and Acceptance + +Import a Qiskit circuit with two shared free symbols in nested arithmetic, gate +arguments, and global phase. The QC entry function must have one named `f64` +argument per symbol and must contain the matching Arith and Math operations. +Export it, bind the parameters, and compare its numeric operator and global +phase with the source circuit. + +Import partially bound expressions and a parameterized custom gate. Both must +resolve the remaining symbols without source mutation. Import a `for` loop whose +binder has the same displayed name as a distinct free symbol used in its body. +The gate must use the free function argument, not the loop induction value. + +Export hand-written QC with supported `f64` Arith and Math expressions. The +result must contain shared Qiskit parameters and bind to the same numeric +values. Duplicate or unused named inputs, unsupported SSA operations, +unsupported Qiskit functions, non-finite constants, malformed trees, and +excessive expressions must fail during preflight. + +Reject a `ParameterVectorElement` before module construction and leave the +source circuit unchanged. Continue to accept standalone scalar parameters whose +names contain brackets without inferring vector semantics. + +## Idempotence and Recovery + +All build and test commands are repeatable. Build artifacts remain under +`build/` and are not committed. + +If `main` advances before publication, rebase this scalar commit first. Preserve +the CBit resource model and the symbolic-expression fields and paths in +overlapping Qiskit translation files, then restack each dependent Qiskit commit. + +Do not push, open a pull request, edit issue text, or post review replies +without fresh human authorization. Preserve unrelated worktree changes. + +## Artifacts and Notes + +The Qiskit 2.5 replay opcodes required by this implementation are addition, +subtraction, multiplication, division, power, their reverse forms, sine, cosine, +tangent, inverse sine, inverse cosine, inverse tangent, exponential, logarithm, +absolute value, and conjugation. Reverse subtraction, division, and power swap +the replay operands before creating the generic tree. Real conjugation is an +identity operation. Other replay opcodes fail with their operation name in the +diagnostic. + +The Release compiler suite passed all 133 tests before the final rebase. A fresh +nanobind 2.15.0 and Qiskit 2.5.2 build passed all 158 focused scalar-symbol +Qiskit translation tests after #2158 merged. Rebasing onto `cb5cf0103` after +pull request 2173 only relocated the changelog entry. The production source tree +did not change. The focused Clang format, Ruff, Rumdl, and committed-diff checks +also pass. Stub generation, the warnings-as-errors documentation build, +repository lint, and focused clang-tidy checks remain part of publication +validation. + +## Interfaces and Dependencies + +`Parameter` in `QiskitTranslation.h` is a copyable immutable tree with a kind, +finite numeric value or symbol name and identity, and zero, one, or two child +pointers. `Loop::parameter` is `std::optional` and must contain a +symbol when present. `CircuitReader` returns normalized trees for instruction +parameters and global phase. `CircuitWriter` accepts the same tree and +reconstructs Qiskit parameters with the version-specific C and public Python +APIs. + +No SymPy dependency is added. No Qiskit object or expression string is stored in +MLIR. The supported compiler operations remain frontend-neutral Arith and Math +dialect operations on `f64` values. + +Revision note (2026-08-19): Split exact vector provenance into a separate +follow-up and aligned this plan with the scalar-symbol contract on #2158. diff --git a/CHANGELOG.md b/CHANGELOG.md index 765aed4872..28f7e68b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ releases may include breaking changes. #### Import and export - ✨ Add Qiskit circuit import and target-aware export to the compiler - collection ([#2031], [#2133], [#2140]) ([**@burgholzer**], + collection ([#2031], [#2133], [#2140], [#2150]) ([**@burgholzer**], [**@simon1hofmann**]) - ✨ Add conversions between `jeff` and QCO ([#1479], [#1548], [#1565], [#1637], [#1676], [#1706], [#1776], [#1836], [#1934], [#2000], [#2018], [#2105]) @@ -791,6 +791,7 @@ for previous changelogs._ [#2157]: https://github.com/munich-quantum-toolkit/core/pull/2157 [#2156]: https://github.com/munich-quantum-toolkit/core/pull/2156 [#2154]: https://github.com/munich-quantum-toolkit/core/pull/2154 +[#2150]: https://github.com/munich-quantum-toolkit/core/pull/2150 [#2148]: https://github.com/munich-quantum-toolkit/core/pull/2148 [#2147]: https://github.com/munich-quantum-toolkit/core/pull/2147 [#2141]: https://github.com/munich-quantum-toolkit/core/pull/2141 diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 314b301776..f92a74b5f0 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -14,6 +14,7 @@ // Qiskit requires its umbrella header before the extension function table. #include #include +#include // NOLINT(misc-include-cleaner): enables the std::complex caster. #include // NOLINT(misc-include-cleaner): enables the std::string caster. #include #include @@ -33,6 +34,7 @@ #include #include #include +#include #include #include @@ -96,6 +98,17 @@ constexpr size_t MAX_ANNOTATED_OPERATION_DEPTH = 64U; return pythonText(pythonAttribute(object, name, error), error); } +[[nodiscard]] uint64_t pythonUnsignedAttribute(const nb::handle object, + const char* name, + const std::string_view error) { + const auto attribute = pythonAttribute(object, name, error); + uint64_t result = 0; + if (!nb::try_cast(attribute, result)) { + throw std::runtime_error(std::string(error)); + } + return result; +} + [[noreturn]] void throwPythonError(const std::string_view message) { const nb::python_error error; throw std::runtime_error(std::string(message) + ": " + error.what()); @@ -163,47 +176,302 @@ QkExitCode addParameterizedGate(QkCircuit* circuit, const QkGate gate, const auto isNumber = qk_param_equal(parameter, numeric); qk_param_free(numeric); if (isNumber) { - return {.number = number}; + return {.kind = ParameterKind::Number, .number = number}; } } - // qk_str_free requires the mutable allocation returned by Qiskit. - // NOLINTNEXTLINE(misc-const-correctness) - char* const text = qk_param_str(parameter); - if (text == nullptr) { - throwPythonError("Qiskit failed to format an instruction parameter"); - } - Parameter result{.number = std::nullopt, .text = text}; - qk_str_free(text); - return result; + throw std::runtime_error( + "Qiskit's native API does not expose symbolic parameter-expression " + "structure"); } -[[nodiscard]] Parameter normalizePythonParameter(const nb::handle parameter) { +[[nodiscard]] Parameter +normalizePythonParameterLeaf(const nb::handle parameter) { double number = 0.0; if (nb::try_cast(parameter, number)) { - return {.number = number}; + if (!std::isfinite(number)) { + throw std::runtime_error("Qiskit returned a non-finite parameter"); + } + return {.kind = ParameterKind::Number, .number = number}; } - if (nb::hasattr(parameter, "name")) { - return {.number = std::nullopt, - .text = pythonStringAttribute( - parameter, "name", - "Qiskit modifier exponent has an invalid symbol name")}; + + std::complex complexNumber; + if (nb::try_cast(parameter, complexNumber)) { + if (!std::isfinite(complexNumber.real()) || + !std::isfinite(complexNumber.imag())) { + throw std::runtime_error("Qiskit returned a non-finite parameter"); + } + if (complexNumber.imag() != 0.0) { + throw std::runtime_error( + "Qiskit parameter expressions with complex values are not " + "supported"); + } + return {.kind = ParameterKind::Number, .number = complexNumber.real()}; } - auto text = - pythonText(parameter, "Qiskit modifier exponent has a non-text value"); - return {.number = std::nullopt, .text = std::move(text)}; -} -[[nodiscard]] uint64_t pythonUnsignedAttribute(const nb::handle object, - const char* name, - const std::string_view error) { - const auto attribute = pythonAttribute(object, name, error); - uint64_t result = 0; - if (!nb::try_cast(attribute, result)) { - throw std::runtime_error(std::string(error)); + if (!nb::hasattr(parameter, "name") || !nb::hasattr(parameter, "uuid")) { + throw std::runtime_error( + "Qiskit parameter expression contains an unsupported operand"); + } + auto name = pythonStringAttribute( + parameter, "name", "Qiskit parameter has an invalid symbol name"); + auto identity = + pythonText(pythonAttribute(parameter, "uuid", + "Qiskit parameter has no stable identity"), + "Qiskit parameter has an invalid stable identity"); + if (name.empty()) { + throw std::runtime_error("Qiskit parameter has an empty symbol name"); + } + if (name.find('\0') != std::string::npos) { + throw std::runtime_error( + "Qiskit parameter names cannot contain null characters"); + } + if (identity.empty()) { + throw std::runtime_error("Qiskit parameter has an empty stable identity"); + } + if (identity.find('\0') != std::string::npos) { + throw std::runtime_error( + "Qiskit parameter identities cannot contain null characters"); + } + Parameter result{.kind = ParameterKind::Symbol, + .text = std::move(name), + .identity = std::move(identity)}; + const auto vectorElement = + nb::module_::import_("qiskit.circuit").attr("ParameterVectorElement"); + if (nb::isinstance(parameter, vectorElement)) { + throw std::runtime_error( + "Qiskit parameter-vector elements are not supported"); } return result; } +struct ParsedParameter { + Parameter value; + size_t depth = 1U; +}; + +[[noreturn]] void throwParameterExpressionSizeError() { + throw std::runtime_error( + "Qiskit parameter expression exceeds the supported " + + std::to_string(MAX_PARAMETER_EXPRESSION_NODES) + "-node size"); +} + +[[noreturn]] void throwParameterExpressionDepthError() { + throw std::runtime_error( + "Qiskit parameter expression exceeds the supported " + + std::to_string(MAX_PARAMETER_EXPRESSION_DEPTH) + "-level nesting depth"); +} + +void countParameterExpressionNode(size_t& nodeCount) { + if (nodeCount >= MAX_PARAMETER_EXPRESSION_NODES) { + throwParameterExpressionSizeError(); + } + ++nodeCount; +} + +[[nodiscard]] ParsedParameter +takeParameterExpressionOperand(const nb::handle operand, + std::vector& stack, + size_t& nodeCount) { + if (operand.is_none()) { + if (stack.empty()) { + throw std::runtime_error( + "Qiskit parameter expression replay has too few operands"); + } + auto result = std::move(stack.back()); + stack.pop_back(); + return result; + } + countParameterExpressionNode(nodeCount); + return {.value = normalizePythonParameterLeaf(operand)}; +} + +[[nodiscard]] Parameter makeUnaryParameter(const ParameterKind kind, + Parameter operand) { + return {.kind = kind, + .left = std::make_shared(std::move(operand))}; +} + +[[nodiscard]] Parameter makeBinaryParameter(const ParameterKind kind, + Parameter lhs, Parameter rhs) { + return {.kind = kind, + .left = std::make_shared(std::move(lhs)), + .right = std::make_shared(std::move(rhs))}; +} + +[[nodiscard]] std::string parameterOpcode(const nb::handle replayEntry) { + auto opcode = pythonText( + pythonAttribute(replayEntry, "op", + "Qiskit parameter replay entry has no operation"), + "Qiskit parameter replay entry has an invalid operation"); + constexpr std::string_view prefix = "OpCode."; + if (opcode.starts_with(prefix)) { + opcode.erase(0U, prefix.size()); + } + return opcode; +} + +[[nodiscard]] bool isUnaryParameterOpcode(const std::string_view opcode) { + return opcode == "NEG" || opcode == "SIN" || opcode == "COS" || + opcode == "TAN" || opcode == "ASIN" || opcode == "ACOS" || + opcode == "ATAN" || opcode == "EXP" || opcode == "LOG" || + opcode == "ABS" || opcode == "CONJ" || opcode == "CONJUGATE"; +} + +[[nodiscard]] ParameterKind unaryParameterKind(const std::string_view opcode) { + if (opcode == "NEG") { + return ParameterKind::Negate; + } + if (opcode == "SIN") { + return ParameterKind::Sin; + } + if (opcode == "COS") { + return ParameterKind::Cos; + } + if (opcode == "TAN") { + return ParameterKind::Tan; + } + if (opcode == "ASIN") { + return ParameterKind::ArcSin; + } + if (opcode == "ACOS") { + return ParameterKind::ArcCos; + } + if (opcode == "ATAN") { + return ParameterKind::ArcTan; + } + if (opcode == "EXP") { + return ParameterKind::Exp; + } + if (opcode == "LOG") { + return ParameterKind::Log; + } + if (opcode == "ABS") { + return ParameterKind::Abs; + } + return ParameterKind::Conjugate; +} + +[[nodiscard]] bool isBinaryParameterOpcode(const std::string_view opcode) { + return opcode == "ADD" || opcode == "SUB" || opcode == "MUL" || + opcode == "DIV" || opcode == "POW" || opcode == "RSUB" || + opcode == "RDIV" || opcode == "RPOW"; +} + +[[nodiscard]] ParameterKind binaryParameterKind(const std::string_view opcode) { + if (opcode == "ADD") { + return ParameterKind::Add; + } + if (opcode == "SUB" || opcode == "RSUB") { + return ParameterKind::Subtract; + } + if (opcode == "MUL") { + return ParameterKind::Multiply; + } + if (opcode == "DIV" || opcode == "RDIV") { + return ParameterKind::Divide; + } + return ParameterKind::Power; +} + +[[nodiscard]] Parameter normalizePythonParameter(const nb::handle parameter) { + if (nb::hasattr(parameter, "name") && nb::hasattr(parameter, "uuid")) { + return normalizePythonParameterLeaf(parameter); + } + + bool hasTrackedSymbols = false; + if (nb::hasattr(parameter, "parameters")) { + const auto parameters = pythonAttribute( + parameter, "parameters", + "Qiskit parameter expression has no tracked-symbol set"); + try { + hasTrackedSymbols = nb::len(parameters) != 0U; + } catch (const nb::python_error& error) { + throwPythonError( + "Qiskit parameter expression tracked-symbol set is not sized", error); + } + } + if (!hasTrackedSymbols) { + return normalizePythonParameterLeaf(parameter); + } + + const auto replay = pythonAttribute( + parameter, "_qpy_replay", + "Qiskit parameter expression does not expose its operation replay"); + size_t replaySize = 0U; + try { + replaySize = nb::len(replay); + } catch (const nb::python_error& error) { + throwPythonError("Qiskit parameter expression replay is not sized", error); + } + if (replaySize == 0U) { + throw std::runtime_error("Qiskit parameter expression replay is empty"); + } + if (replaySize > MAX_PARAMETER_EXPRESSION_NODES) { + throwParameterExpressionSizeError(); + } + + size_t nodeCount = 0U; + std::vector stack; + stack.reserve(replaySize); + try { + for (const nb::handle replayEntry : nb::iter(replay)) { + const auto opcode = parameterOpcode(replayEntry); + if (opcode == "SIGN" || opcode == "GRAD" || opcode == "SUBSTITUTE") { + throw std::runtime_error("Qiskit parameter expression operation '" + + opcode + "' is not supported"); + } + const auto lhs = + pythonAttribute(replayEntry, "lhs", + "Qiskit parameter replay entry has no left operand"); + const auto rhs = + pythonAttribute(replayEntry, "rhs", + "Qiskit parameter replay entry has no right operand"); + if (isUnaryParameterOpcode(opcode)) { + if (!rhs.is_none()) { + throw std::runtime_error( + "Qiskit unary parameter replay entry has a right operand"); + } + auto operand = takeParameterExpressionOperand(lhs, stack, nodeCount); + countParameterExpressionNode(nodeCount); + ++operand.depth; + if (operand.depth > MAX_PARAMETER_EXPRESSION_DEPTH) { + throwParameterExpressionDepthError(); + } + operand.value = makeUnaryParameter(unaryParameterKind(opcode), + std::move(operand.value)); + stack.push_back(std::move(operand)); + continue; + } + if (!isBinaryParameterOpcode(opcode)) { + throw std::runtime_error("Qiskit parameter expression operation '" + + opcode + "' is not supported"); + } + auto right = takeParameterExpressionOperand(rhs, stack, nodeCount); + auto left = takeParameterExpressionOperand(lhs, stack, nodeCount); + if (opcode == "RSUB" || opcode == "RDIV" || opcode == "RPOW") { + std::swap(left, right); + } + countParameterExpressionNode(nodeCount); + const auto depth = std::max(left.depth, right.depth) + 1U; + if (depth > MAX_PARAMETER_EXPRESSION_DEPTH) { + throwParameterExpressionDepthError(); + } + stack.push_back({.value = makeBinaryParameter(binaryParameterKind(opcode), + std::move(left.value), + std::move(right.value)), + .depth = depth}); + } + } catch (const nb::python_error& error) { + throwPythonError("Qiskit parameter expression replay is not iterable", + error); + } + if (stack.size() != 1U) { + throw std::runtime_error( + "Qiskit parameter expression replay leaves multiple results"); + } + return std::move(stack.back().value); +} + void appendControlModifier(const nb::handle object, std::vector& modifiers) { const auto controls = pythonUnsignedAttribute( @@ -505,6 +773,12 @@ normalizeExpression(const QkExprNode* expression, const size_t depth = 0U) { class OwnedParameter final { public: + OwnedParameter() : value_(qk_param_zero()) { + if (value_ == nullptr) { + throwPythonError("Qiskit failed to allocate a parameter expression"); + } + } + explicit OwnedParameter(const double value) { if (!std::isfinite(value)) { throw std::runtime_error( @@ -516,6 +790,17 @@ class OwnedParameter final { } } + explicit OwnedParameter(const std::string_view name) { + if (name.empty()) { + throw std::runtime_error( + "cannot construct a Qiskit parameter with an empty name"); + } + value_ = qk_param_new_symbol(std::string(name).c_str()); + if (value_ == nullptr) { + throwPythonError("Qiskit failed to construct a symbolic parameter"); + } + } + OwnedParameter(const OwnedParameter&) = delete; OwnedParameter& operator=(const OwnedParameter&) = delete; OwnedParameter(OwnedParameter&&) = delete; @@ -523,6 +808,7 @@ class OwnedParameter final { ~OwnedParameter() { qk_param_free(value_); } [[nodiscard]] const QkParam* get() const { return value_; } + [[nodiscard]] QkParam* getMutable() { return value_; } private: QkParam* value_ = nullptr; @@ -700,18 +986,28 @@ class NativeCircuitReader final : public CircuitReader { return result; } - [[nodiscard]] Parameter globalPhase() const override { - // qk_param_free requires the mutable allocation returned by Qiskit. - // NOLINTNEXTLINE(misc-const-correctness) - QkParam* const phase = qk_circuit_global_phase(circuit_); - if (phase == nullptr) { - throwPythonError("Qiskit failed to read the circuit global phase"); + [[nodiscard]] std::vector parameters() const override { + std::vector result; + const auto parameters = + pythonAttribute(pythonCircuit_, "parameters", + "Qiskit circuit does not expose its free parameters"); + try { + result.reserve(nb::len(parameters)); + for (const nb::handle parameter : nb::iter(parameters)) { + result.push_back(normalizePythonParameter(parameter)); + } + } catch (const nb::python_error& error) { + throwPythonError("Qiskit circuit parameters are not iterable", error); } - const auto result = normalizeParameter(phase); - qk_param_free(phase); return result; } + [[nodiscard]] Parameter globalPhase() const override { + return normalizePythonParameter( + pythonAttribute(pythonCircuit_, "global_phase", + "Qiskit circuit does not expose its global phase")); + } + [[nodiscard]] Instruction instruction(const size_t index) const override { const auto kind = normalizeKind(qk_circuit_instruction_kind(circuit_, index)); @@ -753,9 +1049,27 @@ class NativeCircuitReader final : public CircuitReader { std::copy_n(native.clbits, native.num_clbits, result.clbits.begin()); } result.parameters.reserve(native.num_params); - for (const auto* parameter : - std::span(native.params, static_cast(native.num_params))) { - result.parameters.emplace_back(normalizeParameter(parameter)); + if (result.kind == OperationKind::Gate || + result.kind == OperationKind::Unknown) { + const auto parameters = + pythonAttribute(pythonOperation(index), "params", + "Qiskit operation does not expose its parameters"); + try { + for (const nb::handle parameter : nb::iter(parameters)) { + result.parameters.push_back(normalizePythonParameter(parameter)); + } + } catch (const nb::python_error& error) { + throwPythonError("Qiskit operation parameters are not iterable", error); + } + if (result.parameters.size() != native.num_params) { + throw std::runtime_error( + "Qiskit Python and native parameter counts do not match"); + } + } else { + for (const auto* parameter : + std::span(native.params, static_cast(native.num_params))) { + result.parameters.emplace_back(normalizeParameter(parameter)); + } } if (result.kind == OperationKind::Unknown) { result.name = std::move(normalizedUnknown->name); @@ -857,18 +1171,7 @@ class NativeCircuitReader final : public CircuitReader { instruction(index).name + "' has no circuit definition"); } - - const auto definitionParameters = - nb::cast(nb::module_::import_("builtins") - .attr("list")(pythonAttribute( - definition, "parameters", - "Qiskit definition has no parameter list"))); - if (definitionParameters.empty()) { - return std::make_unique(definition); - } - throw std::runtime_error( - "Qiskit custom instruction definitions must be numerically bound " - "before import"); + return std::make_unique(definition); } [[nodiscard]] uintptr_t @@ -1072,12 +1375,38 @@ class NativeControlFlowReader final : public ControlFlowReader { case QkLoopParamKind_Parameter: { auto symbol = qk_control_flow_loop_symbol_info(controlFlow_); if (symbol.ty != QkSymbolType_Standalone) { - qk_str_free(symbol.name); + if (symbol.name != nullptr) { + qk_str_free(symbol.name); + } throw std::runtime_error( - "Qiskit indexed parameter-vector loop variables are not supported"); + "Qiskit indexed parameter-vector loop variables are not " + "supported"); } - result.parameter = symbol.name; + if (symbol.name == nullptr) { + throwPythonError("Qiskit failed to read a loop-parameter name"); + } + const std::string nativeName = symbol.name; qk_str_free(symbol.name); + const auto parameters = pythonAttribute( + operation_, "params", + "Qiskit for-loop operation does not expose its parameters"); + try { + if (nb::len(parameters) < 2U) { + throw std::runtime_error( + "Qiskit for-loop operation has no loop parameter"); + } + auto parameter = normalizePythonParameter(parameters[1]); + if (parameter.kind != ParameterKind::Symbol) { + throw std::runtime_error("Qiskit for-loop parameter is not a symbol"); + } + if (parameter.text != nativeName) { + throw std::runtime_error( + "Qiskit Python and native loop-parameter names do not match"); + } + result.parameter = std::move(parameter); + } catch (const nb::python_error& error) { + throwPythonError("Qiskit failed to inspect a loop parameter", error); + } break; } case QkLoopParamKind_Variable: @@ -1184,15 +1513,16 @@ class NativeCircuitWriter final : public CircuitWriter { qk_classical_register_free(reg); } - void setGlobalPhase(const double phase) override { - const OwnedParameter parameter(phase); - checkExitCode(qk_circuit_set_global_phase(circuit_, parameter.get()), + void setGlobalPhase(const Parameter& phase) override { + std::vector> ownedParameters; + const auto* parameter = nativeParameter(phase, ownedParameters); + checkExitCode(qk_circuit_set_global_phase(circuit_, parameter), "setting global phase"); } void addGate(const StandardGateMapping mapping, const std::vector& qubits, - const std::vector& parameters) override { + const std::vector& parameters) override { const auto* gate = versionGate(mapping); if (gate == nullptr) { const auto& descriptor = @@ -1217,9 +1547,9 @@ class NativeCircuitWriter final : public CircuitWriter { std::vector nativeParameters; ownedParameters.reserve(parameters.size()); nativeParameters.reserve(parameters.size()); - for (const auto parameter : parameters) { - ownedParameters.emplace_back(std::make_unique(parameter)); - nativeParameters.emplace_back(ownedParameters.back()->get()); + for (const auto& parameter : parameters) { + nativeParameters.emplace_back( + nativeParameter(parameter, ownedParameters)); } checkExitCode(addParameterizedGate(circuit_, gate->native, qubits.data(), nativeParameters.data()), @@ -1330,8 +1660,151 @@ class NativeCircuitWriter final : public CircuitWriter { } } + [[nodiscard]] const QkParam* nativeParameter( + const Parameter& parameter, + std::vector>& ownedParameters) { + size_t nodeCount = 0U; + return nativeParameter(parameter, ownedParameters, nodeCount, 1U); + } + + [[nodiscard]] const QkParam* + nativeParameter(const Parameter& parameter, + std::vector>& ownedParameters, + size_t& nodeCount, const size_t depth) { + countParameterExpressionNode(nodeCount); + if (depth > MAX_PARAMETER_EXPRESSION_DEPTH) { + throwParameterExpressionDepthError(); + } + if (parameter.kind == ParameterKind::Number) { + if (parameter.left != nullptr || parameter.right != nullptr) { + throw std::runtime_error( + "numeric parameter expression node has operands"); + } + ownedParameters.emplace_back( + std::make_unique(parameter.number)); + return ownedParameters.back()->get(); + } + if (parameter.kind == ParameterKind::Symbol) { + if (parameter.left != nullptr || parameter.right != nullptr) { + throw std::runtime_error( + "symbolic parameter expression node has operands"); + } + if (parameter.identity.empty()) { + throw std::runtime_error( + "cannot export a symbolic parameter without a stable identity"); + } + if (parameter.text.empty()) { + throw std::runtime_error( + "cannot export a symbolic parameter without a name"); + } + const auto found = symbols_.find(parameter.identity); + if (found != symbols_.end()) { + if (found->second.name != parameter.text) { + throw std::runtime_error( + "one symbolic parameter identity has conflicting metadata"); + } + return found->second.parameter->get(); + } + auto [inserted, success] = + symbols_.emplace(parameter.identity, + Symbol{.name = parameter.text, + .parameter = std::make_unique( + parameter.text)}); + static_cast(success); + return inserted->second.parameter->get(); + } + + const auto unary = parameter.kind == ParameterKind::Negate || + parameter.kind == ParameterKind::Sin || + parameter.kind == ParameterKind::Cos || + parameter.kind == ParameterKind::Tan || + parameter.kind == ParameterKind::ArcSin || + parameter.kind == ParameterKind::ArcCos || + parameter.kind == ParameterKind::ArcTan || + parameter.kind == ParameterKind::Exp || + parameter.kind == ParameterKind::Log || + parameter.kind == ParameterKind::Abs || + parameter.kind == ParameterKind::Conjugate; + if (parameter.left == nullptr || (unary && parameter.right != nullptr) || + (!unary && parameter.right == nullptr)) { + throw std::runtime_error("parameter expression has invalid operands"); + } + const auto* left = nativeParameter(*parameter.left, ownedParameters, + nodeCount, depth + 1U); + const QkParam* right = nullptr; + if (!unary) { + right = nativeParameter(*parameter.right, ownedParameters, nodeCount, + depth + 1U); + } + auto output = std::make_unique(); + QkExitCode result = QkExitCode_Success; + switch (parameter.kind) { + case ParameterKind::Number: + case ParameterKind::Symbol: + throw std::runtime_error("invalid parameter expression node"); + case ParameterKind::Add: + result = qk_param_add(output->getMutable(), left, right); + break; + case ParameterKind::Subtract: + result = qk_param_sub(output->getMutable(), left, right); + break; + case ParameterKind::Multiply: + result = qk_param_mul(output->getMutable(), left, right); + break; + case ParameterKind::Divide: + result = qk_param_div(output->getMutable(), left, right); + break; + case ParameterKind::Power: + result = qk_param_pow(output->getMutable(), left, right); + break; + case ParameterKind::Negate: + result = qk_param_neg(output->getMutable(), left); + break; + case ParameterKind::Sin: + result = qk_param_sin(output->getMutable(), left); + break; + case ParameterKind::Cos: + result = qk_param_cos(output->getMutable(), left); + break; + case ParameterKind::Tan: + result = qk_param_tan(output->getMutable(), left); + break; + case ParameterKind::ArcSin: + result = qk_param_asin(output->getMutable(), left); + break; + case ParameterKind::ArcCos: + result = qk_param_acos(output->getMutable(), left); + break; + case ParameterKind::ArcTan: + result = qk_param_atan(output->getMutable(), left); + break; + case ParameterKind::Exp: + result = qk_param_exp(output->getMutable(), left); + break; + case ParameterKind::Log: + result = qk_param_log(output->getMutable(), left); + break; + case ParameterKind::Abs: + result = qk_param_abs(output->getMutable(), left); + break; + case ParameterKind::Conjugate: + result = qk_param_conjugate(output->getMutable(), left); + break; + } + checkExitCode(result, "constructing a parameter expression"); + const auto* value = output->get(); + ownedParameters.push_back(std::move(output)); + return value; + } + + struct Symbol { + std::string name; + std::unique_ptr parameter; + }; + QkCircuit* circuit_ = nullptr; std::vector pendingControlledUnitaries_; + std::unordered_map symbols_; }; class NativeTranslation final : public VersionedTranslation { diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 35a22781f6..a96c35e725 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -26,9 +26,12 @@ #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -40,12 +43,12 @@ #include #include -#include #include #include #include #include #include +#include #include #include #include @@ -69,20 +72,225 @@ struct ExportedInstruction { StandardGateMapping gate; std::vector qubits; std::vector clbits; - std::vector parameters; + std::vector parameters; std::vector> matrix; uint32_t unitaryControls = 0; }; -[[nodiscard]] double exportParameter(const mlir::Value value) { +using ExportedParameters = llvm::DenseMap; + +[[noreturn]] void throwExportedParameterExpressionSizeError() { + throw std::runtime_error("QC parameter expression exceeds the supported " + + std::to_string(MAX_PARAMETER_EXPRESSION_NODES) + + "-node size"); +} + +[[noreturn]] void throwExportedParameterExpressionDepthError() { + throw std::runtime_error("QC parameter expression exceeds the supported " + + std::to_string(MAX_PARAMETER_EXPRESSION_DEPTH) + + "-level nesting depth"); +} + +[[nodiscard]] Parameter numberParameter(const double value) { + return {.kind = ParameterKind::Number, .number = value}; +} + +[[nodiscard]] Parameter unaryParameter(const ParameterKind kind, + Parameter operand) { + return {.kind = kind, + .left = std::make_shared(std::move(operand))}; +} + +[[nodiscard]] Parameter binaryParameter(const ParameterKind kind, + Parameter left, Parameter right) { + return {.kind = kind, + .left = std::make_shared(std::move(left)), + .right = std::make_shared(std::move(right))}; +} + +[[nodiscard]] Parameter exportParameterImpl(mlir::Value value, + ExportedParameters& parameters, + const size_t depth, size_t& nodes) { + if (depth > MAX_PARAMETER_EXPRESSION_DEPTH) { + throwExportedParameterExpressionDepthError(); + } + if (const auto cached = parameters.find(value); cached != parameters.end()) { + return cached->second; + } + if (++nodes > MAX_PARAMETER_EXPRESSION_NODES) { + throwExportedParameterExpressionSizeError(); + } if (const auto number = mlir::utils::valueToDouble(value)) { if (!std::isfinite(*number)) { throw std::runtime_error("cannot export a non-finite QC parameter"); } - return *number; + auto result = numberParameter(*number); + parameters.try_emplace(value, result); + return result; } - throw std::runtime_error( - "Qiskit circuit export supports only numeric parameters"); + if (!value.getType().isF64()) { + throw std::runtime_error( + "Qiskit circuit export requires f64 scalar parameters"); + } + auto* const operation = value.getDefiningOp(); + if (operation == nullptr || operation->getNumResults() != 1U || + operation->getResult(0) != value) { + throw std::runtime_error( + "Qiskit circuit export cannot resolve an unnamed scalar parameter"); + } + const auto unary = [&](const ParameterKind kind) { + if (operation->getNumOperands() != 1U) { + throw std::runtime_error("QC parameter operation '" + + operation->getName().getStringRef().str() + + "' has invalid arity"); + } + return unaryParameter(kind, + exportParameterImpl(operation->getOperand(0), + parameters, depth + 1U, nodes)); + }; + const auto binary = [&](const ParameterKind kind) { + if (operation->getNumOperands() != 2U) { + throw std::runtime_error("QC parameter operation '" + + operation->getName().getStringRef().str() + + "' has invalid arity"); + } + auto left = exportParameterImpl(operation->getOperand(0), parameters, + depth + 1U, nodes); + auto right = exportParameterImpl(operation->getOperand(1), parameters, + depth + 1U, nodes); + return binaryParameter(kind, std::move(left), std::move(right)); + }; + + Parameter result; + if (llvm::isa(*operation)) { + result = binary(ParameterKind::Add); + } else if (llvm::isa(*operation)) { + result = binary(ParameterKind::Subtract); + } else if (llvm::isa(*operation)) { + result = binary(ParameterKind::Multiply); + } else if (llvm::isa(*operation)) { + result = binary(ParameterKind::Divide); + } else if (llvm::isa(*operation)) { + result = binary(ParameterKind::Power); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::Negate); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::Sin); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::Cos); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::Tan); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::ArcSin); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::ArcCos); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::ArcTan); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::Exp); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::Log); + } else if (llvm::isa(*operation)) { + result = unary(ParameterKind::Abs); + } else { + throw std::runtime_error( + "Qiskit circuit export does not support scalar parameter operation '" + + operation->getName().getStringRef().str() + "'"); + } + parameters.try_emplace(value, result); + return result; +} + +[[nodiscard]] Parameter exportParameter(const mlir::Value value, + ExportedParameters& parameters) { + size_t nodes = 0U; + return exportParameterImpl(value, parameters, 1U, nodes); +} + +void validateExportParameterImpl(const Parameter& parameter, const size_t depth, + size_t& nodes) { + if (depth > MAX_PARAMETER_EXPRESSION_DEPTH) { + throwExportedParameterExpressionDepthError(); + } + if (++nodes > MAX_PARAMETER_EXPRESSION_NODES) { + throwExportedParameterExpressionSizeError(); + } + const auto requireLeaf = [&] { + if (parameter.left || parameter.right) { + throw std::runtime_error( + "QC parameter-expression leaf has unexpected operands"); + } + }; + const auto requireUnary = [&] { + if (!parameter.left || parameter.right) { + throw std::runtime_error( + "QC unary parameter expression has invalid operands"); + } + validateExportParameterImpl(*parameter.left, depth + 1U, nodes); + }; + const auto requireBinary = [&] { + if (!parameter.left || !parameter.right) { + throw std::runtime_error( + "QC binary parameter expression has missing operands"); + } + validateExportParameterImpl(*parameter.left, depth + 1U, nodes); + validateExportParameterImpl(*parameter.right, depth + 1U, nodes); + }; + switch (parameter.kind) { + case ParameterKind::Number: + requireLeaf(); + if (!std::isfinite(parameter.number)) { + throw std::runtime_error("cannot export a non-finite QC parameter"); + } + return; + case ParameterKind::Symbol: + requireLeaf(); + if (parameter.text.empty() || parameter.identity.empty()) { + throw std::runtime_error( + "QC parameter symbol has invalid identity metadata"); + } + if (parameter.text.find('\0') != std::string::npos || + parameter.identity.find('\0') != std::string::npos) { + throw std::runtime_error( + "QC parameter symbol metadata contains a null character"); + } + return; + case ParameterKind::Add: + case ParameterKind::Subtract: + case ParameterKind::Multiply: + case ParameterKind::Divide: + case ParameterKind::Power: + requireBinary(); + return; + case ParameterKind::Negate: + case ParameterKind::Sin: + case ParameterKind::Cos: + case ParameterKind::Tan: + case ParameterKind::ArcSin: + case ParameterKind::ArcCos: + case ParameterKind::ArcTan: + case ParameterKind::Exp: + case ParameterKind::Log: + case ParameterKind::Abs: + case ParameterKind::Conjugate: + requireUnary(); + return; + } + throw std::runtime_error("unknown QC parameter expression kind"); +} + +void validateExportParameter(const Parameter& parameter) { + size_t nodes = 0U; + validateExportParameterImpl(parameter, 1U, nodes); +} + +[[nodiscard]] bool isParameterExpressionOperation(mlir::Operation& operation) { + return llvm::isa(operation); } [[nodiscard]] uint32_t checkedIndex(const int64_t index, @@ -138,11 +346,104 @@ struct ExportState { std::vector instructions; std::vector quantumRegisters; std::vector classicalRegisters; - double globalPhase = 0.0; + ExportedParameters parameters; + std::vector inputParameters; + Parameter globalPhase{.kind = ParameterKind::Number, .number = 0.0}; uint32_t numQubits = 0; uint32_t numClbits = 0; }; +void collectParameterIdentities(const Parameter& parameter, + llvm::StringSet<>& identities) { + if (parameter.kind == ParameterKind::Symbol) { + identities.insert(parameter.identity); + return; + } + if (parameter.left) { + collectParameterIdentities(*parameter.left, identities); + } + if (parameter.right) { + collectParameterIdentities(*parameter.right, identities); + } +} + +void validateExportParameters(const ExportState& state) { + llvm::StringSet<> usedIdentities; + const auto validate = [&](const Parameter& parameter) { + validateExportParameter(parameter); + collectParameterIdentities(parameter, usedIdentities); + }; + validate(state.globalPhase); + for (const auto& instruction : state.instructions) { + for (const auto& parameter : instruction.parameters) { + validate(parameter); + } + } + for (const auto& input : state.inputParameters) { + if (!usedIdentities.contains(input.identity)) { + throw std::runtime_error( + "Qiskit circuit export cannot preserve unused named f64 program " + "input '" + + input.text + "'"); + } + } +} + +void collectParameters(mlir::func::FuncOp function, ExportState& state) { + llvm::StringSet<> names; + for (const auto [index, argument] : + llvm::enumerate(function.getArguments())) { + const auto name = function.getArgAttrOfType( + index, mlir::utils::INPUT_NAME_ATTR); + if (!argument.getType().isF64() || !name || name.getValue().empty()) { + throw std::runtime_error( + "Qiskit circuit export requires named f64 program inputs"); + } + if (name.getValue().contains('\0')) { + throw std::runtime_error( + "Qiskit circuit export does not support parameter names with null " + "characters"); + } + if (!names.insert(name.getValue()).second) { + throw std::runtime_error( + "Qiskit circuit export requires unique parameter names"); + } + Parameter parameter{ + .kind = ParameterKind::Symbol, + .text = name.str(), + .identity = "input:" + std::to_string(index), + }; + state.parameters[argument] = parameter; + state.inputParameters.push_back(std::move(parameter)); + } +} + +void addGlobalPhase(ExportState& state, const Parameter& phase) { + if (phase.kind == ParameterKind::Number) { + if (!std::isfinite(phase.number)) { + throw std::runtime_error( + "QC global phase cannot be represented by Qiskit"); + } + if (state.globalPhase.kind == ParameterKind::Number) { + state.globalPhase.number += phase.number; + if (!std::isfinite(state.globalPhase.number)) { + throw std::runtime_error( + "QC global phase cannot be represented by Qiskit"); + } + return; + } + if (std::abs(phase.number) <= mlir::utils::TOLERANCE) { + return; + } + } else if (state.globalPhase.kind == ParameterKind::Number && + std::abs(state.globalPhase.number) <= mlir::utils::TOLERANCE) { + state.globalPhase = phase; + return; + } + state.globalPhase = + binaryParameter(ParameterKind::Add, std::move(state.globalPhase), phase); +} + [[nodiscard]] std::vector mapQubits(const mlir::ValueRange values, const llvm::DenseMap& qubits) { @@ -161,7 +462,8 @@ mapQubits(const mlir::ValueRange values, [[nodiscard]] ExportedInstruction collectUnitaryInstruction(mlir::Operation& operation, - const llvm::DenseMap& qubits); + const llvm::DenseMap& qubits, + ExportedParameters& parameters); [[nodiscard]] std::vector modifierBodyOperations(mlir::Region& region) { @@ -171,7 +473,8 @@ modifierBodyOperations(mlir::Region& region) { } std::vector operations; for (auto& operation : region.front()) { - if (!llvm::isa(operation)) { + if (!llvm::isa(operation) && + !isParameterExpressionOperation(operation)) { operations.push_back(&operation); } } @@ -262,15 +565,17 @@ void invertGate(ExportedInstruction& instruction) { if (instruction.parameters.empty()) { throw std::runtime_error("QC inverse modifier has invalid arity"); } - instruction.parameters.front() = -instruction.parameters.front(); + instruction.parameters.front() = unaryParameter( + ParameterKind::Negate, std::move(instruction.parameters.front())); return; } if (instruction.gate.gate == Gate::U3 && instruction.parameters.size() == 3U) { - const std::array values{instruction.parameters[0], - instruction.parameters[1], - instruction.parameters[2]}; - instruction.parameters = {-values[0], -values[2], -values[1]}; + auto parameters = std::move(instruction.parameters); + instruction.parameters = { + unaryParameter(ParameterKind::Negate, std::move(parameters[0])), + unaryParameter(ParameterKind::Negate, std::move(parameters[2])), + unaryParameter(ParameterKind::Negate, std::move(parameters[1]))}; return; } throw std::runtime_error( @@ -279,7 +584,8 @@ void invertGate(ExportedInstruction& instruction) { [[nodiscard]] ExportedInstruction collectUnitaryInstruction(mlir::Operation& operation, - const llvm::DenseMap& qubits) { + const llvm::DenseMap& qubits, + ExportedParameters& parameters) { if (auto control = llvm::dyn_cast(operation)) { auto bodyOperations = modifierBodyOperations(control.getRegion()); const auto controls = mapQubits(control.getControls(), qubits); @@ -298,16 +604,18 @@ collectUnitaryInstruction(mlir::Operation& operation, .gate = {mlir::qc::StandardGate::CU, 0}, .qubits = {controls.front(), targets.front()}}; for (const auto parameter : unitary.getParameters()) { - result.parameters.push_back(exportParameter(parameter)); + result.parameters.push_back(exportParameter(parameter, parameters)); } - result.parameters.push_back(exportParameter(phase.getTheta())); + result.parameters.push_back( + exportParameter(phase.getTheta(), parameters)); return result; } if (bodyOperations.size() != 1U) { throw std::runtime_error( "QC control export requires one standard gate in the modifier body"); } - auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap); + auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap, + parameters); auto& numControls = result.kind == ExportedInstruction::Kind::Unitary ? result.unitaryControls : result.gate.controls; @@ -328,7 +636,8 @@ collectUnitaryInstruction(mlir::Operation& operation, } auto nestedMap = modifierQubitMap(qubits, inverse.getRegion().front(), inverse.getQubits()); - auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap); + auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap, + parameters); invertGate(result); return result; } @@ -338,15 +647,17 @@ collectUnitaryInstruction(mlir::Operation& operation, throw std::runtime_error( "QC power export requires one standard gate in the modifier body"); } - const auto exponent = exportParameter(power.getExponent()); - if (exponent != 1.0 && exponent != -1.0) { + const auto exponent = exportParameter(power.getExponent(), parameters); + if (exponent.kind != ParameterKind::Number || + (exponent.number != 1.0 && exponent.number != -1.0)) { throw std::runtime_error( "QC power export supports only constant exponents 1 and -1"); } auto nestedMap = modifierQubitMap(qubits, power.getRegion().front(), power.getQubits()); - auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap); - if (exponent == -1.0) { + auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap, + parameters); + if (exponent.number == -1.0) { invertGate(result); } return result; @@ -381,7 +692,7 @@ collectUnitaryInstruction(mlir::Operation& operation, } result.gate.gate = descriptor->gate; for (const auto parameter : gate.getParameters()) { - result.parameters.push_back(exportParameter(parameter)); + result.parameters.push_back(exportParameter(parameter, parameters)); } return result; } @@ -567,7 +878,8 @@ void collectFlatInstructions(mlir::func::FuncOp function, ExportState& state) { throw std::runtime_error( "QC to Qiskit export encountered an unsupported memory allocation"); } - if (llvm::isa(operation)) { + if (llvm::isa(operation) || + isParameterExpressionOperation(operation)) { continue; } if (auto load = llvm::dyn_cast(operation)) { @@ -598,11 +910,8 @@ void collectFlatInstructions(mlir::func::FuncOp function, ExportState& state) { continue; } if (auto phase = llvm::dyn_cast(operation)) { - state.globalPhase += exportParameter(phase.getTheta()); - if (!std::isfinite(state.globalPhase)) { - throw std::runtime_error( - "QC global phase cannot be represented by Qiskit"); - } + addGlobalPhase(state, + exportParameter(phase.getTheta(), state.parameters)); continue; } if (auto measure = llvm::dyn_cast(operation)) { @@ -645,7 +954,7 @@ void collectFlatInstructions(mlir::func::FuncOp function, ExportState& state) { } if (llvm::isa(operation)) { state.instructions.push_back( - collectUnitaryInstruction(operation, state.qubits)); + collectUnitaryInstruction(operation, state.qubits, state.parameters)); continue; } if (llvm::isa(operation)) { state.instructions.push_back( - collectUnitaryInstruction(operation, state.qubits)); + collectUnitaryInstruction(operation, state.qubits, state.parameters)); continue; } + if (operation.getNumResults() == 1U && + operation.getResult(0).getType().isF64()) { + throw std::runtime_error("Qiskit circuit export does not support scalar " + "parameter operation '" + + operation.getName().getStringRef().str() + "'"); + } throw std::runtime_error("unsupported QC operation in Qiskit export: " + operation.getName().getStringRef().str()); } @@ -690,18 +1005,15 @@ nb::object exportCircuit(const mlir::QCProgram& program, throw std::runtime_error( "QC to Qiskit export requires a single-block entry function"); } - if (!function.getArguments().empty()) { - throw std::runtime_error( - "Qiskit circuit export does not support symbolic or runtime inputs"); - } - ExportState state; + collectParameters(function, state); if (target != nullptr) { state.numQubits = checkedIndex(static_cast(target->numQubits()), "target qubit count"); } collectResources(function, state, target); collectFlatInstructions(function, state); + validateExportParameters(state); if (target != nullptr) { Register reg{.name = "q"}; reg.bits.resize(state.numQubits); diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index ac69754644..4934f48b5a 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -21,6 +21,7 @@ #include "mlir/Dialect/QCO/IR/QCODialect.h" #include "mlir/Dialect/QTensor/IR/QTensorDialect.h" #include "mlir/Dialect/Utils/DenseUnitary.h" +#include "mlir/Dialect/Utils/Utils.h" #include #include @@ -37,8 +38,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -75,19 +78,36 @@ namespace { using ParameterValue = std::variant; using LocalParameters = llvm::StringMap; +using GlobalParameters = llvm::StringMap; +using ValidationParameters = llvm::StringMap; constexpr size_t MAX_DEFINITION_DEPTH = 64U; constexpr size_t MAX_CONTROL_FLOW_DEPTH = 64U; constexpr size_t MAX_EXPANDED_OPERATIONS = 10'000'000U; +[[nodiscard]] mlir::Value floatConstant(mlir::ImplicitLocOpBuilder& builder, + double value); + +[[noreturn]] void throwImportedParameterExpressionSizeError() { + throw std::runtime_error( + "Qiskit parameter expression exceeds the supported " + + std::to_string(MAX_PARAMETER_EXPRESSION_NODES) + "-node size"); +} + +[[noreturn]] void throwImportedParameterExpressionDepthError() { + throw std::runtime_error( + "Qiskit parameter expression exceeds the supported " + + std::to_string(MAX_PARAMETER_EXPRESSION_DEPTH) + "-level nesting depth"); +} + [[nodiscard]] std::shared_ptr createContext() { mlir::DialectRegistry registry; registry.insert(); + mlir::func::FuncDialect, mlir::math::MathDialect, + mlir::scf::SCFDialect, mlir::LLVM::LLVMDialect, + mlir::memref::MemRefDialect, mlir::jeff::JeffDialect>(); mlir::registerBuiltinDialectTranslation(registry); mlir::registerLLVMDialectTranslation(registry); auto context = std::make_shared(registry); @@ -95,44 +115,225 @@ constexpr size_t MAX_EXPANDED_OPERATIONS = 10'000'000U; return context; } -void validateParameter(const Parameter& parameter, - const llvm::StringSet<>& localParameters) { - if (parameter.number) { - if (!std::isfinite(*parameter.number)) { +void validateParameterImpl(const Parameter& parameter, + const ValidationParameters& localParameters, + const ValidationParameters& freeParameters, + const size_t depth, size_t& nodes) { + if (depth > MAX_PARAMETER_EXPRESSION_DEPTH) { + throwImportedParameterExpressionDepthError(); + } + if (++nodes > MAX_PARAMETER_EXPRESSION_NODES) { + throwImportedParameterExpressionSizeError(); + } + const auto requireLeft = [&]() -> const Parameter& { + if (!parameter.left) { + throw std::runtime_error( + "Qiskit parameter expression has a missing operand"); + } + return *parameter.left; + }; + const auto requireRight = [&]() -> const Parameter& { + if (!parameter.right) { + throw std::runtime_error( + "Qiskit parameter expression has a missing operand"); + } + return *parameter.right; + }; + switch (parameter.kind) { + case ParameterKind::Number: + if (parameter.left || parameter.right) { + throw std::runtime_error( + "Qiskit parameter-expression leaf has unexpected operands"); + } + if (!std::isfinite(parameter.number)) { throw std::runtime_error("Qiskit returned a non-finite parameter"); } return; - } - if (localParameters.contains(parameter.text)) { + case ParameterKind::Symbol: + if (parameter.left || parameter.right) { + throw std::runtime_error( + "Qiskit parameter-expression leaf has unexpected operands"); + } + if (parameter.identity.empty() || parameter.text.empty()) { + throw std::runtime_error( + "Qiskit returned a parameter with invalid symbol metadata"); + } + if (const auto local = localParameters.find(parameter.identity); + local != localParameters.end()) { + if (parameter.text != local->second.text) { + throw std::runtime_error("Qiskit parameter symbol '" + parameter.text + + "' aliases local symbol '" + + local->second.text + + "' with inconsistent metadata"); + } + return; + } + if (const auto free = freeParameters.find(parameter.identity); + free != freeParameters.end()) { + if (parameter.text != free->second.text) { + throw std::runtime_error("Qiskit parameter symbol '" + parameter.text + + "' aliases free symbol '" + free->second.text + + "' with inconsistent metadata"); + } + return; + } + throw std::runtime_error("Qiskit parameter symbol '" + parameter.text + + "' is not defined in this circuit scope"); + case ParameterKind::Add: + case ParameterKind::Subtract: + case ParameterKind::Multiply: + case ParameterKind::Divide: + case ParameterKind::Power: + validateParameterImpl(requireLeft(), localParameters, freeParameters, + depth + 1U, nodes); + validateParameterImpl(requireRight(), localParameters, freeParameters, + depth + 1U, nodes); + return; + case ParameterKind::Negate: + case ParameterKind::Sin: + case ParameterKind::Cos: + case ParameterKind::Tan: + case ParameterKind::ArcSin: + case ParameterKind::ArcCos: + case ParameterKind::ArcTan: + case ParameterKind::Exp: + case ParameterKind::Log: + case ParameterKind::Abs: + case ParameterKind::Conjugate: + if (parameter.right) { + throw std::runtime_error( + "Qiskit unary parameter expression has invalid operands"); + } + validateParameterImpl(requireLeft(), localParameters, freeParameters, + depth + 1U, nodes); return; } - throw std::runtime_error( - "Qiskit circuit import does not support free symbolic parameter '" + - parameter.text + "'"); + throw std::runtime_error("unknown normalized Qiskit parameter expression"); } -[[nodiscard]] ParameterValue -parameterValue(const std::string_view text, - const LocalParameters& localParameters) { - if (const auto local = localParameters.find(text); - local != localParameters.end()) { - return local->second; - } - throw std::runtime_error( - "Qiskit circuit import does not support free symbolic parameter '" + - std::string(text) + "'"); +void validateParameter(const Parameter& parameter, + const ValidationParameters& localParameters, + const ValidationParameters& freeParameters) { + size_t nodes = 0U; + validateParameterImpl(parameter, localParameters, freeParameters, 1U, nodes); +} + +[[nodiscard]] mlir::Value +materializeParameterValue(mlir::qc::QCProgramBuilder& builder, + const ParameterValue& parameter) { + return std::holds_alternative(parameter) + ? floatConstant(builder, std::get(parameter)) + : std::get(parameter); } [[nodiscard]] ParameterValue -parameterValue(const Parameter& parameter, - const LocalParameters& localParameters) { - if (parameter.number) { - if (!std::isfinite(*parameter.number)) { +parameterValueImpl(mlir::qc::QCProgramBuilder& builder, + const Parameter& parameter, + const LocalParameters& localParameters, + const GlobalParameters& globalParameters, const size_t depth, + size_t& nodes) { + if (depth > MAX_PARAMETER_EXPRESSION_DEPTH) { + throwImportedParameterExpressionDepthError(); + } + if (++nodes > MAX_PARAMETER_EXPRESSION_NODES) { + throwImportedParameterExpressionSizeError(); + } + const auto requireLeft = [&]() -> const Parameter& { + if (!parameter.left) { + throw std::runtime_error( + "Qiskit parameter expression has a missing operand"); + } + return *parameter.left; + }; + const auto requireRight = [&]() -> const Parameter& { + if (!parameter.right) { + throw std::runtime_error( + "Qiskit parameter expression has a missing operand"); + } + return *parameter.right; + }; + switch (parameter.kind) { + case ParameterKind::Number: + if (!std::isfinite(parameter.number)) { throw std::runtime_error("Qiskit returned a non-finite parameter"); } - return *parameter.number; + return parameter.number; + case ParameterKind::Symbol: + if (!parameter.identity.empty()) { + if (const auto local = localParameters.find(parameter.identity); + local != localParameters.end()) { + return local->second; + } + if (const auto global = globalParameters.find(parameter.identity); + global != globalParameters.end()) { + return global->second; + } + } + throw std::runtime_error("Qiskit parameter symbol '" + parameter.text + + "' is not defined in this circuit scope"); + case ParameterKind::Conjugate: + // QC scalar parameters are real-valued, so conjugation is the identity. + return parameterValueImpl(builder, requireLeft(), localParameters, + globalParameters, depth + 1U, nodes); + default: + break; + } + + const auto left = materializeParameterValue( + builder, parameterValueImpl(builder, requireLeft(), localParameters, + globalParameters, depth + 1U, nodes)); + switch (parameter.kind) { + case ParameterKind::Negate: + return mlir::arith::NegFOp::create(builder, left).getResult(); + case ParameterKind::Sin: + return mlir::math::SinOp::create(builder, left).getResult(); + case ParameterKind::Cos: + return mlir::math::CosOp::create(builder, left).getResult(); + case ParameterKind::Tan: + return mlir::math::TanOp::create(builder, left).getResult(); + case ParameterKind::ArcSin: + return mlir::math::AsinOp::create(builder, left).getResult(); + case ParameterKind::ArcCos: + return mlir::math::AcosOp::create(builder, left).getResult(); + case ParameterKind::ArcTan: + return mlir::math::AtanOp::create(builder, left).getResult(); + case ParameterKind::Exp: + return mlir::math::ExpOp::create(builder, left).getResult(); + case ParameterKind::Log: + return mlir::math::LogOp::create(builder, left).getResult(); + case ParameterKind::Abs: + return mlir::math::AbsFOp::create(builder, left).getResult(); + default: + break; + } + + const auto right = materializeParameterValue( + builder, parameterValueImpl(builder, requireRight(), localParameters, + globalParameters, depth + 1U, nodes)); + switch (parameter.kind) { + case ParameterKind::Add: + return mlir::arith::AddFOp::create(builder, left, right).getResult(); + case ParameterKind::Subtract: + return mlir::arith::SubFOp::create(builder, left, right).getResult(); + case ParameterKind::Multiply: + return mlir::arith::MulFOp::create(builder, left, right).getResult(); + case ParameterKind::Divide: + return mlir::arith::DivFOp::create(builder, left, right).getResult(); + case ParameterKind::Power: + return mlir::math::PowFOp::create(builder, left, right).getResult(); + default: + break; } - return parameterValue(parameter.text, localParameters); + throw std::runtime_error("unknown normalized Qiskit parameter expression"); +} + +[[nodiscard]] ParameterValue +parameterValue(mlir::qc::QCProgramBuilder& builder, const Parameter& parameter, + const LocalParameters& localParameters, + const GlobalParameters& globalParameters) { + size_t nodes = 0U; + return parameterValueImpl(builder, parameter, localParameters, + globalParameters, 1U, nodes); } void requireArity(const Instruction& instruction, const size_t qubits, @@ -257,6 +458,7 @@ void emitModifiedOperation( mlir::qc::QCProgramBuilder& builder, const Instruction& instruction, const mlir::ValueRange qubits, const ModifiedQubitArity arity, const LocalParameters& localParameters, + const GlobalParameters& globalParameters, llvm::function_ref emitBase) { const auto targets = qubits.drop_front(arity.controls); const auto emitModifiers = @@ -279,7 +481,8 @@ void emitModifiedOperation( }); return; case GateModifierKind::Power: { - const auto exponent = parameterValue(modifier.exponent, localParameters); + const auto exponent = parameterValue(builder, modifier.exponent, + localParameters, globalParameters); builder.pow(exponent, targetArguments, [&](const mlir::ValueRange innerArguments) { self(self, count - 1U, innerArguments); @@ -305,7 +508,8 @@ void emitModifiedGate(mlir::qc::QCProgramBuilder& builder, const Instruction& instruction, const mlir::ValueRange qubits, const llvm::ArrayRef parameters, - const LocalParameters& localParameters) { + const LocalParameters& localParameters, + const GlobalParameters& globalParameters) { const auto arity = gateArity(instruction); if (!arity) { throw std::runtime_error("unsupported modified Qiskit standard gate '" + @@ -318,7 +522,7 @@ void emitModifiedGate(mlir::qc::QCProgramBuilder& builder, emitModifiedOperation( builder, instruction, qubits, modifiedQubitArity(instruction, arity->first), localParameters, - [&](const mlir::ValueRange targetArguments) { + globalParameters, [&](const mlir::ValueRange targetArguments) { emitStandardGate(builder, instruction, targetArguments, parameters); }); } @@ -359,7 +563,8 @@ void emitGate(mlir::qc::QCProgramBuilder& builder, const Instruction& instruction, const llvm::ArrayRef allQubits, const llvm::ArrayRef qubitMap, - const LocalParameters& localParameters) { + const LocalParameters& localParameters, + const GlobalParameters& globalParameters) { llvm::SmallVector qubits; qubits.reserve(instruction.qubits.size()); for (const auto index : instruction.qubits) { @@ -372,13 +577,14 @@ void emitGate(mlir::qc::QCProgramBuilder& builder, llvm::SmallVector parameters; parameters.reserve(instruction.parameters.size()); for (const auto& parameter : instruction.parameters) { - parameters.push_back(parameterValue(parameter, localParameters)); + parameters.push_back( + parameterValue(builder, parameter, localParameters, globalParameters)); } const llvm::ArrayRef qubitRange(qubits); if (!instruction.modifiers.empty()) { emitModifiedGate(builder, instruction, qubitRange, parameters, - localParameters); + localParameters, globalParameters); return; } emitStandardGate(builder, instruction, qubitRange, parameters); @@ -765,6 +971,7 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, llvm::ArrayRef allQubits, llvm::ArrayRef classicalBits, const LocalParameters& localParameters, + const GlobalParameters& globalParameters, size_t definitionDepth, size_t controlFlowDepth); [[nodiscard]] int64_t rangeLength(const Loop& loop) { @@ -821,6 +1028,7 @@ void translateControlFlow(mlir::qc::QCProgramBuilder& builder, llvm::ArrayRef rootQubitMap, llvm::ArrayRef rootClbitMap, const LocalParameters& localParameters, + const GlobalParameters& globalParameters, const size_t definitionDepth, const size_t controlFlowDepth) { if (controlFlowDepth >= MAX_CONTROL_FLOW_DEPTH) { @@ -849,7 +1057,7 @@ void translateControlFlow(mlir::qc::QCProgramBuilder& builder, const LocalParameters& parameters) { translateCircuit(builder, block, qubitMap, clbitMap, rootQubitMap, rootClbitMap, allQubits, classicalBits, parameters, - definitionDepth, controlFlowDepth + 1U); + globalParameters, definitionDepth, controlFlowDepth + 1U); }; switch (controlFlow.kind()) { @@ -904,7 +1112,7 @@ void translateControlFlow(mlir::qc::QCProgramBuilder& builder, auto parameters = localParameters; if (loop.parameter) { requireExactLoopParameter(value); - parameters[*loop.parameter] = + parameters[loop.parameter->identity] = floatConstant(builder, static_cast(value)); } translateBlock(*body, parameters); @@ -919,7 +1127,7 @@ void translateControlFlow(mlir::qc::QCProgramBuilder& builder, builder.scfFor(0, count, 1, [&](const mlir::Value iteration) { auto parameters = localParameters; if (loop.parameter) { - parameters[*loop.parameter] = + parameters[loop.parameter->identity] = loopParameterValue(builder, iteration, loop); } translateBlock(*body, parameters); @@ -988,9 +1196,11 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, const llvm::ArrayRef allQubits, const llvm::ArrayRef classicalBits, const LocalParameters& localParameters, + const GlobalParameters& globalParameters, const size_t definitionDepth, const size_t controlFlowDepth) { - builder.gphase(parameterValue(circuit.globalPhase(), localParameters)); + builder.gphase(parameterValue(builder, circuit.globalPhase(), localParameters, + globalParameters)); const auto getQubit = [&](const uint32_t local) { if (local >= qubitMap.size() || qubitMap[local] >= allQubits.size()) { throw std::runtime_error( @@ -1028,8 +1238,8 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, } translateCircuit(builder, *definition, definitionQubits, definitionClbits, definitionQubits, definitionClbits, allQubits, - classicalBits, localParameters, definitionDepth + 1U, - controlFlowDepth); + classicalBits, localParameters, globalParameters, + definitionDepth + 1U, controlFlowDepth); }; for (size_t index = 0; index < circuit.numInstructions(); ++index) { @@ -1037,7 +1247,8 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, switch (instruction.kind) { case OperationKind::Gate: if (instruction.standardGate) { - emitGate(builder, instruction, allQubits, qubitMap, localParameters); + emitGate(builder, instruction, allQubits, qubitMap, localParameters, + globalParameters); } else { translateDefinition(index, instruction); } @@ -1081,7 +1292,7 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, const auto matrix = mlir::DenseElementsAttr::get( type, llvm::ArrayRef>(values)); emitModifiedOperation(builder, instruction, operands, arity, - localParameters, + localParameters, globalParameters, [&](const mlir::ValueRange targetArguments) { builder.unitary(targetArguments, matrix); }); @@ -1090,7 +1301,7 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, const auto controlFlow = circuit.controlFlow(index); translateControlFlow(builder, *controlFlow, allQubits, classicalBits, rootQubitMap, rootClbitMap, localParameters, - definitionDepth, controlFlowDepth); + globalParameters, definitionDepth, controlFlowDepth); break; } case OperationKind::Delay: @@ -1230,7 +1441,8 @@ expansionSummary(const CircuitReader& circuit, ExpansionCountState& state, } void validateCircuit(const CircuitReader& circuit, - const llvm::StringSet<>& localParameters, + const ValidationParameters& localParameters, + const ValidationParameters& freeParameters, uint32_t rootQubits, uint32_t rootClbits, size_t definitionDepth, size_t controlFlowDepth); @@ -1293,7 +1505,8 @@ void validateTarget(const ClassicalTarget& target, const uint32_t rootClbits) { } void validateControlFlow(const ControlFlowReader& controlFlow, - llvm::StringSet<> localParameters, + ValidationParameters localParameters, + const ValidationParameters& freeParameters, const uint32_t rootQubits, const uint32_t rootClbits, const size_t definitionDepth, const size_t controlFlowDepth) { @@ -1353,7 +1566,12 @@ void validateControlFlow(const ControlFlowReader& controlFlow, } } if (loop.parameter) { - localParameters.insert(*loop.parameter); + if (loop.parameter->kind != ParameterKind::Symbol || + loop.parameter->identity.empty() || loop.parameter->text.empty()) { + throw std::runtime_error( + "Qiskit for-loop parameter has invalid symbol metadata"); + } + localParameters[loop.parameter->identity] = *loop.parameter; } break; } @@ -1408,13 +1626,14 @@ void validateControlFlow(const ControlFlowReader& controlFlow, throw std::runtime_error( "Qiskit control-flow block operands do not match its bit mapping"); } - validateCircuit(*block, localParameters, rootQubits, rootClbits, - definitionDepth, controlFlowDepth + 1U); + validateCircuit(*block, localParameters, freeParameters, rootQubits, + rootClbits, definitionDepth, controlFlowDepth + 1U); } } void validateDefinition(const CircuitReader& circuit, const size_t index, - const llvm::StringSet<>& localParameters, + const ValidationParameters& localParameters, + const ValidationParameters& freeParameters, const size_t definitionDepth, const size_t controlFlowDepth) { if (definitionDepth >= MAX_DEFINITION_DEPTH) { @@ -1422,13 +1641,14 @@ void validateDefinition(const CircuitReader& circuit, const size_t index, "Qiskit instruction definitions exceed the nesting limit of 64"); } const auto definition = circuit.definition(index); - validateCircuit(*definition, localParameters, definition->numQubits(), - definition->numClbits(), definitionDepth + 1U, - controlFlowDepth); + validateCircuit(*definition, localParameters, freeParameters, + definition->numQubits(), definition->numClbits(), + definitionDepth + 1U, controlFlowDepth); } void validateCircuit(const CircuitReader& circuit, - const llvm::StringSet<>& localParameters, + const ValidationParameters& localParameters, + const ValidationParameters& freeParameters, const uint32_t rootQubits, const uint32_t rootClbits, const size_t definitionDepth, const size_t controlFlowDepth) { @@ -1441,7 +1661,7 @@ void validateCircuit(const CircuitReader& circuit, circuit.numQubits(), "quantum")); static_cast(validateRegisterLayout(circuitRegisters(circuit, false), circuit.numClbits(), "classical")); - validateParameter(circuit.globalPhase(), localParameters); + validateParameter(circuit.globalPhase(), localParameters, freeParameters); for (size_t index = 0; index < circuit.numInstructions(); ++index) { const auto instruction = circuit.instruction(index); @@ -1458,11 +1678,11 @@ void validateCircuit(const CircuitReader& circuit, } } for (const auto& parameter : instruction.parameters) { - validateParameter(parameter, localParameters); + validateParameter(parameter, localParameters, freeParameters); } for (const auto& modifier : instruction.modifiers) { if (modifier.kind == GateModifierKind::Power) { - validateParameter(modifier.exponent, localParameters); + validateParameter(modifier.exponent, localParameters, freeParameters); } } @@ -1491,8 +1711,8 @@ void validateCircuit(const CircuitReader& circuit, "Qiskit circuit import does not support modifiers on custom " "instructions"); } - validateDefinition(circuit, index, localParameters, definitionDepth, - controlFlowDepth); + validateDefinition(circuit, index, localParameters, freeParameters, + definitionDepth, controlFlowDepth); break; case OperationKind::Unknown: if (!instruction.modifiers.empty()) { @@ -1500,8 +1720,8 @@ void validateCircuit(const CircuitReader& circuit, "Qiskit circuit import does not support modifiers on custom " "instructions"); } - validateDefinition(circuit, index, localParameters, definitionDepth, - controlFlowDepth); + validateDefinition(circuit, index, localParameters, freeParameters, + definitionDepth, controlFlowDepth); break; case OperationKind::Barrier: if (!instruction.parameters.empty() || !instruction.clbits.empty()) { @@ -1526,8 +1746,9 @@ void validateCircuit(const CircuitReader& circuit, break; case OperationKind::ControlFlow: { const auto controlFlow = circuit.controlFlow(index); - validateControlFlow(*controlFlow, localParameters, rootQubits, rootClbits, - definitionDepth, controlFlowDepth); + validateControlFlow(*controlFlow, localParameters, freeParameters, + rootQubits, rootClbits, definitionDepth, + controlFlowDepth); break; } case OperationKind::Delay: @@ -1541,10 +1762,30 @@ void validateCircuit(const CircuitReader& circuit, mlir::QCProgram importCircuit(const nb::handle circuit) { auto translation = selectTranslation(); auto view = translation->openCircuit(circuit); + const auto freeParameters = view->parameters(); + ValidationParameters freeParameterSymbols; + llvm::StringSet<> freeParameterNames; + for (const auto& parameter : freeParameters) { + if (parameter.kind != ParameterKind::Symbol || parameter.text.empty() || + parameter.identity.empty()) { + throw std::runtime_error( + "Qiskit circuit returned an invalid free parameter"); + } + if (!freeParameterSymbols.try_emplace(parameter.identity, parameter) + .second) { + throw std::runtime_error( + "Qiskit circuit returned a duplicate parameter identity"); + } + if (!freeParameterNames.insert(parameter.text).second) { + throw std::runtime_error( + "Qiskit circuit contains distinct parameters with the same name"); + } + } ExpansionCountState expansion; static_cast(expansionSummary(*view, expansion)); - validateCircuit(*view, {}, view->numQubits(), view->numClbits(), 0U, 0U); + validateCircuit(*view, {}, freeParameterSymbols, view->numQubits(), + view->numClbits(), 0U, 0U); const auto quantumRegisters = circuitRegisters(*view, true); const auto classicalRegisters = circuitRegisters(*view, false); const auto looseQubits = @@ -1568,6 +1809,26 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { } } builder.initialize(resultTypes); + auto function = llvm::cast( + builder.getInsertionBlock()->getParentOp()); + GlobalParameters globalParameters; + for (const auto& parameter : freeParameters) { + const llvm::SmallVector argumentAttributes{ + builder.getNamedAttr(mlir::utils::INPUT_NAME_ATTR, + builder.getStringAttr(parameter.text))}; + const auto index = function.getNumArguments(); + // MLIR types are handles. Converting FloatType to Type keeps the same + // storage and does not slice object state. + // NOLINTNEXTLINE(cppcoreguidelines-slicing) + const mlir::Type parameterType = builder.getF64Type(); + if (failed(function.insertArgument( + index, parameterType, builder.getDictionaryAttr(argumentAttributes), + builder.getLoc()))) { + throw std::runtime_error( + "failed to create a compiler input for a Qiskit parameter"); + } + globalParameters[parameter.identity] = function.getArgument(index); + } llvm::SmallVector qubits; qubits.reserve(view->numQubits()); @@ -1607,7 +1868,7 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { std::iota(qubitMap.begin(), qubitMap.end(), 0U); std::iota(clbitMap.begin(), clbitMap.end(), 0U); translateCircuit(builder, *view, qubitMap, clbitMap, qubitMap, clbitMap, - qubits, classicalBits, {}, 0U, 0U); + qubits, classicalBits, {}, globalParameters, 0U, 0U); auto moduleOp = classicalStorage.empty() ? builder.finalize() : builder.finalize(classicalStorage); diff --git a/bindings/mlir/qiskit/QiskitTranslation.h b/bindings/mlir/qiskit/QiskitTranslation.h index a200a7e7d7..e90ccf69f1 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -48,9 +48,38 @@ struct Register { validateRegisterLayout(const std::vector& registers, uint32_t total, std::string_view kind); +inline constexpr size_t MAX_PARAMETER_EXPRESSION_DEPTH = 64U; +inline constexpr size_t MAX_PARAMETER_EXPRESSION_NODES = 4096U; + +enum class ParameterKind : uint8_t { + Number, + Symbol, + Add, + Subtract, + Multiply, + Divide, + Power, + Negate, + Sin, + Cos, + Tan, + ArcSin, + ArcCos, + ArcTan, + Exp, + Log, + Abs, + Conjugate, +}; + +/** One normalized scalar parameter-expression tree. */ struct Parameter { - std::optional number = 0.0; + ParameterKind kind = ParameterKind::Number; + double number = 0.0; std::string text; + std::string identity; + std::shared_ptr left; + std::shared_ptr right; }; enum class GateModifierKind : uint8_t { @@ -169,7 +198,7 @@ struct Loop { int64_t stop = 0; int64_t step = 1; std::vector values; - std::optional parameter; + std::optional parameter; }; struct SwitchCase { @@ -196,6 +225,8 @@ class CircuitReader { [[nodiscard]] virtual bool hasClassicalVariables() const = 0; [[nodiscard]] virtual Register quantumRegister(size_t index) const = 0; [[nodiscard]] virtual Register classicalRegister(size_t index) const = 0; + /** Return the circuit's free scalar parameters in a stable order. */ + [[nodiscard]] virtual std::vector parameters() const = 0; [[nodiscard]] virtual Parameter globalPhase() const = 0; [[nodiscard]] virtual Instruction instruction(size_t index) const = 0; [[nodiscard]] virtual std::vector> @@ -239,10 +270,10 @@ class CircuitWriter { virtual void addQuantumRegister(std::string_view name, uint32_t size) = 0; virtual void addClassicalRegister(std::string_view name, uint32_t size) = 0; - virtual void setGlobalPhase(double phase) = 0; + virtual void setGlobalPhase(const Parameter& phase) = 0; virtual void addGate(StandardGateMapping gate, const std::vector& qubits, - const std::vector& parameters) = 0; + const std::vector& parameters) = 0; virtual void addMeasure(uint32_t qubit, uint32_t clbit) = 0; virtual void addReset(uint32_t qubit) = 0; virtual void addBarrier(const std::vector& qubits) = 0; diff --git a/docs/mlir/python_compiler_collection.md b/docs/mlir/python_compiler_collection.md index 695960c9d6..d7f2fbc329 100644 --- a/docs/mlir/python_compiler_collection.md +++ b/docs/mlir/python_compiler_collection.md @@ -173,16 +173,25 @@ program structures than its C API can construct. | Classical-bit and register conditions | Supported | Rejected | | Constant Boolean, `Uint` up to 64 bits, and `Float` expressions | Supported | Rejected | | Standalone classical variables or variable expressions | Rejected | Rejected | -| Free symbolic parameters | Rejected | Rejected | +| Free symbols and supported real parameter expressions | Supported | Supported | +| Parameter-vector elements | Rejected | Not emitted | | Dense numeric unitaries up to eight qubits | Supported | Supported | | Register aliases or interleaved membership | Rejected | Rejected | | Transpiler layout metadata | Accepted and ignored | Not emitted | -Lexically bound {code}`for`-loop induction parameters are supported. Numeric -parameters passed to a custom instruction are bound before its definition is -expanded. Definition expansion rejects missing definitions, cycles, operand -arity mismatches, nesting beyond 64 levels, and more than 10 million expanded -operations. +Free standalone symbols become named {code}`f64` program inputs. +Parameter-vector elements are rejected because converting them to standalone +parameters would change positional binding order. Standalone parameter names +that contain brackets remain ordinary scalar names. Parameter-expression trees +support at most 64 levels and 4,096 nodes. Import and export support real +addition, subtraction, multiplication, division, power, negation, trigonometric +and inverse trigonometric functions, exponential, logarithm, absolute value, and +real conjugation. Other parameter-expression functions are rejected. Lexically +bound {code}`for`-loop induction parameters are supported and remain distinct +from free symbols. Parameterized custom-instruction definitions are expanded +after their symbols and expressions are resolved. Definition expansion rejects +missing definitions, cycles, operand arity mismatches, nesting beyond 64 levels, +and more than 10 million expanded operations. Dense numeric unitaries remain explicit matrix operations during import and export. Target compilation synthesizes supported one- and two-qubit matrices to diff --git a/mlir/include/mlir/Dialect/Utils/Utils.h b/mlir/include/mlir/Dialect/Utils/Utils.h index a4b9b6dc24..feab5bad12 100644 --- a/mlir/include/mlir/Dialect/Utils/Utils.h +++ b/mlir/include/mlir/Dialect/Utils/Utils.h @@ -46,6 +46,9 @@ namespace mlir::utils { inline constexpr llvm::StringLiteral QUBIT_REGISTER_NAME_ATTR = "mqt.qubit_register_name"; +/// Attribute used to retain the source-level name of a scalar program input. +inline constexpr llvm::StringLiteral INPUT_NAME_ATTR = "mqt.input_name"; + /// Check if a floating-point value is an integer. [[nodiscard]] inline bool isIntegerExponent(double r) { return r == std::floor(r) && std::isfinite(r); diff --git a/mlir/lib/Compiler/CMakeLists.txt b/mlir/lib/Compiler/CMakeLists.txt index 566c7b8697..59552438be 100644 --- a/mlir/lib/Compiler/CMakeLists.txt +++ b/mlir/lib/Compiler/CMakeLists.txt @@ -75,6 +75,7 @@ add_mlir_library( MLIRTargetLLVMIRExport MLIRBuiltinToLLVMIRTranslation MLIRLLVMToLLVMIRTranslation + MLIRMathDialect MQTCompilerTarget MQT::MLIRSupport) diff --git a/mlir/lib/Compiler/Programs.cpp b/mlir/lib/Compiler/Programs.cpp index b73d58ff3f..116939f8dc 100644 --- a/mlir/lib/Compiler/Programs.cpp +++ b/mlir/lib/Compiler/Programs.cpp @@ -46,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -85,8 +86,8 @@ namespace mlir { DialectRegistry registry; registry.insert(); registerBuiltinDialectTranslation(registry); registerLLVMDialectTranslation(registry); diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 40c918150c..f6ed06d610 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -14,6 +14,8 @@ import re import subprocess import sys +from typing import TYPE_CHECKING +from uuid import uuid4 import numpy as np import pytest @@ -27,6 +29,8 @@ Gate, InverseModifier, Parameter, + ParameterExpression, + ParameterVector, PowerModifier, Qubit, library, @@ -37,6 +41,9 @@ from mqt.core.mlir import CompilerTarget, QCProgram, compile_program from mqt.core.plugins.qiskit import qiskit_to_mqt +if TYPE_CHECKING: + from collections.abc import Callable + installed_qiskit = Version(qiskit.__version__) candidate_version = os.environ.get("MQT_QISKIT_TEST_CANDIDATE_VERSION") if not (Version("2.5.0") <= installed_qiskit < Version("2.6.0") or qiskit.__version__ == candidate_version): @@ -704,7 +711,7 @@ def test_nested_numeric_custom_definitions_are_inlined() -> None: def test_ambiguous_custom_parameter_binding_is_rejected() -> None: - """Do not infer formal parameter order from Qiskit's sorted parameter set.""" + """Reject custom-definition symbols absent from the enclosing circuit.""" z = Parameter("z") a = Parameter("a") definition = QuantumCircuit(1) @@ -715,7 +722,7 @@ def test_ambiguous_custom_parameter_binding_is_rejected() -> None: circuit = QuantumCircuit(1) circuit.append(gate, [0]) - with pytest.raises(RuntimeError, match="must be numerically bound before import"): + with pytest.raises(RuntimeError, match="parameter symbol 'z' is not defined"): QCProgram.from_qiskit(circuit) @@ -835,9 +842,12 @@ def test_rejections_do_not_modify_source_circuits() -> None: """Reject unsupported parameters and inputs without mutation.""" theta = Parameter("theta") symbolic = QuantumCircuit(1) - symbolic.rx(theta, 0) + symbolic.rx(theta.sign(), 0) symbolic_data = list(symbolic.data) - with pytest.raises(RuntimeError, match="free symbolic parameter 'theta'"): + with pytest.raises( + RuntimeError, + match=r"(?i)Qiskit parameter expression operation 'sign' is not supported", + ): QCProgram.from_qiskit(symbolic) assert list(symbolic.data) == symbolic_data assert symbolic.parameters == {theta} @@ -852,6 +862,79 @@ def test_rejections_do_not_modify_source_circuits() -> None: assert list(runtime_input.data) == input_data +@pytest.mark.parametrize("value", [np.inf, np.nan], ids=["infinity", "nan"]) +def test_nonfinite_parameters_fail_closed_without_mutation(value: float) -> None: + """Reject non-finite scalar parameters before changing the source circuit.""" + circuit = QuantumCircuit(1) + circuit.rx(value, 0) + source_data = list(circuit.data) + + with pytest.raises(RuntimeError, match="Qiskit returned a non-finite parameter"): + QCProgram.from_qiskit(circuit) + + assert len(circuit.data) == len(source_data) + current = circuit.data[0] + original = source_data[0] + assert current.operation.name == original.operation.name == "rx" + assert current.qubits == original.qubits + assert current.clbits == original.clbits + assert len(current.operation.params) == 1 + assert np.isnan(current.operation.params[0]) == np.isnan(value) + assert np.isinf(current.operation.params[0]) == np.isinf(value) + + +def test_complex_parameter_expression_fails_closed_without_mutation() -> None: + """Reject a complex-valued expression before changing the source circuit.""" + theta = Parameter("theta") + circuit = QuantumCircuit(1) + circuit.rx(theta + 1j, 0) + source_data = list(circuit.data) + + with pytest.raises(RuntimeError, match="parameter expressions with complex values are not supported"): + QCProgram.from_qiskit(circuit) + + assert list(circuit.data) == source_data + assert circuit.parameters == {theta} + + +def test_excessively_nested_parameter_expression_fails_closed_without_mutation() -> None: + """Bound parameter-expression traversal before changing the source circuit.""" + theta = Parameter("theta") + angle: ParameterExpression = theta + for _ in range(65): + angle = angle.sin() + circuit = QuantumCircuit(1) + circuit.rz(angle, 0) + source_data = list(circuit.data) + + with pytest.raises(RuntimeError, match="exceeds the supported 64-level nesting depth"): + QCProgram.from_qiskit(circuit) + + assert list(circuit.data) == source_data + assert circuit.parameters == {theta} + + +def test_oversized_parameter_expression_fails_closed_without_mutation() -> None: + """Bound a wide parameter expression before changing the source circuit.""" + theta = Parameter("theta") + level: list[ParameterExpression] = [theta] + level.extend(theta + float(index) for index in range(1, 2049)) + while len(level) > 1: + level = [ + level[index] + level[index + 1] if index + 1 < len(level) else level[index] + for index in range(0, len(level), 2) + ] + circuit = QuantumCircuit(1) + circuit.rz(level[0], 0) + source_data = list(circuit.data) + + with pytest.raises(RuntimeError, match="exceeds the supported 4096-node size"): + QCProgram.from_qiskit(circuit) + + assert list(circuit.data) == source_data + assert circuit.parameters == {theta} + + @pytest.mark.parametrize("resource", ["quantum", "classical"]) @pytest.mark.parametrize("layout", ["alias", "interleaved"]) def test_noncanonical_register_membership_is_rejected(resource: str, layout: str) -> None: @@ -968,10 +1051,470 @@ def test_excessively_nested_control_flow_is_rejected() -> None: QCProgram.from_qiskit(body) -def test_flat_export_rejects_symbolic_inputs() -> None: - """Reject program inputs before allocating an output circuit.""" +def test_direct_symbolic_parameters_round_trip_with_shared_identity() -> None: + """Represent a shared Qiskit parameter as one named f64 input.""" + theta = Parameter("theta") + circuit = QuantumCircuit(1, global_phase=theta) + circuit.ry(theta, 0) + circuit.rz(theta, 0) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert 'mqt.input_name = "theta"' in program.ir + assert len(restored.parameters) == 1 + restored_theta = next(iter(restored.parameters)) + assert restored.global_phase == restored_theta + assert restored.data[0].operation.params[0] == restored_theta + assert restored.data[1].operation.params[0] == restored_theta + value = 0.375 + assert np.allclose( + Operator(restored.assign_parameters({restored_theta: value})).data, + Operator(circuit.assign_parameters({theta: value})).data, + ) + + +def test_parameter_vector_elements_fail_import_without_mutation() -> None: + """Reject vector elements until the provenance follow-up is applied.""" + vector = ParameterVector("theta", 2) + circuit = QuantumCircuit(1) + circuit.rx(vector[0], 0) + source_data = list(circuit.data) + + with pytest.raises(RuntimeError, match="parameter-vector elements are not supported"): + QCProgram.from_qiskit(circuit) + + assert list(circuit.data) == source_data + assert circuit.parameters == {vector[0]} + + +def test_standalone_bracket_parameter_names_remain_standalone() -> None: + """Do not infer an input group from a standalone parameter's name.""" + theta_ten = Parameter("theta[10]") + theta_two = Parameter("theta[2]") + circuit = QuantumCircuit(1) + circuit.rx(theta_ten, 0) + circuit.ry(theta_two, 0) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert "mqt.input_group" not in program.ir + assert {parameter.name for parameter in restored.parameters} == {"theta[2]", "theta[10]"} + values = [0.1, 0.2] + assert Operator(restored.assign_parameters(values)).equiv(Operator(circuit.assign_parameters(values))) + + +def _assign_parameter_values(circuit: QuantumCircuit, values: dict[str, float]) -> QuantumCircuit: + """Bind a circuit using parameter names after an import/export round trip. + + Returns: + A copy of the circuit with all parameters bound. + """ + return circuit.assign_parameters({parameter: values[parameter.name] for parameter in circuit.parameters}) + + +def test_nested_symbolic_arithmetic_round_trip_with_shared_global_phase() -> None: + """Preserve nested arithmetic and shared symbols in gates and global phase.""" + theta = Parameter("theta") + phi = Parameter("phi") + angle = -((2 - theta) * (phi.sin() + 0.25) / (theta**2 + 1)) + circuit = QuantumCircuit(1, global_phase=theta + phi) + circuit.ry(angle, 0) + circuit.rz(theta + phi, 0) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert {parameter.name for parameter in restored.parameters} == {"phi", "theta"} + assert len(restored.parameters) == 2 + values = {"phi": 0.4, "theta": -0.3} + assert np.allclose( + Operator(_assign_parameter_values(restored, values)).data, + Operator(_assign_parameter_values(circuit, values)).data, + ) + + +@pytest.mark.parametrize( + ("operation", "value"), + [ + (lambda parameter: 2 + parameter, 0.25), + (lambda parameter: 2 - parameter, 0.25), + (lambda parameter: 2 * parameter, 0.25), + (lambda parameter: 2 / parameter, 0.75), + (lambda parameter: 2**parameter, -0.5), + ], + ids=["reverse-add", "reverse-subtract", "reverse-multiply", "reverse-divide", "reverse-power"], +) +def test_reverse_symbolic_arithmetic_round_trip( + operation: Callable[[Parameter], ParameterExpression], value: float +) -> None: + """Preserve Qiskit's reflected arithmetic operators.""" + theta = Parameter("theta") + circuit = QuantumCircuit(1) + circuit.rz(operation(theta), 0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + values = {"theta": value} + assert np.allclose( + Operator(_assign_parameter_values(restored, values)).data, + Operator(_assign_parameter_values(circuit, values)).data, + ) + + +@pytest.mark.parametrize( + ("operation", "value"), + [ + (lambda parameter: parameter.sin(), 0.2), + (lambda parameter: parameter.cos(), 0.2), + (lambda parameter: parameter.tan(), 0.2), + (lambda parameter: parameter.arcsin(), 0.2), + (lambda parameter: parameter.arccos(), 0.2), + (lambda parameter: parameter.arctan(), 0.2), + (lambda parameter: parameter.exp(), 0.2), + (lambda parameter: parameter.log(), 1.2), + (abs, -0.2), + (lambda parameter: parameter.conjugate(), 0.2), + ], + ids=["sin", "cos", "tan", "arcsin", "arccos", "arctan", "exp", "log", "abs", "conjugate"], +) +def test_symbolic_unary_function_round_trip( + operation: Callable[[Parameter], ParameterExpression], value: float +) -> None: + """Preserve supported unary Qiskit parameter functions.""" + theta = Parameter("theta") + circuit = QuantumCircuit(1) + circuit.rx(operation(theta), 0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + values = {"theta": value} + assert np.allclose( + Operator(_assign_parameter_values(restored, values)).data, + Operator(_assign_parameter_values(circuit, values)).data, + ) + + +def test_partially_bound_symbolic_expression_round_trip() -> None: + """Keep the unbound identity after partially binding an expression.""" + theta = Parameter("theta") + phi = Parameter("phi") + angle = (theta * phi + phi.sin()).bind({theta: 0.5}) + circuit = QuantumCircuit(1) + circuit.ry(angle, 0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + assert {parameter.name for parameter in restored.parameters} == {"phi"} + values = {"phi": 0.4} + assert np.allclose( + Operator(_assign_parameter_values(restored, values)).data, + Operator(_assign_parameter_values(circuit, values)).data, + ) + + +def test_float_castable_symbolic_expression_keeps_parameter_identity() -> None: + """Do not collapse a float-castable expression that still tracks a symbol.""" + theta = Parameter("theta") + angle = (theta - theta) + 2 + assert angle.parameters == {theta} + assert float(angle) == pytest.approx(2) + circuit = QuantumCircuit(1) + circuit.rz(angle, 0) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert 'mqt.input_name = "theta"' in program.ir + assert {parameter.name for parameter in restored.parameters} == {"theta"} + values = {"theta": 0.3} + assert np.allclose( + Operator(_assign_parameter_values(restored, values)).data, + Operator(_assign_parameter_values(circuit, values)).data, + ) + + +def test_parameterized_custom_definition_round_trip() -> None: + """Substitute symbolic call parameters while recursively inlining a definition.""" + formal = Parameter("formal") + definition = QuantumCircuit(1) + definition.rx(formal + 1, 0) + custom = definition.to_gate(label="symbolic") + circuit = QuantumCircuit(1) + circuit.append(custom, [0]) + theta = Parameter("theta") + circuit.assign_parameters({formal: theta + 0.25}, inplace=True) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + assert {parameter.name for parameter in restored.parameters} == {"theta"} + values = {"theta": -0.2} + assert np.allclose( + Operator(_assign_parameter_values(restored, values)).data, + Operator(_assign_parameter_values(circuit, values)).data, + ) + + +def test_manual_arith_and_math_parameter_expression_exports_to_qiskit() -> None: + """Reconstruct an expression from generic Arith and Math operations.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main(%theta: f64 {mqt.input_name = "theta"}) attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %offset = arith.constant 5.000000e-01 : f64 + %sum = arith.addf %theta, %offset : f64 + %angle = math.sin %sum : f64 + qc.rz(%angle) %q : !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + + restored = program.to_qiskit() + + theta = next(iter(restored.parameters)) + bound = restored.assign_parameters({theta: 0.25}) + assert bound.data[0].operation.params[0] == pytest.approx(np.sin(0.75)) + + +def _wide_parameter_expression_program(term_count: int) -> QCProgram: + lines = [ + "module {", + ' func.func @main(%theta: f64 {mqt.input_name = "theta"}) attributes {passthrough = ["entry_point"]} {', + " %q = qc.alloc : !qc.qubit", + ] + values = [] + for index in range(term_count): + value = f"%term{index}" + lines.append(f" {value} = math.sin %theta : f64") + values.append(value) + sum_index = 0 + while len(values) > 1: + next_values = [] + for index in range(0, len(values), 2): + if index + 1 == len(values): + next_values.append(values[index]) + continue + value = f"%sum{sum_index}" + sum_index += 1 + lines.append(f" {value} = arith.addf {values[index]}, {values[index + 1]} : f64") + next_values.append(value) + values = next_values + lines.extend([ + f" qc.rz({values[0]}) %q : !qc.qubit", + " qc.dealloc %q : !qc.qubit", + " return", + " }", + "}", + ]) + return QCProgram.from_mlir_str("\n".join(lines)) + + +@pytest.mark.parametrize( + "term_count", + [1366, 2049], + ids=["expanded-tree", "unique-ssa-graph"], +) +def test_oversized_export_parameter_expression_fails_without_mutation(term_count: int) -> None: + """Bound normalized trees and compiler SSA traversal before Qiskit construction.""" + program = _wide_parameter_expression_program(term_count) + source_ir = program.ir + + with pytest.raises(RuntimeError, match="exceeds the supported 4096-node size"): + program.to_qiskit() + + assert program.ir == source_ir + + +def test_unsupported_scalar_operation_fails_export_without_mutation() -> None: + """Reject an unsupported f64 producer before changing the source program.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main(%theta: f64 {mqt.input_name = "theta"}) attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %angle = math.sqrt %theta : f64 + qc.rz(%angle) %q : !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + source_ir = program.ir + + with pytest.raises( + RuntimeError, + match=r"Qiskit circuit export does not support scalar parameter operation 'math\.sqrt'", + ): + program.to_qiskit() + + assert program.ir == source_ir + + +def test_same_name_global_and_loop_parameters_use_identity_not_name() -> None: + """Do not capture a same-name global symbol as a loop induction value.""" + global_parameter = Parameter("theta") + loop_parameter = Parameter("theta") + body = QuantumCircuit(1) + body.ry(global_parameter, 0) + circuit = QuantumCircuit(1) + with pytest.warns(UserWarning, match="loop_parameter was not found"): + circuit.for_loop(range(2), loop_parameter, body, [0], [], label=None) + source_data = list(circuit.data) + + program = QCProgram.from_qiskit(circuit) + + input_match = re.search(r"func\.func @main\((%[^: ]+): f64 \{[^}]*mqt\.input_name = \"theta\"[^}]*\}", program.ir) + assert input_match is not None + assert f"qc.ry({input_match.group(1)})" in program.ir + assert list(circuit.data) == source_data + assert circuit.parameters == {global_parameter} + + +def test_conflicting_free_parameter_uuid_alias_fails_without_mutation() -> None: + """Reject differently named free symbols that share one stable identity.""" + identity = uuid4() + canonical = Parameter("canonical", uuid=identity) + alias = Parameter("alias", uuid=identity) + circuit = QuantumCircuit(1) + circuit.rx(canonical, 0) + circuit.rz(alias, 0) + source_data = list(circuit.data) + + with pytest.raises( + RuntimeError, + match="parameter symbol 'alias' aliases free symbol 'canonical' with inconsistent metadata", + ): + QCProgram.from_qiskit(circuit) + + assert list(circuit.data) == source_data + assert circuit.parameters == {canonical} + + +def test_conflicting_local_parameter_uuid_alias_fails_without_mutation() -> None: + """Resolve a shared identity against the active lexical loop binding.""" + identity = uuid4() + global_parameter = Parameter("global", uuid=identity) + loop_parameter = Parameter("local", uuid=identity) + body = QuantumCircuit(1) + body.ry(global_parameter, 0) + circuit = QuantumCircuit(1) + circuit.for_loop(range(2), loop_parameter, body, [0], [], label=None) + source_data = list(circuit.data) + + with pytest.raises( + RuntimeError, + match="parameter symbol 'global' aliases local symbol 'local' with inconsistent metadata", + ): + QCProgram.from_qiskit(circuit) + + assert list(circuit.data) == source_data + assert circuit.parameters == {global_parameter} + + +def test_duplicate_named_symbolic_inputs_fail_closed_without_mutation() -> None: + """Reject ambiguous Qiskit parameter names before changing the source IR.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main( + %first: f64 {mqt.input_name = "theta"}, + %second: f64 {mqt.input_name = "theta"} + ) attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + qc.rx(%first) %q : !qc.qubit + qc.rz(%second) %q : !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + source_ir = program.ir + + with pytest.raises(RuntimeError, match="requires unique parameter names"): + program.to_qiskit() + + assert program.ir == source_ir + + +def test_parameter_names_with_null_characters_fail_closed() -> None: + """Reject names that the Qiskit C API would silently truncate.""" + parameter = Parameter("before\0after") + circuit = QuantumCircuit(1) + circuit.rz(parameter, 0) + source_data = list(circuit.data) + + with pytest.raises(RuntimeError, match="names cannot contain null characters"): + QCProgram.from_qiskit(circuit) + + assert list(circuit.data) == source_data + + program = QCProgram.from_mlir_str( + r"""module { + func.func @main(%theta: f64 {mqt.input_name = "before\00after"}) attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + qc.rz(%theta) %q : !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + source_ir = program.ir + + with pytest.raises(RuntimeError, match="names with null characters"): + program.to_qiskit() + + assert program.ir == source_ir + + +def test_named_symbolic_input_exports_to_qiskit() -> None: + """Reconstruct a direct Qiskit parameter from a named f64 input.""" symbolic = QCProgram.from_mlir_str( """module { + func.func @main(%theta: f64 {mqt.input_name = "theta"}) attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + qc.rx(%theta) %q : !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + restored = symbolic.to_qiskit() + + assert [parameter.name for parameter in restored.parameters] == ["theta"] + assert restored.data[0].operation.params[0] == next(iter(restored.parameters)) + + +def test_unused_named_symbolic_input_fails_export_without_mutation() -> None: + """Reject a compiler input that would disappear from the Qiskit circuit.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main(%theta: f64 {mqt.input_name = "theta"}) attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + qc.x %q : !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + source_ir = program.ir + + with pytest.raises(RuntimeError, match="cannot preserve unused named f64 program input 'theta'"): + program.to_qiskit() + + assert program.ir == source_ir + + +def test_unnamed_runtime_input_is_rejected_on_export() -> None: + """Do not infer source semantics for arbitrary runtime inputs.""" + runtime = QCProgram.from_mlir_str( + """module { func.func @main(%theta: f64) attributes {passthrough = ["entry_point"]} { %q = qc.alloc : !qc.qubit qc.rx(%theta) %q : !qc.qubit @@ -981,8 +1524,30 @@ def test_flat_export_rejects_symbolic_inputs() -> None: } """ ) - with pytest.raises(RuntimeError, match="symbolic or runtime inputs"): - symbolic.to_qiskit() + + with pytest.raises(RuntimeError, match="requires named f64 program inputs"): + runtime.to_qiskit() + + +def test_named_non_f64_runtime_input_is_rejected_on_export() -> None: + """Reject a named compiler input whose type cannot represent a parameter.""" + runtime = QCProgram.from_mlir_str( + """module { + func.func @main(%count: i64 {mqt.input_name = "count"}) attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + qc.x %q : !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + source_ir = runtime.ir + + with pytest.raises(RuntimeError, match="requires named f64 program inputs"): + runtime.to_qiskit() + + assert runtime.ir == source_ir def test_target_aware_qiskit_export_maps_sparse_site_ids() -> None: From ee6933dec7e7ec9a27ecf831bc2ab758804cded6 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Wed, 19 Aug 2026 17:05:49 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20Import=20captured=20Qiskit=20ex?= =?UTF-8?q?pressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- .../qiskit-classical-expression-captures.md | 304 +++++++++++++ bindings/mlir/qiskit/Qiskit2_5.cpp | 423 ++++++++++++++++-- bindings/mlir/qiskit/QiskitImport.cpp | 92 +++- bindings/mlir/qiskit/QiskitTranslation.h | 4 + docs/mlir/python_compiler_collection.md | 8 +- test/python/test_mlir_qiskit_translation.py | 163 +++++++ 6 files changed, 945 insertions(+), 49 deletions(-) create mode 100644 .agent/plans/qiskit-classical-expression-captures.md diff --git a/.agent/plans/qiskit-classical-expression-captures.md b/.agent/plans/qiskit-classical-expression-captures.md new file mode 100644 index 0000000000..5d7a088a2e --- /dev/null +++ b/.agent/plans/qiskit-classical-expression-captures.md @@ -0,0 +1,304 @@ +# Import Qiskit classical-expression captures + +This ExecPlan is a living document. The sections `Progress`, +`Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must +be kept up to date as work proceeds. + +This ExecPlan must be maintained in accordance with `.agent/PLANS.md` from the +repository root. + +## Purpose / Big Picture + +Qiskit 2.5 control-flow expressions can read a `Clbit` or `ClassicalRegister` +from the containing circuit. These values can be block captures, but a condition +or switch target can also be their only use. The current importer handles +literal expression trees but rejects these variable leaves. After this change, +`QCProgram.from_qiskit` can import Boolean and unsigned-integer conditions that +read classical bits and registers, including nested control flow and +expression-valued switch targets. The imported MLIR reads the existing +first-class CBit registers, so each expression refers to the same classical +state as the source circuit. + +This plan covers import only. It does not add Qiskit writer APIs or construct +Qiskit control-flow operations during export. + +## Progress + +- [x] (2026-08-19 14:46Z) Read the repository instructions and compare the + current scalar/CBit branch with the earlier full control-flow + implementation. +- [x] (2026-08-19 14:52Z) Extend the normalized expression model with captured + bit and register leaves without changing the scalar `Parameter` model. +- [x] (2026-08-19 14:53Z) Normalize Qiskit expression variables through public + Python bit identity and Qiskit's native local-to-root Clbit maps. +- [x] (2026-08-19 14:54Z) Materialize and validate captured leaves through the + existing CBit load and register-packing helpers. +- [x] (2026-08-19 14:56Z) Add focused bit, register, nested-capture, malformed + capture, and switch-expression import tests. +- [x] (2026-08-19 15:03Z) Build, run the full Qiskit translation test file and + repository lint session, inspect the final diff, and prepare the completed + import slice for a local commit. +- [x] (2026-08-19 15:10Z) Reproduce the valid explicit-body case in which a + condition reads a root Clbit absent from all block operands. +- [x] (2026-08-19 15:19Z) Retain the Python circuit hierarchy, add a + containing-circuit resolver with parent-map composition, add focused if, + switch, and nested-map regressions, rebuild, and pass all eight focused + capture tests. +- [x] (2026-08-19 15:23Z) Pass all 165 Qiskit translation tests and the complete + repository lint session, inspect the final diff, and prepare the existing + local import commit for amendment. +- [x] (2026-08-19 16:12Z) Reproduce a nested legacy tuple condition that reads + root Clbit one as local index zero, route it through the public Python bit + resolver, add the exact `for`-then-`if` regression, rebuild, and pass all + nine focused capture and condition tests. +- [x] (2026-08-19 16:14Z) Pass all 166 Qiskit translation tests, rerun the + complete repository lint session, inspect the final diff, and prepare the + existing local import commit for amendment. +- [x] (2026-08-19 19:45Z) Rebase the focused import commit onto the scalar + commit after first-class CBit support merged, rebuild the release MLIR + binding, and pass all 166 Qiskit translation tests again. +- [x] (2026-08-19 20:08Z) Restack onto the audited scalar parent, update the + recorded parent identity, rebuild the release binding, and pass all 167 + Qiskit translation tests. + +## Surprises & Discoveries + +- Observation: The current branch already contains structured-control import, + CBit register storage, and the scalar symbolic `Parameter` tree. The older + full implementation therefore cannot be cherry-picked safely. Evidence: + `QiskitImport.cpp` already emits `scf.if`, `scf.while`, and + `scf.index_switch`, while the parent scalar-symbol commit adds the independent + parameter work. + +- Observation: Qiskit 2.5 native switch-target accessors are not safe for an + expression-valued target. The public Python `SwitchCaseOp.target` expression + tree must be used for that case. Evidence: the earlier implementation records + that the native C accessors abort when the target is an expression. + +- Observation: A full test run must inject the worktree-built extension into + child Python processes as well as the pytest process. Evidence: one existing + isolation test launches `sys.executable`; after using a temporary + `sitecustomize.py`, all 162 tests exercised the local binding and passed. The + temporary harness was removed after validation. + +- Observation: `CircuitInstruction.clbits` contains the bits passed to the + control-flow blocks, not every bit read by the condition or switch target. An + explicit body can have zero classical operands while its expression reads a + Clbit from the containing circuit. Evidence: an explicit `if_test` with an + empty `clbits` argument is valid Qiskit, but the initial resolver rejected it + because both the instruction and its block had zero Clbits. + +- Observation: A nested expression bit must first be resolved in its containing + Python circuit. A lookup in the root Python circuit can confuse equal Clbit + objects from similar local registers. Evidence: the nested regression maps + local Clbit zero to root Clbit one and observes a load from root index one. + +- Observation: Qiskit's native legacy Clbit-condition accessor returns an index + in the containing nested circuit. Using that number as a root index reads the + wrong CBit register element. Evidence: a tuple condition on root Clbit one + inside a context-managed `for` loop initially emitted `cbit.load` at index + zero; resolving the Python condition bit through the enclosing map emits index + one. + +## Decision Log + +- Decision: Add `ClassicalBit` and `ClassicalRegister` to `ExpressionKind`, with + a global bit index or a normalized register payload on `Expression`. + Rationale: The normalized tree then owns stable capture identity and stays + independent of Python object lifetimes. Date/Author: 2026-08-19 / Codex. + +- Decision: Keep `ParameterKind`, `Parameter`, and `Loop::parameter` unchanged. + Rationale: Scalar symbols and classical captures have different identity and + typing rules. This branch must remain composable with the reviewed scalar + slice. Date/Author: 2026-08-19 / Codex. + +- Decision: Retain the full Python `CircuitInstruction`, the containing Python + circuit, and the root Python circuit in `NativeControlFlowReader`. Resolve a + classical bit in the containing circuit and compose its local index with the + enclosing native capture map when the circuit is nested. Use the current + native block map only to validate the instruction structure. Apply this rule + to expression leaves, switch targets, and legacy tuple conditions. Rationale: + `CircuitInstruction.clbits` describes block operands only, native condition + indices can remain local, and direct root lookup is ambiguous for nested local + registers. Date/Author: 2026-08-19 / Codex. + +- Decision: Parse expression-valued switch targets from the public Python + expression tree. Continue to use native metadata for cases and block maps. + Rationale: This avoids the unsafe Qiskit 2.5 native accessor while keeping the + established native control-flow reader for supported metadata. Date/Author: + 2026-08-19 / Codex. + +- Decision: Document circuit Clbit and ClassicalRegister expression variables + separately from standalone runtime variables. Rationale: circuit-owned bits + resolve to existing CBit state whether or not a block captures them; the + importer still rejects Qiskit runtime inputs, and export remains outside this + slice. Date/Author: 2026-08-19 / Codex. + +## Outcomes & Retrospective + +The import slice now preserves Clbit and ClassicalRegister identity through the +containing Python circuit and Qiskit's native root maps. It lowers variable +leaves through the existing CBit load and little-endian register pack paths, +preflights malformed captures, and reads expression-valued switch targets only +through the public Python expression tree. Conditions and switch targets also +work when their classical bits are absent from every block operand. The public +support table distinguishes these supported circuit values from rejected +standalone runtime inputs. + +The release MLIR binding built successfully. The complete Qiskit translation +test file passed with 167 tests against that local extension, including the +subprocess isolation test, the condition-only regressions, and the nested legacy +Clbit condition. `uvx nox -s lint`, `git diff --check`, Clang format, Ruff, +Rumdl, Prettier, and `ty` all passed. Export-side writer construction remains +deliberately out of scope. + +## Context and Orientation + +`bindings/mlir/qiskit/QiskitTranslation.h` contains version-neutral normalized +data passed between the Qiskit version adapter and the MLIR importer. +`bindings/mlir/qiskit/Qiskit2_5.cpp` reads Qiskit 2.5 through its native C API +and selected public Python objects. `NativeControlFlowReader` supplies one +normalized `ClassicalTarget` for an if, while, or switch operation. +`bindings/mlir/qiskit/QiskitImport.cpp` lowers that target to MLIR. It already +stores classical state in `!cbit.reg` values and provides `loadClassicalBit` +and `packRegister` helpers. + +A block capture is a Clbit used by a control-flow block that comes from its +enclosing circuit. Qiskit exposes Python objects in `CircuitInstruction.clbits` +in block-capture order. Its native control-flow object exposes a map from that +local order to root-circuit Clbit indices. A condition or switch target can also +read a bit that no block uses. The importer therefore retains the containing +Python circuit to find the local bit and uses the enclosing native map to reach +its root index when the circuit is nested. The retained root Python circuit owns +the complete object hierarchy while the reader traverses nested blocks. + +The current scalar `Parameter` tree represents numeric gate and loop +expressions. It is unrelated to Qiskit's typed classical-expression tree and +must not be refactored in this task. + +## Plan of Work + +First, extend `ExpressionKind` and `Expression` in +`bindings/mlir/qiskit/QiskitTranslation.h` with bit and register leaves. Keep +all scalar parameter declarations byte-for-byte unchanged. + +Next, update `bindings/mlir/qiskit/Qiskit2_5.cpp`. Make the native expression +normalizer walk the matching public Python expression node beside each native +node. Resolve a `Var` leaf by inspecting its public `var` object. For a Clbit, +find the bit in the containing Python circuit and compose the local index +through the enclosing native capture map. Use the same resolver for a legacy +tuple condition's Clbit instead of trusting its native local index. For a +classical register, apply the same mapping to each member in register order. +Reject malformed captures, duplicate or invalid types, standalone variables, and +widths outside the existing 64-bit limit. Keep a Python-only expression walker +for switch targets so no unsafe native switch-expression accessor is called. + +Then update `bindings/mlir/qiskit/QiskitImport.cpp`. Pass callbacks into the +recursive expression emitter. A bit leaf calls `loadClassicalBit`; a register +leaf calls `packRegister` and extends it to the normalized expression width. +Extend preflight validation to check leaf types, bit bounds, register size, +unique register bits, and expression widths before MLIR construction begins. + +Finally, add tests to `test/python/test_mlir_qiskit_translation.py`. Cover one +captured Clbit expression, one captured register expression, nested control flow +whose inner expression uses outer captures, and an expression-valued switch +target. Also cover explicit if and switch bodies whose expression bits are +absent from every block operand, plus a nested permutation that proves +parent-map composition. Verify the expected CBit loads and +arithmetic/control-flow ops, and re-import the produced program or source +circuit where export is outside this slice. + +## Concrete Steps + +Run all commands from the repository root. + +Inspect the focused diff and formatting: + + git diff --check + clang-format --dry-run --Werror bindings/mlir/qiskit/Qiskit2_5.cpp \ + bindings/mlir/qiskit/QiskitImport.cpp \ + bindings/mlir/qiskit/QiskitTranslation.h + uvx ruff check test/python/test_mlir_qiskit_translation.py + +Build the Qiskit binding with the configured release tree. If the isolated +worktree has no compatible build tree yet, configure it with the repository's +release preset first: + + cmake --build build/release --parallel 8 + +Run the focused tests: + + uv run --no-sync pytest test/python/test_mlir_qiskit_translation.py \ + -k 'classical_expression or condition_only or switch_expression' + +Run the complete Qiskit translation test file after the focused tests pass: + + uv run --no-sync pytest test/python/test_mlir_qiskit_translation.py + +Run the repository lint session before handoff: + + uvx nox -s lint + +## Validation and Acceptance + +Acceptance requires that a Qiskit if or while condition containing +`expr.lift(circuit.clbits[i])` imports to an MLIR `cbit.load` from the matching +register element. A register expression must load and pack its members in +Qiskit's little-endian register order. An inner control-flow instruction must +resolve its own `CircuitInstruction.clbits` capture order and reach the same +root CBit elements. An expression-valued Qiskit switch must import without +calling a native switch-expression target accessor and must produce +`scf.index_switch`. Explicit if and switch bodies with empty classical operand +lists must still read condition-only and target-only bits from the containing +circuit. A nested condition-only bit must follow the enclosing block's +local-to-root permutation. A nested legacy tuple condition on root Clbit one +must emit a `cbit.load` at index one even when that bit is local index zero in +the enclosing block. + +Malformed block-capture lists and variables absent from the containing circuit +must fail during validation with a clear runtime error. Existing literal +expression, structured-control, CBit, and symbolic parameter tests must continue +to pass. The final tree must have no exporter or writer control-flow +construction changes. + +## Idempotence and Recovery + +All build, format-check, and test commands are repeatable. Source changes are +limited to the version-neutral normalized model, the Qiskit 2.5 reader, the MLIR +importer, one Python test file, and this plan. Do not reset or overwrite +unrelated work. If a test exposes a Qiskit API difference, inspect the installed +2.5 objects from the test environment and adjust only the version-specific +reader. Do not add a private exporter fallback. + +## Artifacts and Notes + +The source branch begins at the focused scalar-symbol parent, which already +includes CBit and symbolic scalar support. Native expression nodes do not carry +sufficient public Clbit identity by themselves. `CircuitInstruction.clbits` +supplies identity for block operands, while the containing Python circuit +supplies identity for bits used only by a condition or switch target. + +## Interfaces and Dependencies + +At completion, `ExpressionKind` in `bindings/mlir/qiskit/QiskitTranslation.h` +has `ClassicalBit` and `ClassicalRegister` cases. `Expression` has +`uint32_t bit` and `Register reg` payloads. `NativeControlFlowReader` in +`Qiskit2_5.cpp` owns the full Python instruction, its operation, its containing +circuit, and the root Python circuit. Its expression normalization resolves all +classical leaves and legacy Clbit conditions to root-circuit indices through the +containing-circuit and parent-map path. `QiskitImport.cpp` accepts expression +leaves only through callbacks backed by `loadClassicalBit` and `packRegister`. + +This work depends only on Qiskit 2.5's existing native extension table, +nanobind's public Python object access, MLIR's arithmetic and structured-control +dialects, and MQT Core's CBit builder methods. It introduces no new dependency. + +Revision note: Created the initial self-contained plan after comparing the +current scalar/CBit branch with the earlier combined implementation. Updated it +after implementation and final validation to record the public documentation +decision, subprocess-aware test setup, and successful results. Updated it again +after the final audit found valid condition-only and target-only bits outside +the block-capture list; the plan now records the containing-circuit resolver and +nested parent-map regression. Updated it once more after the nested legacy +Clbit-condition accessor exposed its containing-circuit index rather than a root +index. diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index f92a74b5f0..599b4bfb2d 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -677,8 +677,10 @@ normalizeBinaryOperation(const QkBinaryOpType op) { "Qiskit returned an unknown unary expression operation"); } -[[nodiscard]] std::unique_ptr -normalizeExpression(const QkExprNode* expression, const size_t depth = 0U) { +template +[[nodiscard]] std::unique_ptr normalizeExpression( + const QkExprNode* expression, const nb::handle pythonExpression, + NormalizeVariable& normalizeVariable, const size_t depth = 0U) { if (expression == nullptr) { throw std::runtime_error("Qiskit returned a null classical expression"); } @@ -693,8 +695,16 @@ normalizeExpression(const QkExprNode* expression, const size_t depth = 0U) { result->kind = ExpressionKind::Binary; result->binaryOperation = normalizeBinaryOperation(info.op); setType(*result, info.ty); - result->left = normalizeExpression(info.left, depth + 1U); - result->right = normalizeExpression(info.right, depth + 1U); + result->left = normalizeExpression( + info.left, + pythonAttribute(pythonExpression, "left", + "Qiskit binary expression has no left operand"), + normalizeVariable, depth + 1U); + result->right = normalizeExpression( + info.right, + pythonAttribute(pythonExpression, "right", + "Qiskit binary expression has no right operand"), + normalizeVariable, depth + 1U); return result; } case QkExprNodeKind_Unary: { @@ -702,22 +712,38 @@ normalizeExpression(const QkExprNode* expression, const size_t depth = 0U) { result->kind = ExpressionKind::Unary; result->unaryOperation = normalizeUnaryOperation(info.op); setType(*result, info.ty); - result->left = normalizeExpression(info.operand, depth + 1U); + result->left = normalizeExpression( + info.operand, + pythonAttribute(pythonExpression, "operand", + "Qiskit unary expression has no operand"), + normalizeVariable, depth + 1U); return result; } case QkExprNodeKind_Cast: { const auto info = qk_expr_cast_info(expression); result->kind = ExpressionKind::Cast; setType(*result, info.ty); - result->left = normalizeExpression(info.operand, depth + 1U); + result->left = normalizeExpression( + info.operand, + pythonAttribute(pythonExpression, "operand", + "Qiskit cast expression has no operand"), + normalizeVariable, depth + 1U); return result; } case QkExprNodeKind_Index: { const auto info = qk_expr_index_info(expression); result->kind = ExpressionKind::Index; setType(*result, info.ty); - result->left = normalizeExpression(info.target, depth + 1U); - result->right = normalizeExpression(info.index, depth + 1U); + result->left = normalizeExpression( + info.target, + pythonAttribute(pythonExpression, "target", + "Qiskit index expression has no target"), + normalizeVariable, depth + 1U); + result->right = normalizeExpression( + info.index, + pythonAttribute(pythonExpression, "index", + "Qiskit index expression has no index"), + normalizeVariable, depth + 1U); return result; } case QkExprNodeKind_Value: { @@ -743,9 +769,9 @@ normalizeExpression(const QkExprNode* expression, const size_t depth = 0U) { return result; } case QkExprNodeKind_Var: - throw std::runtime_error( - "Qiskit circuit import does not support variables in classical " - "expressions"); + setType(*result, qk_var_type_info(qk_expr_as_var(expression))); + normalizeVariable(*result, pythonExpression); + return result; case QkExprNodeKind_Stretch: throw std::runtime_error( "Qiskit circuit import does not support stretch expressions"); @@ -915,6 +941,7 @@ class NativeCircuitReader final : public CircuitReader { data_(pythonAttribute( circuit, "_data", "expected a Qiskit QuantumCircuit with native CircuitData")), + rootPythonCircuit_(pythonCircuit_), circuit_(qk_circuit_borrow_from_python(data_.ptr())) { if (circuit_ == nullptr) { throwPythonError("Qiskit rejected QuantumCircuit._data"); @@ -924,12 +951,14 @@ class NativeCircuitReader final : public CircuitReader { NativeCircuitReader(nb::object pythonCircuit, const QkCircuit* circuit, const QkCircuit* rootCircuit, + nb::object rootPythonCircuit, const QkControlFlowInstruction* parent) : pythonCircuit_(std::move(pythonCircuit)), data_(pythonAttribute( pythonCircuit_, "_data", "Qiskit control-flow block has no native CircuitData")), - circuit_(circuit), rootCircuit_(rootCircuit), parent_(parent) {} + rootPythonCircuit_(std::move(rootPythonCircuit)), circuit_(circuit), + rootCircuit_(rootCircuit), parent_(parent) {} [[nodiscard]] uint32_t numQubits() const override { return qk_circuit_num_qubits(circuit_); @@ -1222,6 +1251,7 @@ class NativeCircuitReader final : public CircuitReader { nb::object pythonCircuit_; nb::object data_; + nb::object rootPythonCircuit_; const QkCircuit* circuit_ = nullptr; const QkCircuit* rootCircuit_ = circuit_; const QkControlFlowInstruction* parent_ = nullptr; @@ -1232,11 +1262,18 @@ class NativeControlFlowReader final : public ControlFlowReader { NativeControlFlowReader(const QkCircuit* rootCircuit, const QkCircuit* circuit, const size_t index, const QkControlFlowInstruction* parent, - nb::object operation) - : rootCircuit_(rootCircuit), + nb::object instruction, + nb::object containingPythonCircuit, + nb::object rootPythonCircuit) + : rootCircuit_(rootCircuit), circuit_(circuit), parent_(parent), controlFlow_( qk_circuit_get_control_flow_instruction(circuit, index, parent)), - operation_(std::move(operation)) { + instruction_(std::move(instruction)), + operation_(pythonAttribute( + instruction_, "operation", + "Qiskit circuit instruction has no control-flow operation")), + containingPythonCircuit_(std::move(containingPythonCircuit)), + rootPythonCircuit_(std::move(rootPythonCircuit)) { if (controlFlow_ == nullptr) { throwPythonError("Qiskit failed to inspect a control-flow instruction"); } @@ -1281,7 +1318,7 @@ class NativeControlFlowReader final : public ControlFlowReader { const auto block = nb::borrow(blocks[index]); return std::make_unique( block, qk_control_flow_block_circuit(controlFlow_, index), rootCircuit_, - controlFlow_); + rootPythonCircuit_, controlFlow_); } [[nodiscard]] std::vector qubitMap() const override { @@ -1317,8 +1354,14 @@ class NativeControlFlowReader final : public ControlFlowReader { switch (qk_control_flow_condition_type(controlFlow_)) { case QkConditionType_ClBit: { const auto bit = qk_control_flow_condition_bit_info(controlFlow_); + const auto condition = pythonAttribute( + operation_, "condition", "Qiskit control flow has no condition"); + if (nb::len(condition) != 2U) { + throw std::runtime_error( + "Qiskit classical-bit condition has an invalid shape"); + } result.kind = ClassicalTargetKind::ClassicalBit; - result.bit = static_cast(bit.clbit); + result.bit = rootClbitIndex(condition[0]); result.expectedBit = bit.condition; return result; } @@ -1344,8 +1387,10 @@ class NativeControlFlowReader final : public ControlFlowReader { } case QkConditionType_Expr: result.kind = ClassicalTargetKind::Expression; - result.expression = - normalizeExpression(qk_control_flow_condition_expr(controlFlow_)); + result.expression = normalizePythonExpression( + qk_control_flow_condition_expr(controlFlow_), + pythonAttribute(operation_, "condition", + "Qiskit control flow has no condition")); return result; } throw std::runtime_error("Qiskit returned an unknown condition type"); @@ -1418,28 +1463,38 @@ class NativeControlFlowReader final : public ControlFlowReader { [[nodiscard]] ClassicalTarget switchTarget() const override { ClassicalTarget result; - switch (qk_control_flow_switch_target_type(controlFlow_)) { - case QkConditionType_ClBit: + const auto target = + pythonAttribute(operation_, "target", "Qiskit switch has no target"); + const auto circuitModule = nb::module_::import_("qiskit.circuit"); + if (nb::isinstance(target, circuitModule.attr("Clbit"))) { result.kind = ClassicalTargetKind::ClassicalBit; - result.bit = qk_control_flow_switch_target_bit(controlFlow_); + result.bit = rootClbitIndex(target); return result; - case QkConditionType_ClReg: + } + if (nb::isinstance(target, circuitModule.attr("ClassicalRegister"))) { result.kind = ClassicalTargetKind::ClassicalRegister; - result.reg = normalizeRegister( - qk_control_flow_switch_target_register(controlFlow_), rootCircuit_); - if (result.reg.bits.empty() || result.reg.bits.size() > 64U) { + result.reg.name = pythonStringAttribute( + target, "name", "Qiskit switch register has no name"); + if (nb::len(target) == 0U || nb::len(target) > 64U) { throw std::runtime_error( "Qiskit switch registers must contain between 1 and 64 bits"); } + result.reg.bits.reserve(nb::len(target)); + for (const nb::handle bit : nb::iter(target)) { + result.reg.bits.push_back(rootClbitIndex(bit)); + } result.width = static_cast(result.reg.bits.size()); return result; - case QkConditionType_Expr: + } + const auto expressionModule = + nb::module_::import_("qiskit.circuit.classical.expr"); + if (nb::isinstance(target, expressionModule.attr("Expr"))) { result.kind = ClassicalTargetKind::Expression; - result.expression = - normalizeExpression(qk_control_flow_switch_target_expr(controlFlow_)); + // Qiskit 2.5's native switch-target accessors abort for expressions. + result.expression = normalizePythonExpressionOnly(target); return result; } - throw std::runtime_error("Qiskit returned an unknown switch-target type"); + throw std::runtime_error("Qiskit switch has an unknown target type"); } [[nodiscard]] std::vector switchCases() const override { @@ -1467,15 +1522,321 @@ class NativeControlFlowReader final : public ControlFlowReader { } private: + [[nodiscard]] uint32_t rootClbitIndex(const nb::handle bit) const { + const auto clbits = pythonAttribute( + instruction_, "clbits", + "Qiskit control-flow instruction has no classical-bit operands"); + if (numBlocks() == 0U || + nb::len(clbits) != qk_circuit_num_clbits(qk_control_flow_block_circuit( + controlFlow_, 0U))) { + throw std::runtime_error( + "Qiskit control flow has incompatible classical-bit captures"); + } + const auto* const map = qk_control_flow_clbit_map(controlFlow_); + if (map == nullptr && nb::len(clbits) != 0U) { + throw std::runtime_error( + "Qiskit control flow has no classical-bit capture map"); + } + // Conditions and switch targets refer to bits in the containing circuit. + // The current block-operand map is not an identity source: a bit can be + // absent from all blocks, and a nested map can still use a local index. + // Resolve the Python bit in the containing circuit, then use the enclosing + // control flow's native map when that circuit is itself a nested block. + try { + const auto findBit = pythonAttribute( + containingPythonCircuit_, "find_bit", + "Qiskit containing circuit cannot resolve expression variables"); + const auto location = findBit(bit); + const auto localIndex = pythonUnsignedAttribute( + location, "index", + "Qiskit expression variable has an invalid circuit index"); + if (localIndex >= qk_circuit_num_clbits(circuit_)) { + throw std::runtime_error( + "Qiskit expression variable has an invalid circuit index"); + } + if (parent_ == nullptr) { + return static_cast(localIndex); + } + + const auto* const parentMap = qk_control_flow_clbit_map(parent_); + if (parentMap == nullptr) { + throw std::runtime_error( + "Qiskit enclosing control flow has no classical-bit capture map"); + } + return parentMap[localIndex]; + } catch (const nb::python_error& error) { + throwPythonError( + "Qiskit expression variable is absent from its containing circuit", + error); + } + } + + static void setPythonExpressionType(Expression& result, + const nb::handle pythonExpression) { + const auto type = pythonAttribute(pythonExpression, "type", + "Qiskit expression has no type"); + const auto typeName = pythonStringAttribute( + pythonAttribute(type, "__class__", + "Qiskit expression type has no Python class"), + "__name__", "Qiskit expression type has no class name"); + if (typeName == "Bool") { + result.type = ClassicalType::Bool; + result.width = 1U; + return; + } + if (typeName == "Uint") { + const auto width = pythonUnsignedAttribute( + type, "width", "Qiskit Uint expression has no width"); + if (width == 0U || width > 64U) { + throw std::runtime_error( + "Qiskit unsigned classical values must be between 1 and 64 bits"); + } + result.type = ClassicalType::Uint; + result.width = static_cast(width); + return; + } + if (typeName == "Float") { + result.type = ClassicalType::Float; + result.width = 64U; + return; + } + if (typeName == "Duration") { + throw std::runtime_error( + "Qiskit circuit import does not support duration expressions"); + } + throw std::runtime_error("Qiskit expression has an unknown Python type"); + } + + [[nodiscard]] static BinaryOperation + pythonBinaryOperation(const std::string_view name) { + if (name == "BIT_AND") { + return BinaryOperation::BitAnd; + } + if (name == "BIT_OR") { + return BinaryOperation::BitOr; + } + if (name == "BIT_XOR") { + return BinaryOperation::BitXor; + } + if (name == "LOGIC_AND") { + return BinaryOperation::LogicAnd; + } + if (name == "LOGIC_OR") { + return BinaryOperation::LogicOr; + } + if (name == "EQUAL") { + return BinaryOperation::Equal; + } + if (name == "NOT_EQUAL") { + return BinaryOperation::NotEqual; + } + if (name == "LESS") { + return BinaryOperation::Less; + } + if (name == "LESS_EQUAL") { + return BinaryOperation::LessEqual; + } + if (name == "GREATER") { + return BinaryOperation::Greater; + } + if (name == "GREATER_EQUAL") { + return BinaryOperation::GreaterEqual; + } + if (name == "SHIFT_LEFT") { + return BinaryOperation::ShiftLeft; + } + if (name == "SHIFT_RIGHT") { + return BinaryOperation::ShiftRight; + } + if (name == "ADD") { + return BinaryOperation::Add; + } + if (name == "SUB") { + return BinaryOperation::Subtract; + } + if (name == "MUL") { + return BinaryOperation::Multiply; + } + if (name == "DIV") { + return BinaryOperation::Divide; + } + throw std::runtime_error( + "Qiskit expression has an unknown Python binary operation"); + } + + [[nodiscard]] static UnaryOperation + pythonUnaryOperation(const std::string_view name) { + if (name == "BIT_NOT") { + return UnaryOperation::BitNot; + } + if (name == "LOGIC_NOT") { + return UnaryOperation::LogicNot; + } + if (name == "NEGATE") { + return UnaryOperation::Negate; + } + throw std::runtime_error( + "Qiskit expression has an unknown Python unary operation"); + } + + [[nodiscard]] std::unique_ptr + normalizePythonExpressionOnly(const nb::handle pythonExpression, + const size_t depth = 0U) const { + if (depth >= MAX_EXPRESSION_DEPTH) { + throw std::runtime_error( + "Qiskit classical expressions exceed the nesting limit of 64"); + } + auto result = std::make_unique(); + setPythonExpressionType(*result, pythonExpression); + const auto className = pythonStringAttribute( + pythonAttribute(pythonExpression, "__class__", + "Qiskit expression has no Python class"), + "__name__", "Qiskit expression has no class name"); + if (className == "Var") { + normalizePythonVariable(*result, pythonExpression); + return result; + } + if (className == "Value") { + result->kind = ExpressionKind::Value; + const auto value = pythonAttribute( + pythonExpression, "value", "Qiskit literal expression has no value"); + switch (result->type) { + case ClassicalType::Bool: + if (!nb::try_cast(value, result->boolValue)) { + throw std::runtime_error( + "Qiskit Boolean expression has an invalid value"); + } + break; + case ClassicalType::Uint: + if (!nb::try_cast(value, result->uintValue)) { + throw std::runtime_error( + "Qiskit Uint expression has an invalid value"); + } + break; + case ClassicalType::Float: + if (!nb::try_cast(value, result->floatValue) || + !std::isfinite(result->floatValue)) { + throw std::runtime_error( + "Qiskit Float expression has an invalid value"); + } + break; + } + return result; + } + if (className == "Unary") { + result->kind = ExpressionKind::Unary; + result->unaryOperation = pythonUnaryOperation(pythonStringAttribute( + pythonAttribute(pythonExpression, "op", + "Qiskit unary expression has no operation"), + "name", "Qiskit unary expression operation has no name")); + result->left = normalizePythonExpressionOnly( + pythonAttribute(pythonExpression, "operand", + "Qiskit unary expression has no operand"), + depth + 1U); + return result; + } + if (className == "Binary") { + result->kind = ExpressionKind::Binary; + result->binaryOperation = pythonBinaryOperation(pythonStringAttribute( + pythonAttribute(pythonExpression, "op", + "Qiskit binary expression has no operation"), + "name", "Qiskit binary expression operation has no name")); + result->left = normalizePythonExpressionOnly( + pythonAttribute(pythonExpression, "left", + "Qiskit binary expression has no left operand"), + depth + 1U); + result->right = normalizePythonExpressionOnly( + pythonAttribute(pythonExpression, "right", + "Qiskit binary expression has no right operand"), + depth + 1U); + return result; + } + if (className == "Cast") { + result->kind = ExpressionKind::Cast; + result->left = normalizePythonExpressionOnly( + pythonAttribute(pythonExpression, "operand", + "Qiskit cast expression has no operand"), + depth + 1U); + return result; + } + if (className == "Index") { + result->kind = ExpressionKind::Index; + result->left = normalizePythonExpressionOnly( + pythonAttribute(pythonExpression, "target", + "Qiskit index expression has no target"), + depth + 1U); + result->right = normalizePythonExpressionOnly( + pythonAttribute(pythonExpression, "index", + "Qiskit index expression has no index"), + depth + 1U); + return result; + } + if (className == "Stretch") { + throw std::runtime_error( + "Qiskit circuit import does not support stretch expressions"); + } + throw std::runtime_error("Qiskit expression has an unknown Python node"); + } + + void normalizePythonVariable(Expression& result, + const nb::handle pythonExpression) const { + const auto variable = pythonAttribute( + pythonExpression, "var", "Qiskit variable expression has no value"); + const auto circuitModule = nb::module_::import_("qiskit.circuit"); + if (nb::isinstance(variable, circuitModule.attr("Clbit"))) { + if (result.type != ClassicalType::Bool || result.width != 1U) { + throw std::runtime_error( + "Qiskit classical-bit variable must have Boolean type"); + } + result.kind = ExpressionKind::ClassicalBit; + result.bit = rootClbitIndex(variable); + return; + } + if (nb::isinstance(variable, circuitModule.attr("ClassicalRegister"))) { + if (result.type != ClassicalType::Uint || nb::len(variable) == 0U || + nb::len(variable) > 64U || result.width < nb::len(variable)) { + throw std::runtime_error( + "Qiskit classical-register variable has an invalid type"); + } + result.kind = ExpressionKind::ClassicalRegister; + result.reg.name = pythonStringAttribute( + variable, "name", "Qiskit classical register has no name"); + result.reg.bits.reserve(nb::len(variable)); + for (const nb::handle bit : nb::iter(variable)) { + result.reg.bits.push_back(rootClbitIndex(bit)); + } + return; + } + throw std::runtime_error( + "Qiskit circuit import does not support standalone variables in " + "classical expressions"); + } + + [[nodiscard]] std::unique_ptr + normalizePythonExpression(const QkExprNode* expression, + const nb::handle pythonExpression) const { + auto normalizeVariable = [this](Expression& result, + const nb::handle pythonVariable) { + normalizePythonVariable(result, pythonVariable); + }; + return normalizeExpression(expression, pythonExpression, normalizeVariable); + } + const QkCircuit* rootCircuit_ = nullptr; + const QkCircuit* circuit_ = nullptr; + const QkControlFlowInstruction* parent_ = nullptr; QkControlFlowInstruction* controlFlow_ = nullptr; + nb::object instruction_; nb::object operation_; + nb::object containingPythonCircuit_; + nb::object rootPythonCircuit_; }; std::unique_ptr NativeCircuitReader::controlFlow(const size_t index) const { return std::make_unique( - rootCircuit_, circuit_, index, parent_, pythonOperation(index)); + rootCircuit_, circuit_, index, parent_, + nb::borrow(data_[index]), pythonCircuit_, rootPythonCircuit_); } class NativeCircuitWriter final : public CircuitWriter { diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 4934f48b5a..44af3356f0 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -647,8 +647,10 @@ circuitRegisters(const CircuitReader& circuit, const bool quantum) { return mlir::arith::TruncIOp::create(builder, target, value).getResult(); } -[[nodiscard]] mlir::Value emitExpression(mlir::qc::QCProgramBuilder& builder, - const Expression& expression) { +[[nodiscard]] mlir::Value emitExpression( + mlir::qc::QCProgramBuilder& builder, const Expression& expression, + llvm::function_ref emitClassicalBit, + llvm::function_ref emitClassicalRegister) { const auto resultType = expressionType(builder, expression.type, expression.width); switch (expression.kind) { @@ -662,8 +664,19 @@ circuitRegisters(const CircuitReader& circuit, const bool quantum) { return floatConstant(builder, expression.floatValue); } break; + case ExpressionKind::ClassicalBit: + return emitClassicalBit(expression.bit); + case ExpressionKind::ClassicalRegister: { + const auto target = llvm::dyn_cast(resultType); + if (!target) { + throw std::runtime_error( + "Qiskit classical-register expressions must have Uint type"); + } + return castInteger(builder, emitClassicalRegister(expression.reg), target); + } case ExpressionKind::Cast: { - const auto operand = emitExpression(builder, *expression.left); + const auto operand = emitExpression( + builder, *expression.left, emitClassicalBit, emitClassicalRegister); if (operand.getType() == resultType) { return operand; } @@ -683,7 +696,8 @@ circuitRegisters(const CircuitReader& circuit, const bool quantum) { throw std::runtime_error("unsupported Qiskit classical-expression cast"); } case ExpressionKind::Unary: { - const auto operand = emitExpression(builder, *expression.left); + const auto operand = emitExpression( + builder, *expression.left, emitClassicalBit, emitClassicalRegister); switch (expression.unaryOperation) { case UnaryOperation::BitNot: { const auto type = llvm::dyn_cast(operand.getType()); @@ -722,8 +736,10 @@ circuitRegisters(const CircuitReader& circuit, const bool quantum) { break; } case ExpressionKind::Binary: { - auto left = emitExpression(builder, *expression.left); - auto right = emitExpression(builder, *expression.right); + auto left = emitExpression(builder, *expression.left, emitClassicalBit, + emitClassicalRegister); + auto right = emitExpression(builder, *expression.right, emitClassicalBit, + emitClassicalRegister); const auto comparison = [&]() -> std::optional { std::optional integerPredicate; std::optional floatPredicate; @@ -829,8 +845,10 @@ circuitRegisters(const CircuitReader& circuit, const bool quantum) { throw std::runtime_error("unsupported Qiskit classical binary operation"); } case ExpressionKind::Index: { - const auto target = emitExpression(builder, *expression.left); - auto index = emitExpression(builder, *expression.right); + const auto target = emitExpression(builder, *expression.left, + emitClassicalBit, emitClassicalRegister); + auto index = emitExpression(builder, *expression.right, emitClassicalBit, + emitClassicalRegister); const auto targetType = llvm::dyn_cast(target.getType()); if (!targetType) { throw std::runtime_error( @@ -926,7 +944,14 @@ emitCondition(mlir::qc::QCProgramBuilder& builder, .getResult(); } case ClassicalTargetKind::Expression: { - const auto condition = emitExpression(builder, *target.expression); + const auto condition = emitExpression( + builder, *target.expression, + [&](const uint32_t bit) { + return loadClassicalBit(builder, classicalBits, rootClbitMap, bit); + }, + [&](const Register& reg) { + return packRegister(builder, classicalBits, rootClbitMap, reg); + }); if (!condition.getType().isInteger(1)) { throw std::runtime_error( "Qiskit control-flow condition expression must have Boolean type"); @@ -951,7 +976,14 @@ emitSwitchTarget(mlir::qc::QCProgramBuilder& builder, value = packRegister(builder, classicalBits, rootClbitMap, target.reg); break; case ClassicalTargetKind::Expression: - value = emitExpression(builder, *target.expression); + value = emitExpression( + builder, *target.expression, + [&](const uint32_t bit) { + return loadClassicalBit(builder, classicalBits, rootClbitMap, bit); + }, + [&](const Register& reg) { + return packRegister(builder, classicalBits, rootClbitMap, reg); + }); break; } if (!llvm::isa(value.getType())) { @@ -1446,22 +1478,48 @@ void validateCircuit(const CircuitReader& circuit, uint32_t rootQubits, uint32_t rootClbits, size_t definitionDepth, size_t controlFlowDepth); -void validateExpression(const Expression& expression) { - if (expression.type == ClassicalType::Uint && - (expression.width == 0U || expression.width > 64U)) { +void validateExpression(const Expression& expression, + const uint32_t rootClbits) { + if ((expression.type == ClassicalType::Bool && expression.width != 1U) || + (expression.type == ClassicalType::Uint && + (expression.width == 0U || expression.width > 64U)) || + (expression.type == ClassicalType::Float && expression.width != 64U)) { throw std::runtime_error( - "Qiskit unsigned classical values must be between 1 and 64 bits"); + "Qiskit classical expression has an invalid type width"); } - const auto requireOperand = [](const std::unique_ptr& operand) { + const auto requireOperand = [&](const std::unique_ptr& operand) { if (!operand) { throw std::runtime_error( "Qiskit classical expression has a missing operand"); } - validateExpression(*operand); + validateExpression(*operand, rootClbits); }; switch (expression.kind) { case ExpressionKind::Value: return; + case ExpressionKind::ClassicalBit: + if (expression.type != ClassicalType::Bool || expression.width != 1U || + expression.bit >= rootClbits) { + throw std::runtime_error( + "Qiskit classical-bit expression has an invalid reference"); + } + return; + case ExpressionKind::ClassicalRegister: { + if (expression.type != ClassicalType::Uint || expression.reg.bits.empty() || + expression.reg.bits.size() > 64U || + expression.width < expression.reg.bits.size()) { + throw std::runtime_error( + "Qiskit classical-register expression has an invalid type"); + } + llvm::DenseSet seen; + for (const auto bit : expression.reg.bits) { + if (bit >= rootClbits || !seen.insert(bit).second) { + throw std::runtime_error( + "Qiskit classical-register expression has an invalid bit"); + } + } + return; + } case ExpressionKind::Unary: case ExpressionKind::Cast: requireOperand(expression.left); @@ -1499,7 +1557,7 @@ void validateTarget(const ClassicalTarget& target, const uint32_t rootClbits) { throw std::runtime_error( "Qiskit control flow contains an empty classical expression"); } - validateExpression(*target.expression); + validateExpression(*target.expression, rootClbits); return; } } diff --git a/bindings/mlir/qiskit/QiskitTranslation.h b/bindings/mlir/qiskit/QiskitTranslation.h index e90ccf69f1..733cb913a6 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -127,6 +127,8 @@ enum class ExpressionKind : uint8_t { Cast, Value, Index, + ClassicalBit, + ClassicalRegister, }; enum class BinaryOperation : uint8_t { BitAnd, @@ -163,6 +165,8 @@ struct Expression { bool boolValue = false; uint64_t uintValue = 0; double floatValue = 0.0; + uint32_t bit = 0; + Register reg; std::unique_ptr left; std::unique_ptr right; }; diff --git a/docs/mlir/python_compiler_collection.md b/docs/mlir/python_compiler_collection.md index d7f2fbc329..ed65eafc96 100644 --- a/docs/mlir/python_compiler_collection.md +++ b/docs/mlir/python_compiler_collection.md @@ -172,13 +172,19 @@ program structures than its C API can construct. | Nested `if`/`else`, `for`, `while`, and `switch` | Supported | Rejected | | Classical-bit and register conditions | Supported | Rejected | | Constant Boolean, `Uint` up to 64 bits, and `Float` expressions | Supported | Rejected | -| Standalone classical variables or variable expressions | Rejected | Rejected | +| Clbit and ClassicalRegister expression variables | Supported | Rejected | +| Standalone classical runtime variables | Rejected | Rejected | | Free symbols and supported real parameter expressions | Supported | Supported | | Parameter-vector elements | Rejected | Not emitted | | Dense numeric unitaries up to eight qubits | Supported | Supported | | Register aliases or interleaved membership | Rejected | Rejected | | Transpiler layout metadata | Accepted and ignored | Not emitted | +Classical-expression variables may refer to Clbits or ClassicalRegisters in the +containing circuit. This includes values used only by the condition or switch +target and not by a control-flow block. Standalone runtime variables remain +unsupported. + Free standalone symbols become named {code}`f64` program inputs. Parameter-vector elements are rejected because converting them to standalone parameters would change positional binding order. Standalone parameter names diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index f6ed06d610..e9dd5ba332 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -36,6 +36,7 @@ library, ) from qiskit.circuit.classical import expr, types +from qiskit.circuit.controlflow import CASE_DEFAULT from qiskit.quantum_info import Operator, random_unitary from mqt.core.mlir import CompilerTarget, QCProgram, compile_program @@ -1025,6 +1026,168 @@ def test_bool_uint_and_float_expressions(condition: expr.Expr, operation: str) - assert operation in program.ir +def _round_trip_qiskit_import(circuit: QuantumCircuit) -> str: + program = QCProgram.from_qiskit(circuit) + assert QCProgram.from_mlir_str(program.ir).ir == program.ir + return program.ir + + +def _cbit_load_indices(ir: str) -> list[int]: + constants = { + name: int(value) for name, value in re.findall(r"(?m)^\s*(%[-\w.$]+) = arith\.constant (\d+) : index$", ir) + } + return [constants[name] for name in re.findall(r"(?m)^\s*%[-\w.$]+ = cbit\.load [^\[]+\[(%[-\w.$]+)\]", ir)] + + +def test_classical_expression_clbit_captures_round_trip_on_import() -> None: + """Keep Clbit identity when an expression capture uses a nontrivial order.""" + circuit = QuantumCircuit(1, 2) + condition = expr.logic_and(circuit.clbits[1], expr.logic_not(circuit.clbits[0])) + with circuit.if_test(condition): + circuit.x(0) + + ir = _round_trip_qiskit_import(circuit) + + assert _cbit_load_indices(ir) == [1, 0] + assert "arith.xori" in ir + assert "arith.andi" in ir + assert "scf.if" in ir + + +def test_classical_expression_register_captures_round_trip_on_import() -> None: + """Pack a captured register in Qiskit's little-endian bit order.""" + circuit = QuantumCircuit(1, 3) + condition = expr.equal(expr.bit_xor(circuit.cregs[0], 1), 5) + with circuit.if_test(condition): + circuit.x(0) + + ir = _round_trip_qiskit_import(circuit) + + assert _cbit_load_indices(ir) == [0, 1, 2] + assert ir.count("arith.shli") == 2 + assert "arith.xori" in ir + assert "arith.cmpi eq" in ir + + +def test_nested_classical_expression_captures_round_trip_on_import() -> None: + """Compose nested local capture maps without changing root Clbit identity.""" + circuit = QuantumCircuit(1, 3) + with circuit.if_test(expr.logic_not(circuit.clbits[2])): + condition = expr.logic_and(circuit.clbits[0], expr.logic_not(circuit.clbits[1])) + with circuit.while_loop(condition, None, None, None, label=None): + circuit.x(0) + + ir = _round_trip_qiskit_import(circuit) + + assert _cbit_load_indices(ir) == [2, 0, 1] + assert "scf.if" in ir + assert "scf.while" in ir + + +def test_switch_expression_captures_round_trip_on_import() -> None: + """Read an expression switch target through Qiskit's public Python tree.""" + circuit = QuantumCircuit(1, 2) + with circuit.switch(expr.bit_xor(circuit.cregs[0], 1), None, None, None, label=None) as case: + with case(0): + circuit.x(0) + with case(case.DEFAULT): + circuit.h(0) + + ir = _round_trip_qiskit_import(circuit) + + assert _cbit_load_indices(ir) == [0, 1] + assert "arith.xori" in ir + assert "scf.index_switch" in ir + + +def test_condition_only_clbit_expression_round_trips_on_import() -> None: + """Resolve a condition bit that no control-flow block uses.""" + body = QuantumCircuit(1) + body.x(0) + circuit = QuantumCircuit(1, 1) + circuit.if_test(expr.logic_not(circuit.clbits[0]), body, [circuit.qubits[0]], []) + + assert len(circuit.data[0].clbits) == 0 + + ir = _round_trip_qiskit_import(circuit) + + assert _cbit_load_indices(ir) == [0] + assert "arith.xori" in ir + assert "scf.if" in ir + + +def test_condition_only_switch_expression_round_trips_on_import() -> None: + """Resolve a switch register that no case block uses.""" + zero = QuantumCircuit(1) + zero.x(0) + default = QuantumCircuit(1) + default.h(0) + circuit = QuantumCircuit(1, 2) + # Qiskit's overload omits expression targets although its runtime accepts them. + circuit.switch( # ty: ignore[no-matching-overload] + expr.bit_xor(circuit.cregs[0], 1), + [(0, zero), (CASE_DEFAULT, default)], + [circuit.qubits[0]], + [], + ) + + assert len(circuit.data[0].clbits) == 0 + assert all(block.num_clbits == 0 for block in circuit.data[0].operation.blocks) + + ir = _round_trip_qiskit_import(circuit) + + assert _cbit_load_indices(ir) == [0, 1] + assert "arith.xori" in ir + assert "scf.index_switch" in ir + + +def test_nested_condition_only_expression_uses_parent_capture_map() -> None: + """Map a nested condition-only bit through its enclosing block.""" + inner_body = QuantumCircuit(1) + inner_body.x(0) + middle = QuantumCircuit(1, 2) + middle.if_test(expr.logic_not(middle.clbits[0]), inner_body, [middle.qubits[0]], []) + circuit = QuantumCircuit(1, 2) + circuit.if_test( + (circuit.clbits[0], True), + middle, + [circuit.qubits[0]], + [circuit.clbits[1], circuit.clbits[0]], + ) + + ir = _round_trip_qiskit_import(circuit) + + assert _cbit_load_indices(ir) == [0, 1] + assert ir.count("scf.if") == 2 + + +def test_nested_legacy_clbit_condition_uses_root_index() -> None: + """Resolve a nested tuple condition through its enclosing Clbit map.""" + circuit = QuantumCircuit(2, 2) + with circuit.for_loop(range(2), None, None, None, None, label=None) as iteration: + circuit.rx(iteration, 0) + with circuit.if_test((circuit.clbits[1], True)): + circuit.x(0) + + ir = _round_trip_qiskit_import(circuit) + + assert _cbit_load_indices(ir) == [1] + assert "scf.for" in ir + assert "scf.if" in ir + + +def test_classical_expression_rejects_mismatched_instruction_captures() -> None: + """Reject an instruction capture list that does not match its block.""" + circuit = QuantumCircuit(1, 1) + with circuit.if_test(expr.logic_not(circuit.clbits[0])): + circuit.x(0) + instruction = circuit.data[0] + circuit._data[0] = instruction.replace(clbits=()) # ruff: ignore[private-member-access] + + with pytest.raises(RuntimeError, match="incompatible classical-bit captures"): + QCProgram.from_qiskit(circuit) + + def test_excessively_nested_classical_expression_is_rejected() -> None: """Bound native normalization before recursive expression traversal.""" condition: expr.Expr = expr.equal(1, 1) From b612830dddf3cd3eaabd9da528566e5f91715afc Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Wed, 19 Aug 2026 17:53:04 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20Export=20structured=20Qiskit=20?= =?UTF-8?q?control=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- .../plans/qiskit-structured-control-export.md | 336 ++++ bindings/mlir/qiskit/Qiskit2_5.cpp | 827 ++++++++- bindings/mlir/qiskit/QiskitExport.cpp | 1490 +++++++++++++++-- bindings/mlir/qiskit/QiskitTranslation.h | 6 + docs/mlir/python_compiler_collection.md | 58 +- test/python/test_mlir_qiskit_translation.py | 705 +++++++- 6 files changed, 3275 insertions(+), 147 deletions(-) create mode 100644 .agent/plans/qiskit-structured-control-export.md diff --git a/.agent/plans/qiskit-structured-control-export.md b/.agent/plans/qiskit-structured-control-export.md new file mode 100644 index 0000000000..1b947ff9fb --- /dev/null +++ b/.agent/plans/qiskit-structured-control-export.md @@ -0,0 +1,336 @@ +# Export structured Qiskit control flow with CBit state + +This ExecPlan is a living document. The sections `Progress`, +`Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must +be kept up to date as work proceeds. + +This ExecPlan must be maintained in accordance with `.agent/PLANS.md` from the +repository root. + +## Purpose / Big Picture + +MQT Core can import Qiskit 2.5 structured control flow, but it currently rejects +the same operations during export. After this change, `QCProgram.to_qiskit()` +can preserve supported nested `scf.if`, `scf.for`, `scf.while`, and +`scf.index_switch` operations. Conditions and switch targets can read captured +first-class CBit registers and use supported Boolean, unsigned-integer, and +floating-point expression trees. A user can observe the result by exporting an +MLIR program, inspecting the Qiskit control-flow operations, and importing the +result again. + +The Qiskit 2.5 C API cannot construct control-flow operations or classical +expressions. The generic exporter therefore validates and normalizes the whole +circuit before it allocates a Qiskit writer. The version-specific writer emits +ordinary operations through the C API, finalizes nested blocks, and then uses +Qiskit's public Python classes to insert the already validated control-flow +operations at their recorded positions. + +This plan covers only structured-control export. Relaxing measurement-result +store adjacency across quantum-only operations is an independently reviewable +follow-up with its own ExecPlan and branch. + +## Progress + +- [x] (2026-08-19 15:07Z) Read the repository instructions, inspect the CBit, + scalar-parameter, and expression-capture base, and compare it with the + earlier combined control-flow implementation. +- [x] (2026-08-19 15:15Z) Add the version-neutral writer interface and the + Qiskit 2.5 deferred Python control-flow writer without changing the import + reader or scalar parameter identity model. +- [x] (2026-08-19 15:28Z) Replace flat export collection with recursive + preflight and emission that uses CBit loads, stores, register/index + access, snapshots, and definite writes. +- [x] (2026-08-19 15:43Z) Add focused CBit structured-control exporter tests and + update the public support documentation. +- [x] (2026-08-19 15:52Z) Build the binding, run all 190 Qiskit translation + tests and repository lint, and review the semantic diff. Creating the + signed local commit is the final handoff step. +- [x] (2026-08-19 16:04Z) Close the final audit gaps for repeated-bit Uint + expressions and non-CBit function results, add five focused cases, and + rerun all 195 translation tests before restacking. +- [x] (2026-08-19 16:22Z) Restack onto the finalized captured-expression import + parent, rebuild the exact structured branch, and pass all 196 translation + tests. +- [x] (2026-08-19 19:55Z) Restack again after #2158 merged, rebuild the release + bindings, pass all 196 translation tests, and pass the complete repository + lint session and focused diff checks. +- [x] (2026-08-19 20:13Z) Restack onto the audited scalar/capture foundation, + preserve named-input reachability validation recursively through nested + structured blocks, rebuild the binding, and pass all 197 translation + tests. + +## Surprises & Discoveries + +- Observation: The current base already has three independent foundations that + the old combined implementation did not preserve: first-class CBit registers, + scalar `Parameter` trees keyed by stable identity, and hybrid native/Python + import of captured classical expression variables. Evidence: the base commit + contains `cbit.load`/`cbit.store` export discovery, shared `Parameter` nodes + with an `identity` field, and `NativeControlFlowReader::rootClbitIndex`. + +- Observation: Qiskit 2.5 exposes structured-control inspection in its C API but + no matching constructors. Evidence: the current writer can append gates, + measurements, resets, barriers, and unitaries natively, while the previous + implementation had to finalize Python block circuits and construct `IfElseOp`, + `ForLoopOp`, `WhileLoopOp`, and `SwitchCaseOp` through public Python classes. + +- Observation: The compiler may move qubit-register `memref.load` operations + into nested SCF regions. Evidence: the first compiled structured-control probe + failed qubit resolution until resource discovery walked all loads in the + function rather than only the entry block. + +- Observation: CBit initialization makes the old synthetic false-store logic + both unnecessary and incorrect for this branch. Evidence: zero-initialized + allocations round-trip without stores, while undefined returned registers are + accepted only after validated top-level measurement writes. + +- Observation: A syntactic packed-Uint tree can place the same resolved CBit at + multiple output positions. Evidence: treating `(c[0] | (c[0] << 1))` as a + register creates invalid repeated-register metadata, while the general + expression tree represents it exactly. + +- Observation: Core represents a circuit without classical outputs with one + constant-zero `i64` exit-code result. Evidence: `QCProgramBuilder::finalize()` + and Qiskit import use this sentinel, while every other non-CBit result carries + semantics that Qiskit circuit export cannot preserve. + +## Decision Log + +- Decision: Change only `CircuitWriter`'s output interface and leave all reader + interfaces untouched. Rationale: the import capture slice is already reviewed + and does not need exporter construction code. Date/Author: 2026-08-19 / Codex. + +- Decision: Represent the collected output as a recursive `ExportedCircuit` + whose instructions may own one `ExportedControlFlow`. Rationale: validation, + supported-gate checks, and writer emission must recurse through every block + before any top-level Qiskit circuit is exposed. Date/Author: 2026-08-19 / + Codex. + +- Decision: Keep scalar parameter trees and stable identities unchanged. Give + each live `scf.for` induction parameter a generated `ParameterKind::Symbol` + with one identity shared by `Loop::parameter` and its lexical body. Rationale: + Qiskit's `ForLoopOp` must use the same Python `Parameter` object that appears + in body gates, and generated names must not collide with free program inputs. + Date/Author: 2026-08-19 / Codex. + +- Decision: Treat a returned undefined CBit register as initialized only by + validated, unconditional measurement stores in the entry block. Reject a load + before such a write and reject writes that occur only in conditional or loop + blocks as initialization. Rationale: Qiskit classical bits start at zero, but + an MLIR register with undefined initialization has no value until every + observed bit is definitely written. Date/Author: 2026-08-19 / Codex. + +- Decision: Build packed-register expressions as one + `ExpressionKind::ClassicalRegister` leaf. Rationale: this preserves register + bit order and lets the Qiskit adapter reuse an actual registered + `ClassicalRegister` when possible instead of reconstructing each shift and + bitwise-or operation. Date/Author: 2026-08-19 / Codex. + +- Decision: If two packed output positions resolve to the same CBit, reject the + packed-register match and use the general classical expression tree. + Rationale: a Qiskit `ClassicalRegister` cannot contain the same bit twice, but + repeated expression leaves are valid. Date/Author: 2026-08-19 / Codex. + +- Decision: Accept a non-CBit return only when it is the sole constant-zero + `i64` no-output sentinel. Reject floating, nonzero, computed, multiple, or + mixed non-CBit results. Rationale: this preserves Core's established circuit + convention without silently discarding observable SSA results. Date/Author: + 2026-08-19 / Codex. + +- Decision: Keep delayed measurement stores strict and leave their quantum-only + relaxation to a separate branch and ExecPlan. Rationale: structured-control + construction and measurement-order equivalence have independent correctness + arguments and should be reviewed separately. Date/Author: 2026-08-19 / Codex. + +## Outcomes & Retrospective + +Structured Qiskit control flow now exports recursively through a normalized, +frontend-neutral plan and a Qiskit 2.5 deferred Python writer. Captured CBits, +packed registers, Boolean/Uint/Float expressions, nested blocks, static loops, +switches, and loop parameter identity round-trip. Preflight rejects stale +snapshots, unsupported expression/result forms, invalid labels, and undefined +CBit reads or returns before allocating the Qiskit writer. + +The release MLIR binding builds successfully after the final post-merge restack. +All 197 tests in `test/python/test_mlir_qiskit_translation.py` pass against the +worktree-built extension, and `uvx nox -s lint` passes. The semantic diff leaves +the import reader and existing identity-keyed scalar parameter normalizer +unchanged, while recursively checking that every named scalar input remains +reachable from the emitted top-level or nested Qiskit parameter trees. The +measurement-store relaxation remains out of scope for this completed plan and +will receive its own branch and ExecPlan. Nothing is pushed. + +## Context and Orientation + +`bindings/mlir/qiskit/QiskitTranslation.h` defines normalized data shared by the +generic MLIR translator and each supported Qiskit version. `CircuitWriter` +currently accepts only flat operations. It will gain `addControlFlow`, which +owns normalized metadata and one writer for each nested block. + +`bindings/mlir/qiskit/QiskitExport.cpp` converts one `mlir::QCProgram` into that +normalized writer stream. The current `ExportState` discovers qubit resources, +returned `!cbit.reg` values, scalar parameters, and flat instructions. A CBit +register is a first-class SSA value. `cbit.load` reads one element, `cbit.store` +writes one element, and `cbit.get_reg` plus `cbit.get_index` describe a +measurement destination. Structured export needs a recursive collector because +each SCF region is a nested circuit block with captured root qubits and +classical bits. + +An SCF operation is MLIR's structured-control representation. `scf.if` has one +or two regions, `scf.for` has a constant iteration range, `scf.while` has a +condition region and a body region, and `scf.index_switch` has labeled case +regions plus a default region. Supported exported forms have no general SSA +results. The only accepted result-bearing `scf.if` form is a pure Boolean select +that reconstructs a Qiskit classical expression. + +A classical snapshot is a `cbit.load` result used later in a condition or +expression. Export is valid only if no intervening store can make that loaded +value stale before the control-flow operation consumes it. A definite write is +an unconditional validated top-level measurement store. Definite-write tracking +prevents an undefined returned CBit from being read before it gains a +Qiskit-representable value. + +`bindings/mlir/qiskit/Qiskit2_5.cpp` implements the version-specific reader and +writer. The reader and its public-Python expression capture logic stay +unchanged. The writer already preserves scalar symbols by `Parameter.identity`. +The new `PythonClassicalBuilder` reconstructs normalized expression trees. The +writer records control-flow insertion points, finalizes child writers against +the parent's exact bit objects, creates Python control-flow operations, and +inserts them in top-down order. + +`test/python/test_mlir_qiskit_translation.py` contains the end-to-end import and +export contract. `docs/mlir/python_compiler_collection.md` contains the public +support table and its exact restrictions. + +## Plan of Work + +First, add `CircuitWriter::addControlFlow` in +`bindings/mlir/qiskit/QiskitTranslation.h`. The method accepts a +`ControlFlowKind`, one classical target, loop and switch metadata, owned block +writers, and the captured root qubit and classical-bit indices. + +Next, extend `bindings/mlir/qiskit/Qiskit2_5.cpp`. Add a +`PythonClassicalBuilder` that turns constants, captured Clbits, captured +ClassicalRegisters, casts, indexing, unary operations, and binary operations +into Qiskit's public expression objects. Extend `NativeCircuitWriter` to record +control flow without adding a C placeholder. During `finish`, convert native +circuits to Python, rebase each nested block onto the parent's exact Qubit and +Clbit objects, preserve canonical scalar parameter objects across blocks, build +the public control-flow operations, and insert them at stable instruction +positions. Validate block shape, captures, loop metadata, switch labels, and bit +counts before construction. + +Then refactor `bindings/mlir/qiskit/QiskitExport.cpp`. Preserve the existing +scalar parameter normalizer and resource discovery. Add recursive circuit and +control-flow records, expression reconstruction, packed-register recognition, +snapshot validation, loop projection, recursive collection, recursive +constructible-gate validation, and recursive writer emission. Use only CBit +operations for classical state. Preflight all unsupported results, dynamic +indices or bounds, signed or over-wide expressions, non-finite values, repeated +captures or labels, stale snapshots, repeated measurement destinations, and +unsupported loop forms before writer allocation. + +For undefined returned CBit registers, scan validated stores in the entry block +in program order. Only an unconditional measurement store makes its destination +definitely written. Reject any exported load of an undefined bit before its +first definite write. A store inside nested control flow may be exported as a +measurement destination but cannot establish top-level initialization. +Zero-initialized CBit allocations need no synthetic stores because Qiskit starts +classical bits at zero. + +Add focused tests that cover nested if/while/switch captures, register +conditions, Boolean select expressions, loop ranges and identity, empty +branches, rejection without source mutation, undefined CBit definite writes, +stale snapshots, malformed labels, and unsupported expression forms. Return all +public classical registers from MLIR test functions. Keep the existing import +capture tests unchanged. Update the support table and structured-export +restrictions in `docs/mlir/python_compiler_collection.md`. + +## Concrete Steps + +Run all commands from the repository root. Inspect formatting throughout: + + git diff --check + clang-format --dry-run --Werror bindings/mlir/qiskit/Qiskit2_5.cpp \ + bindings/mlir/qiskit/QiskitExport.cpp \ + bindings/mlir/qiskit/QiskitTranslation.h + uvx ruff check test/python/test_mlir_qiskit_translation.py + +Configure and build the release MLIR binding if this isolated worktree does not +already have a compatible build: + + cmake --preset release + cmake --build build/release --target mqt-core-mlir-bindings --parallel 8 + +Run focused tests while iterating, then the complete translation file against +the worktree-built extension: + + uv run --no-sync pytest test/python/test_mlir_qiskit_translation.py \ + -k 'control_flow or expression or measurement_store' + uv run --no-sync pytest test/python/test_mlir_qiskit_translation.py + +Run the repository lint session after each completed commit-sized batch: + + uvx nox -s lint + +## Validation and Acceptance + +An exported result-free `scf.if`, constant-range `scf.for`, expression-based +`scf.while`, or result-free `scf.index_switch` must produce the matching Qiskit +operation. Importing that Qiskit circuit again must succeed. Captured bits must +refer to the same root Clbit objects, and packed registers must retain +little-endian bit order. A live loop induction value must use one Python +Parameter identity in the loop metadata and every nested gate expression. + +An undefined returned CBit may be measured unconditionally and then read. A load +before that write, a conditional-only initializing write, a duplicate +destination, a dynamic destination, or a stale snapshot must fail before Qiskit +writer allocation. Zero-initialized returned CBits need no emitted initializer. +All rejected exports must leave the source MLIR text unchanged. + +The commit is accepted when the release binding builds, all Qiskit translation +tests pass, lint passes, and the diff changes only the writer interface, generic +exporter, Qiskit 2.5 writer, tests, documentation, and this plan. + +## Idempotence and Recovery + +All build, format, lint, and test commands are repeatable. Source edits stay in +this dedicated worktree and do not modify other task worktrees. The generic +exporter finishes validation before it calls `selectTranslation` or allocates a +writer, so failures cannot expose a partial Qiskit circuit. If Python +post-processing fails, `finish` owns and discards its incomplete local objects. +Do not cherry-pick the earlier combined implementation because it would restore +obsolete MemRef classical state and overwrite the reviewed scalar and import +models. Do not push the local commit. + +## Artifacts and Notes + +The starting commit already passes captured-expression import tests and uses +`Parameter.identity` for scalar symbols. The old combined implementation is a +design reference only. The final commit boundary is: + + structured export: interface + recursive collector + deferred writer + + tests + support documentation + this plan + +## Interfaces and Dependencies + +At completion, `CircuitWriter` has this additional virtual operation: + + void addControlFlow( + ControlFlowKind kind, ClassicalTarget target, Loop loop, + std::vector switchCases, + std::vector> blocks, + const std::vector& qubits, + const std::vector& clbits); + +`QiskitExport.cpp` owns `ExportedCircuit` and `ExportedControlFlow` records and +recursively calls this operation only after complete preflight. `Qiskit2_5.cpp` +implements the operation with deferred public-Python construction while keeping +native gate and scalar parameter creation. No new dependency is introduced. The +implementation uses LLVM and MLIR utilities already linked by the binding, +nanobind for public Python objects, and Qiskit 2.5's existing C API. + +Revision note: Created the self-contained plan after comparing the reviewed +CBit/scalar/import base with the earlier combined implementation, then closed it +after the release build, complete translation tests, lint, and semantic review. +Updated it for the final audit fixes and restack onto the amended import parent. diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 599b4bfb2d..e2fe4a8f82 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include @@ -1839,6 +1840,328 @@ NativeCircuitReader::controlFlow(const size_t index) const { nb::borrow(data_[index]), pythonCircuit_, rootPythonCircuit_); } +class PythonClassicalBuilder final { +public: + explicit PythonClassicalBuilder(const nb::handle circuit) + : circuit_(nb::borrow(circuit)), + clbits_(pythonAttribute(circuit, "clbits", + "Qiskit circuit has no classical bits")), + expressionModule_( + nb::module_::import_("qiskit.circuit.classical.expr")), + typesModule_(nb::module_::import_("qiskit.circuit.classical.types")) {} + + [[nodiscard]] nb::object expression(const Expression& value) const { + return expression(value, 0U); + } + + [[nodiscard]] nb::object condition(const ClassicalTarget& target) const { + switch (target.kind) { + case ClassicalTargetKind::ClassicalBit: + return nb::make_tuple(classicalBit(target.bit), + nb::bool_(target.expectedBit)); + case ClassicalTargetKind::ClassicalRegister: { + validateRegisterValue(target.reg, target.expectedRegister); + if (const auto reg = registeredClassicalRegister(target.reg)) { + return nb::make_tuple(*reg, nb::int_(target.expectedRegister)); + } + const auto packed = packedRegister(target.reg); + const auto expected = expressionModule_.attr("lift")( + nb::int_(target.expectedRegister), + classicalType(ClassicalType::Uint, + static_cast(target.reg.bits.size()))); + return expressionModule_.attr("equal")(packed, expected); + } + case ClassicalTargetKind::Expression: + if (!target.expression) { + throw std::runtime_error( + "Qiskit control-flow condition has no expression"); + } + if (target.expression->type != ClassicalType::Bool) { + throw std::runtime_error( + "Qiskit control-flow condition expression must be Boolean"); + } + return expression(*target.expression); + } + throw std::runtime_error("Qiskit control flow has an unknown condition"); + } + + [[nodiscard]] nb::object switchTarget(const ClassicalTarget& target) const { + switch (target.kind) { + case ClassicalTargetKind::ClassicalBit: + return classicalBit(target.bit); + case ClassicalTargetKind::ClassicalRegister: + if (target.reg.bits.empty() || target.reg.bits.size() > 64U) { + throw std::runtime_error( + "Qiskit switch registers must contain between 1 and 64 bits"); + } + if (const auto reg = registeredClassicalRegister(target.reg)) { + return *reg; + } + return packedRegister(target.reg); + case ClassicalTargetKind::Expression: + if (!target.expression) { + throw std::runtime_error("Qiskit switch target has no expression"); + } + if (target.expression->type == ClassicalType::Float) { + throw std::runtime_error( + "Qiskit switch target expression cannot be floating-point"); + } + return expression(*target.expression); + } + throw std::runtime_error( + "Qiskit control flow has an unknown switch target"); + } + +private: + [[nodiscard]] nb::object classicalType(const ClassicalType type, + const uint32_t width) const { + switch (type) { + case ClassicalType::Bool: + if (width != 1U) { + throw std::runtime_error("Qiskit Boolean expressions require width 1"); + } + return typesModule_.attr("Bool")(); + case ClassicalType::Uint: + if (width == 0U || width > 64U) { + throw std::runtime_error( + "Qiskit unsigned expressions require a width from 1 to 64"); + } + return typesModule_.attr("Uint")(width); + case ClassicalType::Float: + if (width != 64U) { + throw std::runtime_error( + "Qiskit floating-point expressions require width 64"); + } + return typesModule_.attr("Float")(); + } + throw std::runtime_error("Qiskit expression has an unknown type"); + } + + [[nodiscard]] nb::object classicalBit(const uint32_t bit) const { + if (bit >= nb::len(clbits_)) { + throw std::runtime_error( + "Qiskit classical expression references an invalid bit"); + } + return nb::borrow(clbits_[bit]); + } + + [[nodiscard]] std::optional + registeredClassicalRegister(const Register& reg) const { + const auto registers = pythonAttribute( + circuit_, "cregs", "Qiskit circuit has no classical registers"); + std::optional matchingBits; + for (const nb::handle candidateHandle : nb::iter(registers)) { + if (nb::len(candidateHandle) != reg.bits.size()) { + continue; + } + auto candidate = nb::borrow(candidateHandle); + bool matches = true; + for (size_t index = 0U; index < reg.bits.size(); ++index) { + if (!candidate[index].equal(classicalBit(reg.bits[index]))) { + matches = false; + break; + } + } + if (!matches) { + continue; + } + if (pythonStringAttribute(candidate, "name", + "Qiskit classical register has no name") == + reg.name) { + return candidate; + } + matchingBits = std::move(candidate); + } + return matchingBits; + } + + static void validateRegisterValue(const Register& reg, const uint64_t value) { + if (reg.bits.empty() || reg.bits.size() > 64U) { + throw std::runtime_error( + "Qiskit condition registers must contain between 1 and 64 bits"); + } + if (reg.bits.size() < std::numeric_limits::digits && + value >= (uint64_t{1} << reg.bits.size())) { + throw std::runtime_error( + "Qiskit register condition value exceeds its register width"); + } + } + + [[nodiscard]] nb::object + packedRegister(const Register& reg, + const uint32_t expressionWidth = 0U) const { + const auto width = expressionWidth == 0U + ? static_cast(reg.bits.size()) + : expressionWidth; + if (reg.bits.empty() || reg.bits.size() > 64U || width < reg.bits.size() || + width > 64U) { + throw std::runtime_error( + "Qiskit expression register has an invalid width"); + } + std::unordered_set seen; + std::vector terms; + terms.reserve(reg.bits.size()); + const auto type = classicalType(ClassicalType::Uint, width); + for (size_t index = 0U; index < reg.bits.size(); ++index) { + if (!seen.insert(reg.bits[index]).second) { + throw std::runtime_error( + "Qiskit expression register contains a repeated bit"); + } + auto term = + expressionModule_.attr("cast")(classicalBit(reg.bits[index]), type); + if (index != 0U) { + term = expressionModule_.attr("shift_left")(term, nb::int_(index)); + } + terms.emplace_back(std::move(term)); + } + while (terms.size() > 1U) { + std::vector reduced; + reduced.reserve((terms.size() + 1U) / 2U); + for (size_t index = 0U; index < terms.size(); index += 2U) { + if (index + 1U == terms.size()) { + reduced.emplace_back(std::move(terms[index])); + continue; + } + reduced.emplace_back( + expressionModule_.attr("bit_or")(terms[index], terms[index + 1U])); + } + terms = std::move(reduced); + } + return std::move(terms.front()); + } + + [[nodiscard]] static const char* binaryFunction(const BinaryOperation op) { + switch (op) { + case BinaryOperation::BitAnd: + return "bit_and"; + case BinaryOperation::BitOr: + return "bit_or"; + case BinaryOperation::BitXor: + return "bit_xor"; + case BinaryOperation::LogicAnd: + return "logic_and"; + case BinaryOperation::LogicOr: + return "logic_or"; + case BinaryOperation::Equal: + return "equal"; + case BinaryOperation::NotEqual: + return "not_equal"; + case BinaryOperation::Less: + return "less"; + case BinaryOperation::LessEqual: + return "less_equal"; + case BinaryOperation::Greater: + return "greater"; + case BinaryOperation::GreaterEqual: + return "greater_equal"; + case BinaryOperation::ShiftLeft: + return "shift_left"; + case BinaryOperation::ShiftRight: + return "shift_right"; + case BinaryOperation::Add: + return "add"; + case BinaryOperation::Subtract: + return "sub"; + case BinaryOperation::Multiply: + return "mul"; + case BinaryOperation::Divide: + return "div"; + } + throw std::runtime_error( + "Qiskit expression has an unknown binary operation"); + } + + [[nodiscard]] static const char* unaryFunction(const UnaryOperation op) { + switch (op) { + case UnaryOperation::BitNot: + return "bit_not"; + case UnaryOperation::LogicNot: + return "logic_not"; + case UnaryOperation::Negate: + return "negate"; + } + throw std::runtime_error( + "Qiskit expression has an unknown unary operation"); + } + + [[nodiscard]] nb::object expression(const Expression& value, + const size_t depth) const { + if (depth >= MAX_EXPRESSION_DEPTH) { + throw std::runtime_error( + "Qiskit classical expressions exceed the nesting limit of 64"); + } + const auto requireOperand = [](const std::unique_ptr& operand) { + if (!operand) { + throw std::runtime_error( + "Qiskit classical expression has a missing operand"); + } + return operand.get(); + }; + switch (value.kind) { + case ExpressionKind::Value: { + const auto type = classicalType(value.type, value.width); + switch (value.type) { + case ClassicalType::Bool: + return expressionModule_.attr("lift")(nb::bool_(value.boolValue), type); + case ClassicalType::Uint: + if (value.width < std::numeric_limits::digits && + value.uintValue >= (uint64_t{1} << value.width)) { + throw std::runtime_error( + "Qiskit unsigned expression value exceeds its width"); + } + return expressionModule_.attr("lift")(nb::int_(value.uintValue), type); + case ClassicalType::Float: + if (!std::isfinite(value.floatValue)) { + throw std::runtime_error( + "Qiskit floating-point expression value must be finite"); + } + return expressionModule_.attr("lift")(nb::float_(value.floatValue), + type); + } + break; + } + case ExpressionKind::ClassicalBit: + if (value.type != ClassicalType::Bool || value.width != 1U) { + throw std::runtime_error( + "Qiskit classical-bit expression must have Boolean type"); + } + return expressionModule_.attr("lift")(classicalBit(value.bit)); + case ExpressionKind::ClassicalRegister: + if (value.type != ClassicalType::Uint || value.width == 0U || + value.width < value.reg.bits.size() || value.width > 64U) { + throw std::runtime_error( + "Qiskit classical-register expression has an invalid type"); + } + if (const auto reg = registeredClassicalRegister(value.reg)) { + return expressionModule_.attr("lift")( + *reg, classicalType(ClassicalType::Uint, value.width)); + } + return packedRegister(value.reg, value.width); + case ExpressionKind::Unary: + return expressionModule_.attr(unaryFunction(value.unaryOperation))( + expression(*requireOperand(value.left), depth + 1U)); + case ExpressionKind::Binary: + return expressionModule_.attr(binaryFunction(value.binaryOperation))( + expression(*requireOperand(value.left), depth + 1U), + expression(*requireOperand(value.right), depth + 1U)); + case ExpressionKind::Cast: + return expressionModule_.attr("cast")( + expression(*requireOperand(value.left), depth + 1U), + classicalType(value.type, value.width)); + case ExpressionKind::Index: + return expressionModule_.attr("index")( + expression(*requireOperand(value.left), depth + 1U), + expression(*requireOperand(value.right), depth + 1U)); + } + throw std::runtime_error("Qiskit classical expression has an unknown kind"); + } + + nb::object circuit_; + nb::object clbits_; + nb::object expressionModule_; + nb::object typesModule_; +}; + class NativeCircuitWriter final : public CircuitWriter { public: NativeCircuitWriter(const uint32_t looseQubits, const uint32_t looseClbits) @@ -1959,7 +2282,46 @@ class NativeCircuitWriter final : public CircuitWriter { } } + void addControlFlow(const ControlFlowKind kind, ClassicalTarget target, + Loop loop, std::vector switchCases, + std::vector> blocks, + const std::vector& qubits, + const std::vector& clbits) override { + validateControlFlowShape(kind, target, loop, switchCases, blocks, qubits, + clbits); + for (const auto& block : blocks) { + const auto* const native = + dynamic_cast(block.get()); + if (native == nullptr) { + throw std::runtime_error( + "Qiskit control-flow blocks use an incompatible writer"); + } + if (native->circuit_ == nullptr || + qk_circuit_num_qubits(native->circuit_) != qubits.size() || + qk_circuit_num_clbits(native->circuit_) != clbits.size()) { + throw std::runtime_error( + "Qiskit control-flow block has incompatible bit counts"); + } + } + pendingControlFlow_.push_back( + {.instructionIndex = qk_circuit_num_instructions(circuit_), + .kind = kind, + .target = std::move(target), + .loop = std::move(loop), + .switchCases = std::move(switchCases), + .blockWriters = std::move(blocks), + .qubits = qubits, + .clbits = clbits}); + } + [[nodiscard]] nb::object finish() override { + return finishImpl(false, nb::none(), nb::none()); + } + +private: + [[nodiscard]] nb::object finishImpl(const bool rebase, + const nb::handle exactQubits, + const nb::handle exactClbits) { if (circuit_ == nullptr) { throw std::runtime_error( "Qiskit circuit writer has already been finalized"); @@ -1971,22 +2333,213 @@ class NativeCircuitWriter final : public CircuitWriter { } auto pythonCircuit = nb::steal(result); try { - replacePendingControlledUnitaries(pythonCircuit); + if (rebase) { + pythonCircuit = rebaseCircuit(pythonCircuit, exactQubits, exactClbits); + } + const auto unitaryReplacements = + pendingControlledUnitaryReplacements(pythonCircuit); + finalizeControlFlowBlocks(pythonCircuit); + const auto canonicalParameters = + canonicalizeControlFlowParameters(pythonCircuit); + const auto controlFlowInstructions = + pendingControlFlowInstructions(pythonCircuit, canonicalParameters); + applyPendingInstructions(pythonCircuit, unitaryReplacements, + controlFlowInstructions); } catch (const nb::python_error& error) { - throwPythonError("Qiskit failed to construct a controlled unitary", + throwPythonError("Qiskit failed to construct deferred instructions", error); } return pythonCircuit; } -private: struct PendingControlledUnitary { size_t instructionIndex = 0U; uint32_t numControls = 0U; std::vector qubits; }; - void replacePendingControlledUnitaries(const nb::handle pythonCircuit) const { + struct PendingControlFlow { + size_t instructionIndex = 0U; + ControlFlowKind kind = ControlFlowKind::IfElse; + ClassicalTarget target; + Loop loop; + std::vector switchCases; + std::vector> blockWriters; + std::vector blocks; + std::vector qubits; + std::vector clbits; + }; + + struct IndexedPythonInstruction { + size_t instructionIndex = 0U; + nb::object instruction; + }; + + using PythonParameterMap = std::unordered_map; + static void collectExpressionBits(const Expression& expression, + std::unordered_set& bits, + const size_t depth = 0U) { + if (depth >= MAX_EXPRESSION_DEPTH) { + throw std::runtime_error( + "Qiskit classical expressions exceed the nesting limit of 64"); + } + const auto collectOperand = + [&](const std::unique_ptr& operand) { + if (!operand) { + throw std::runtime_error( + "Qiskit classical expression has a missing operand"); + } + collectExpressionBits(*operand, bits, depth + 1U); + }; + switch (expression.kind) { + case ExpressionKind::Value: + return; + case ExpressionKind::ClassicalBit: + bits.insert(expression.bit); + return; + case ExpressionKind::ClassicalRegister: + bits.insert(expression.reg.bits.begin(), expression.reg.bits.end()); + return; + case ExpressionKind::Unary: + case ExpressionKind::Cast: + collectOperand(expression.left); + return; + case ExpressionKind::Binary: + case ExpressionKind::Index: + collectOperand(expression.left); + collectOperand(expression.right); + return; + } + } + + static void + validateTargetCaptures(const ClassicalTarget& target, + const std::vector& capturedClbits) { + std::unordered_set referenced; + switch (target.kind) { + case ClassicalTargetKind::ClassicalBit: + referenced.insert(target.bit); + break; + case ClassicalTargetKind::ClassicalRegister: + referenced.insert(target.reg.bits.begin(), target.reg.bits.end()); + break; + case ClassicalTargetKind::Expression: + if (!target.expression) { + throw std::runtime_error( + "Qiskit control flow contains an empty classical expression"); + } + collectExpressionBits(*target.expression, referenced); + break; + } + const std::unordered_set captured(capturedClbits.begin(), + capturedClbits.end()); + for (const auto bit : referenced) { + if (!captured.contains(bit)) { + throw std::runtime_error( + "Qiskit control flow does not capture a referenced classical bit"); + } + } + } + + static void validateControlFlowShape( + const ControlFlowKind kind, const ClassicalTarget& target, + const Loop& loop, const std::vector& switchCases, + const std::vector>& blocks, + const std::vector& qubits, + const std::vector& clbits) { + const auto requireUnique = [](const std::vector& bits, + const std::string_view kindName) { + std::unordered_set seen; + for (const auto bit : bits) { + if (!seen.insert(bit).second) { + throw std::runtime_error("Qiskit control flow repeats a " + + std::string(kindName)); + } + } + }; + requireUnique(qubits, "qubit capture"); + requireUnique(clbits, "classical-bit capture"); + for (const auto& block : blocks) { + if (!block) { + throw std::runtime_error("Qiskit control flow has an empty block"); + } + } + + switch (kind) { + case ControlFlowKind::Box: + case ControlFlowKind::Break: + case ControlFlowKind::Continue: + throw std::runtime_error( + "Qiskit circuit export does not support this control-flow kind"); + case ControlFlowKind::IfElse: + if (blocks.empty() || blocks.size() > 2U) { + throw std::runtime_error("Qiskit if/else requires one or two blocks"); + } + break; + case ControlFlowKind::While: + if (blocks.size() != 1U) { + throw std::runtime_error("Qiskit while loop requires one block"); + } + break; + case ControlFlowKind::For: + if (blocks.size() != 1U) { + throw std::runtime_error("Qiskit for loop requires one block"); + } + if (loop.isRange && loop.step == 0) { + throw std::runtime_error("Qiskit for-loop range step cannot be zero"); + } + if (loop.parameter && + (loop.parameter->kind != ParameterKind::Symbol || + loop.parameter->text.empty() || loop.parameter->identity.empty())) { + throw std::runtime_error( + "Qiskit for-loop parameter has invalid identity metadata"); + } + break; + case ControlFlowKind::Switch: { + if (blocks.empty() || switchCases.size() != blocks.size()) { + throw std::runtime_error( + "Qiskit switch metadata must match its non-empty block list"); + } + bool foundDefault = false; + std::unordered_set labels; + for (size_t index = 0U; index < switchCases.size(); ++index) { + const auto& switchCase = switchCases[index]; + if (switchCase.isDefault) { + if (std::exchange(foundDefault, true) || + index + 1U != switchCases.size() || !switchCase.labels.empty()) { + throw std::runtime_error( + "Qiskit switch requires one final unlabeled default case"); + } + continue; + } + if (switchCase.labels.empty()) { + throw std::runtime_error( + "Qiskit switch case requires at least one label"); + } + for (const auto label : switchCase.labels) { + if (!labels.insert(label).second) { + throw std::runtime_error( + "Qiskit switch contains a repeated case label"); + } + } + } + break; + } + } + if (kind != ControlFlowKind::Switch && !switchCases.empty()) { + throw std::runtime_error( + "Qiskit non-switch control flow has switch-case metadata"); + } + if (kind == ControlFlowKind::IfElse || kind == ControlFlowKind::While || + kind == ControlFlowKind::Switch) { + validateTargetCaptures(target, clbits); + } + } + + [[nodiscard]] std::vector + pendingControlledUnitaryReplacements(const nb::handle pythonCircuit) const { + std::vector result; + result.reserve(pendingControlledUnitaries_.size()); auto data = pythonAttribute(pythonCircuit, "data", "Qiskit circuit has no instruction data"); const auto circuitQubits = pythonAttribute(pythonCircuit, "qubits", @@ -2017,7 +2570,270 @@ class NativeCircuitWriter final : public CircuitWriter { pythonAttribute(placeholder, "replace", "Qiskit unitary placeholder cannot be replaced")( nb::arg("operation") = controlled, nb::arg("qubits") = qargs); - data[pending.instructionIndex] = replacement; + result.push_back({.instructionIndex = pending.instructionIndex, + .instruction = replacement}); + } + return result; + } + + [[nodiscard]] static nb::object rebaseCircuit(const nb::handle circuit, + const nb::handle exactQubits, + const nb::handle exactClbits) { + if (nb::len(pythonAttribute(circuit, "qubits", + "Qiskit circuit has no qubits")) != + nb::len(exactQubits) || + nb::len(pythonAttribute(circuit, "clbits", + "Qiskit circuit has no classical bits")) != + nb::len(exactClbits)) { + throw std::runtime_error( + "Qiskit control-flow block has incompatible bit counts"); + } + const auto quantumCircuit = + nb::module_::import_("qiskit.circuit").attr("QuantumCircuit"); + auto rebased = quantumCircuit(); + if (nb::len(exactQubits) != 0U) { + pythonAttribute(rebased, "add_bits", + "Qiskit circuit cannot add captured qubits")(exactQubits); + } + if (nb::len(exactClbits) != 0U) { + pythonAttribute(rebased, "add_bits", + "Qiskit circuit cannot add captured classical bits")( + exactClbits); + } + pythonAttribute(rebased, "compose", + "Qiskit circuit cannot compose a control-flow block")( + circuit, + nb::arg("qubits") = pythonAttribute( + rebased, "qubits", "Qiskit rebased block has no qubits"), + nb::arg("clbits") = pythonAttribute( + rebased, "clbits", "Qiskit rebased block has no classical bits"), + nb::arg("inplace") = true); + return rebased; + } + + void finalizeControlFlowBlocks(const nb::handle pythonCircuit) { + const auto circuitQubits = pythonAttribute(pythonCircuit, "qubits", + "Qiskit circuit has no qubits"); + const auto circuitClbits = pythonAttribute( + pythonCircuit, "clbits", "Qiskit circuit has no classical bits"); + for (auto& pending : pendingControlFlow_) { + auto qargs = mappedBits(circuitQubits, pending.qubits, "qubit"); + auto cargs = mappedBits(circuitClbits, pending.clbits, "classical bit"); + std::vector blocks; + blocks.reserve(pending.blockWriters.size()); + for (size_t index = 0U; index < pending.blockWriters.size(); ++index) { + try { + auto* const writer = dynamic_cast( + pending.blockWriters[index].get()); + if (writer == nullptr) { + throw std::runtime_error( + "Qiskit control-flow blocks use an incompatible writer"); + } + blocks.emplace_back(writer->finishImpl(true, qargs, cargs)); + } catch (const std::exception& error) { + throw std::runtime_error( + "Qiskit failed to finalize control-flow block " + + std::to_string(index) + ": " + error.what()); + } + } + pending.blocks = std::move(blocks); + pending.blockWriters.clear(); + } + } + + static void collectCanonicalParameters(const nb::handle circuit, + PythonParameterMap& canonical, + const bool replace) { + const auto parameters = pythonAttribute( + circuit, "parameters", "Qiskit circuit has no parameter collection"); + std::vector values; + for (const nb::handle parameter : nb::iter(parameters)) { + values.emplace_back(nb::borrow(parameter)); + } + nb::dict replacements; + for (const auto& parameter : values) { + const auto name = pythonStringAttribute( + parameter, "name", "Qiskit circuit parameter has no name"); + const auto [found, inserted] = canonical.emplace(name, parameter); + if (!inserted && !found->second.is(parameter)) { + if (!replace) { + throw std::runtime_error( + "Qiskit native circuit contains distinct parameters named '" + + name + "'"); + } + replacements[parameter] = found->second; + } + } + if (replace && nb::len(replacements) != 0U) { + pythonAttribute(circuit, "assign_parameters", + "Qiskit circuit cannot replace parameters")( + replacements, nb::arg("inplace") = true); + } + } + + [[nodiscard]] PythonParameterMap + canonicalizeControlFlowParameters(const nb::handle pythonCircuit) { + PythonParameterMap canonical; + collectCanonicalParameters(pythonCircuit, canonical, false); + for (auto& pending : pendingControlFlow_) { + for (auto& block : pending.blocks) { + collectCanonicalParameters(block, canonical, true); + } + } + return canonical; + } + + [[nodiscard]] static nb::list mappedBits(const nb::handle bits, + const std::vector& indices, + const std::string_view kind) { + nb::list result; + for (const auto index : indices) { + if (index >= nb::len(bits)) { + throw std::runtime_error("Qiskit control flow references an invalid " + + std::string(kind)); + } + result.append(bits[index]); + } + return result; + } + + [[nodiscard]] static nb::object loopIndexSet(const Loop& loop) { + if (loop.isRange) { + return nb::module_::import_("builtins") + .attr("range")(loop.start, loop.stop, loop.step); + } + nb::list values; + for (const auto value : loop.values) { + values.append(nb::int_(value)); + } + return values; + } + + [[nodiscard]] static nb::object + constructControlFlowOperation(const PendingControlFlow& pending, + const PythonClassicalBuilder& classical, + const PythonParameterMap& parameters) { + const auto circuitModule = nb::module_::import_("qiskit.circuit"); + switch (pending.kind) { + case ControlFlowKind::IfElse: + return circuitModule.attr("IfElseOp")( + classical.condition(pending.target), pending.blocks.front(), + pending.blocks.size() == 2U ? pending.blocks[1] + : nb::borrow(nb::none())); + case ControlFlowKind::While: + return circuitModule.attr("WhileLoopOp")( + classical.condition(pending.target), pending.blocks.front()); + case ControlFlowKind::For: { + nb::object parameter = nb::none(); + if (pending.loop.parameter) { + const auto found = parameters.find(pending.loop.parameter->text); + if (found == parameters.end()) { + throw std::runtime_error( + "Qiskit for-loop parameter is absent from its body"); + } + parameter = found->second; + } + return circuitModule.attr("ForLoopOp")(loopIndexSet(pending.loop), + parameter, pending.blocks.front()); + } + case ControlFlowKind::Switch: { + nb::list cases; + for (size_t index = 0U; index < pending.switchCases.size(); ++index) { + const auto& switchCase = pending.switchCases[index]; + nb::object labels; + if (switchCase.isDefault) { + labels = nb::borrow(circuitModule.attr("CASE_DEFAULT")); + } else if (switchCase.labels.size() == 1U) { + labels = nb::int_(switchCase.labels.front()); + } else { + nb::list values; + for (const auto label : switchCase.labels) { + values.append(nb::int_(label)); + } + labels = std::move(values); + } + cases.append(nb::make_tuple(labels, pending.blocks[index])); + } + return circuitModule.attr("SwitchCaseOp")( + classical.switchTarget(pending.target), cases); + } + case ControlFlowKind::Box: + case ControlFlowKind::Break: + case ControlFlowKind::Continue: + break; + } + throw std::runtime_error( + "Qiskit circuit export encountered an unsupported control-flow kind"); + } + + [[nodiscard]] std::vector + pendingControlFlowInstructions(const nb::handle pythonCircuit, + const PythonParameterMap& parameters) const { + std::vector result; + result.reserve(pendingControlFlow_.size()); + const auto data = pythonAttribute(pythonCircuit, "data", + "Qiskit circuit has no instruction data"); + const auto circuitQubits = pythonAttribute(pythonCircuit, "qubits", + "Qiskit circuit has no qubits"); + const auto circuitClbits = pythonAttribute( + pythonCircuit, "clbits", "Qiskit circuit has no classical bits"); + const auto circuitInstruction = + nb::module_::import_("qiskit.circuit").attr("CircuitInstruction"); + const PythonClassicalBuilder classical(pythonCircuit); + for (const auto& pending : pendingControlFlow_) { + if (pending.instructionIndex > nb::len(data)) { + throw std::runtime_error( + "Qiskit control-flow insertion point is invalid"); + } + auto operation = + constructControlFlowOperation(pending, classical, parameters); + auto qargs = mappedBits(circuitQubits, pending.qubits, "qubit"); + auto cargs = mappedBits(circuitClbits, pending.clbits, "classical bit"); + if (pythonUnsignedAttribute(operation, "num_qubits", + "Qiskit control flow has no qubit count") != + pending.qubits.size() || + pythonUnsignedAttribute( + operation, "num_clbits", + "Qiskit control flow has no classical-bit count") != + pending.clbits.size()) { + throw std::runtime_error( + "Qiskit control-flow operation has incompatible bit counts"); + } + result.push_back( + {.instructionIndex = pending.instructionIndex, + .instruction = circuitInstruction(operation, qargs, cargs)}); + } + return result; + } + + static void applyPendingInstructions( + const nb::handle pythonCircuit, + const std::vector& unitaryReplacements, + const std::vector& controlFlowInstructions) { + auto data = pythonAttribute(pythonCircuit, "data", + "Qiskit circuit has no instruction data"); + for (const auto& replacement : unitaryReplacements) { + if (replacement.instructionIndex >= nb::len(data)) { + throw std::runtime_error( + "Qiskit controlled-unitary replacement point is invalid"); + } + data[replacement.instructionIndex] = replacement.instruction; + } + size_t inserted = 0U; + size_t previous = 0U; + bool first = true; + for (const auto& pending : controlFlowInstructions) { + if ((!first && pending.instructionIndex < previous) || + pending.instructionIndex + inserted > nb::len(data)) { + throw std::runtime_error( + "Qiskit control-flow instruction order is invalid"); + } + pythonAttribute(data, "insert", + "Qiskit circuit data does not support insertion")( + pending.instructionIndex + inserted, pending.instruction); + previous = pending.instructionIndex; + first = false; + ++inserted; } } @@ -2165,6 +2981,7 @@ class NativeCircuitWriter final : public CircuitWriter { QkCircuit* circuit_ = nullptr; std::vector pendingControlledUnitaries_; + std::vector pendingControlFlow_; std::unordered_map symbols_; }; diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index a96c35e725..5fe46f949e 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -26,6 +26,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -40,13 +43,16 @@ #include #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -60,6 +66,11 @@ namespace mqt::bindings::qiskit { namespace { +constexpr size_t MAX_EXPORT_CONTROL_FLOW_DEPTH = 64U; +constexpr size_t MAX_EXPORT_EXPRESSION_NODES = 4096U; + +struct ExportedControlFlow; + struct ExportedInstruction { enum class Kind : uint8_t { Gate, @@ -67,6 +78,7 @@ struct ExportedInstruction { Reset, Barrier, Unitary, + ControlFlow, }; Kind kind = Kind::Gate; StandardGateMapping gate; @@ -75,10 +87,30 @@ struct ExportedInstruction { std::vector parameters; std::vector> matrix; uint32_t unitaryControls = 0; + std::unique_ptr controlFlow; }; using ExportedParameters = llvm::DenseMap; +struct ExportedCircuit { + Parameter globalPhase{.kind = ParameterKind::Number, .number = 0.0}; + std::vector instructions; +}; + +struct ExportedControlFlow { + ControlFlowKind kind = ControlFlowKind::IfElse; + ClassicalTarget target; + Loop loop; + std::vector switchCases; + std::vector blocks; + std::vector qubits; + std::vector clbits; +}; + +struct ExportScope { + ExportedParameters parameters; +}; + [[noreturn]] void throwExportedParameterExpressionSizeError() { throw std::runtime_error("QC parameter expression exceeds the supported " + std::to_string(MAX_PARAMETER_EXPRESSION_NODES) + @@ -343,12 +375,15 @@ struct ExportState { llvm::DenseMap quantumBases; llvm::DenseMap quantumSizes; llvm::DenseMap classicalRegisterInfo; - std::vector instructions; + llvm::DenseMap> unconditionalWrites; + llvm::DenseMap> measurementDestinations; + llvm::DenseSet expressionOperations; std::vector quantumRegisters; std::vector classicalRegisters; ExportedParameters parameters; std::vector inputParameters; - Parameter globalPhase{.kind = ParameterKind::Number, .number = 0.0}; + llvm::StringSet<> parameterNames; + size_t nextLoopParameter = 0U; uint32_t numQubits = 0; uint32_t numClbits = 0; }; @@ -367,19 +402,34 @@ void collectParameterIdentities(const Parameter& parameter, } } -void validateExportParameters(const ExportState& state) { - llvm::StringSet<> usedIdentities; +void validateExportParameters(const ExportedCircuit& circuit, + llvm::StringSet<>& usedIdentities) { const auto validate = [&](const Parameter& parameter) { validateExportParameter(parameter); collectParameterIdentities(parameter, usedIdentities); }; - validate(state.globalPhase); - for (const auto& instruction : state.instructions) { + validate(circuit.globalPhase); + for (const auto& instruction : circuit.instructions) { for (const auto& parameter : instruction.parameters) { validate(parameter); } + if (!instruction.controlFlow) { + continue; + } + if (instruction.controlFlow->loop.parameter) { + validate(*instruction.controlFlow->loop.parameter); + } + for (const auto& block : instruction.controlFlow->blocks) { + validateExportParameters(block, usedIdentities); + } } - for (const auto& input : state.inputParameters) { +} + +void validateExportParameters(const ExportedCircuit& circuit, + const std::vector& inputs) { + llvm::StringSet<> usedIdentities; + validateExportParameters(circuit, usedIdentities); + for (const auto& input : inputs) { if (!usedIdentities.contains(input.identity)) { throw std::runtime_error( "Qiskit circuit export cannot preserve unused named f64 program " @@ -390,7 +440,6 @@ void validateExportParameters(const ExportState& state) { } void collectParameters(mlir::func::FuncOp function, ExportState& state) { - llvm::StringSet<> names; for (const auto [index, argument] : llvm::enumerate(function.getArguments())) { const auto name = function.getArgAttrOfType( @@ -404,7 +453,7 @@ void collectParameters(mlir::func::FuncOp function, ExportState& state) { "Qiskit circuit export does not support parameter names with null " "characters"); } - if (!names.insert(name.getValue()).second) { + if (!state.parameterNames.insert(name.getValue()).second) { throw std::runtime_error( "Qiskit circuit export requires unique parameter names"); } @@ -418,15 +467,15 @@ void collectParameters(mlir::func::FuncOp function, ExportState& state) { } } -void addGlobalPhase(ExportState& state, const Parameter& phase) { +void addGlobalPhase(ExportedCircuit& circuit, const Parameter& phase) { if (phase.kind == ParameterKind::Number) { if (!std::isfinite(phase.number)) { throw std::runtime_error( "QC global phase cannot be represented by Qiskit"); } - if (state.globalPhase.kind == ParameterKind::Number) { - state.globalPhase.number += phase.number; - if (!std::isfinite(state.globalPhase.number)) { + if (circuit.globalPhase.kind == ParameterKind::Number) { + circuit.globalPhase.number += phase.number; + if (!std::isfinite(circuit.globalPhase.number)) { throw std::runtime_error( "QC global phase cannot be represented by Qiskit"); } @@ -435,13 +484,13 @@ void addGlobalPhase(ExportState& state, const Parameter& phase) { if (std::abs(phase.number) <= mlir::utils::TOLERANCE) { return; } - } else if (state.globalPhase.kind == ParameterKind::Number && - std::abs(state.globalPhase.number) <= mlir::utils::TOLERANCE) { - state.globalPhase = phase; + } else if (circuit.globalPhase.kind == ParameterKind::Number && + std::abs(circuit.globalPhase.number) <= mlir::utils::TOLERANCE) { + circuit.globalPhase = phase; return; } - state.globalPhase = - binaryParameter(ParameterKind::Add, std::move(state.globalPhase), phase); + circuit.globalPhase = binaryParameter(ParameterKind::Add, + std::move(circuit.globalPhase), phase); } [[nodiscard]] std::vector @@ -766,11 +815,10 @@ void collectResources(mlir::func::FuncOp function, ExportState& state, "QC to Qiskit export encountered an unsupported memory allocation"); } } - for (auto& operation : function.getBody().front()) { - auto load = llvm::dyn_cast(operation); - if (!load || !llvm::isa(load.getResult().getType()) || + function.walk([&](mlir::memref::LoadOp load) { + if (!llvm::isa(load.getResult().getType()) || load.getIndices().size() != 1U) { - continue; + return; } const auto index = mlir::getConstantIntValue(load.getIndices().front()); if (!index) { @@ -789,7 +837,7 @@ void collectResources(mlir::func::FuncOp function, ExportState& state, "QC to Qiskit export encountered an out-of-bounds qubit index"); } state.qubits[load.getResult()] = checkedAdd(base->second, checked, "qubit"); - } + }); auto returnOp = llvm::dyn_cast(function.getBody().front().back()); @@ -797,12 +845,24 @@ void collectResources(mlir::func::FuncOp function, ExportState& state, throw std::runtime_error( "QC to Qiskit export requires an entry-function return"); } + if (returnOp.getNumOperands() == 1U) { + const auto result = returnOp.getOperand(0); + auto sentinel = result.getDefiningOp(); + const auto integer = + sentinel ? llvm::dyn_cast(sentinel.getValue()) + : mlir::IntegerAttr{}; + if (result.getType().isInteger(64) && integer && + integer.getValue().isZero()) { + return; + } + } llvm::DenseSet returnedRegisters; for (const auto result : returnOp.getOperands()) { const auto type = llvm::dyn_cast(result.getType()); if (!type) { - continue; + throw std::runtime_error( + "QC to Qiskit export supports only CBit function return values"); } if (!returnedRegisters.insert(result).second) { throw std::runtime_error( @@ -828,112 +888,1191 @@ void collectResources(mlir::func::FuncOp function, ExportState& state, } } -void collectFlatInstructions(mlir::func::FuncOp function, ExportState& state) { - llvm::DenseMap> writtenBits; - llvm::DenseMap measurementDestinations; +[[nodiscard]] std::optional +constantUnsignedInteger(const mlir::Value value) { + auto constant = value.getDefiningOp(); + const auto integer = + constant ? llvm::dyn_cast(constant.getValue()) + : mlir::IntegerAttr{}; + if (!integer || integer.getValue().getBitWidth() > 64U) { + return std::nullopt; + } + return integer.getValue().getZExtValue(); +} - for (auto store : function.getBody().front().getOps()) { - auto measure = store.getValue().getDefiningOp(); - if (!measure) { +void setExpressionType(Expression& expression, const mlir::Type type) { + if (type.isInteger(1)) { + expression.type = ClassicalType::Bool; + expression.width = 1U; + return; + } + if (const auto integer = llvm::dyn_cast(type)) { + if (integer.getWidth() == 0U || integer.getWidth() > 64U) { throw std::runtime_error( - "QC to Qiskit export does not support non-measurement classical " - "stores"); + "Qiskit unsigned classical values must be between 1 and 64 bits"); } - const auto info = state.classicalRegisterInfo.find(store.getReg()); - const auto index = mlir::getConstantIntValue(store.getIndex()); - if (info == state.classicalRegisterInfo.end()) { + expression.type = ClassicalType::Uint; + expression.width = integer.getWidth(); + return; + } + if (type.isF64()) { + expression.type = ClassicalType::Float; + expression.width = 64U; + return; + } + throw std::runtime_error( + "Qiskit classical expressions support only Bool, Uint, and Float"); +} + +[[nodiscard]] uint32_t classicalBitIndex(mlir::cbit::LoadOp load, + const ExportState& state) { + if (!load.getResult().getType().isInteger(1)) { + throw std::runtime_error( + "Qiskit classical expressions require a static classical-bit load"); + } + const auto info = state.classicalRegisterInfo.find(load.getReg()); + const auto index = mlir::getConstantIntValue(load.getIndex()); + if (info == state.classicalRegisterInfo.end() || !index) { + throw std::runtime_error( + "Qiskit classical expressions could not resolve a classical bit"); + } + const auto checked = checkedIndex(*index, "classical-bit"); + if (checked >= info->second.size) { + throw std::runtime_error( + "Qiskit classical expression uses an out-of-bounds classical bit"); + } + if (info->second.initialization != mlir::cbit::Initialization::Zero) { + const auto written = state.unconditionalWrites.find(load.getReg()); + if (written == state.unconditionalWrites.end() || + !written->second.contains(checked)) { throw std::runtime_error( - "QC measurement stores to a classical register that is not " - "returned"); + "Qiskit classical expression loads an undefined classical bit " + "before an unconditional measurement write"); } - if (!index) { + } + return checkedAdd(info->second.base, checked, "classical-bit"); +} + +[[nodiscard]] std::unique_ptr +makeBooleanUnary(const UnaryOperation operation, + std::unique_ptr operand, size_t& nodeCount) { + if (++nodeCount > MAX_EXPORT_EXPRESSION_NODES) { + throw std::runtime_error( + "QC classical expression exceeds the size limit of 4096 nodes"); + } + auto result = std::make_unique(); + result->kind = ExpressionKind::Unary; + result->type = ClassicalType::Bool; + result->width = 1U; + result->unaryOperation = operation; + result->left = std::move(operand); + return result; +} + +[[nodiscard]] std::unique_ptr +makeBooleanBinary(const BinaryOperation operation, + std::unique_ptr left, + std::unique_ptr right, size_t& nodeCount) { + if (++nodeCount > MAX_EXPORT_EXPRESSION_NODES) { + throw std::runtime_error( + "QC classical expression exceeds the size limit of 4096 nodes"); + } + auto result = std::make_unique(); + result->kind = ExpressionKind::Binary; + result->type = ClassicalType::Bool; + result->width = 1U; + result->binaryOperation = operation; + result->left = std::move(left); + result->right = std::move(right); + return result; +} + +[[nodiscard]] std::optional +constantBoolean(const std::unique_ptr& expression) { + if (expression && expression->kind == ExpressionKind::Value && + expression->type == ClassicalType::Bool) { + return expression->boolValue; + } + return std::nullopt; +} + +[[nodiscard]] std::unique_ptr +cloneExpression(const Expression& expression, size_t& nodeCount) { + if (++nodeCount > MAX_EXPORT_EXPRESSION_NODES) { + throw std::runtime_error( + "QC classical expression exceeds the size limit of 4096 nodes"); + } + auto result = std::make_unique(); + result->kind = expression.kind; + result->type = expression.type; + result->width = expression.width; + result->binaryOperation = expression.binaryOperation; + result->unaryOperation = expression.unaryOperation; + result->boolValue = expression.boolValue; + result->uintValue = expression.uintValue; + result->floatValue = expression.floatValue; + result->bit = expression.bit; + result->reg = expression.reg; + if (expression.left) { + result->left = cloneExpression(*expression.left, nodeCount); + } + if (expression.right) { + result->right = cloneExpression(*expression.right, nodeCount); + } + return result; +} + +[[nodiscard]] std::unique_ptr +makeBooleanSelect(std::unique_ptr condition, + std::unique_ptr thenValue, + std::unique_ptr elseValue, size_t& nodeCount) { + const auto thenConstant = constantBoolean(thenValue); + const auto elseConstant = constantBoolean(elseValue); + if (thenConstant && elseConstant) { + if (*thenConstant == *elseConstant) { + return std::move(thenValue); + } + if (*thenConstant) { + return condition; + } + return makeBooleanUnary(UnaryOperation::LogicNot, std::move(condition), + nodeCount); + } + if (elseConstant && !*elseConstant) { + return makeBooleanBinary(BinaryOperation::LogicAnd, std::move(condition), + std::move(thenValue), nodeCount); + } + if (elseConstant && *elseConstant) { + return makeBooleanBinary(BinaryOperation::LogicOr, + makeBooleanUnary(UnaryOperation::LogicNot, + std::move(condition), nodeCount), + std::move(thenValue), nodeCount); + } + if (thenConstant && *thenConstant) { + return makeBooleanBinary(BinaryOperation::LogicOr, std::move(condition), + std::move(elseValue), nodeCount); + } + if (thenConstant && !*thenConstant) { + return makeBooleanBinary(BinaryOperation::LogicAnd, + makeBooleanUnary(UnaryOperation::LogicNot, + std::move(condition), nodeCount), + std::move(elseValue), nodeCount); + } + auto negated = + makeBooleanUnary(UnaryOperation::LogicNot, + cloneExpression(*condition, nodeCount), nodeCount); + return makeBooleanBinary( + BinaryOperation::LogicOr, + makeBooleanBinary(BinaryOperation::LogicAnd, std::move(condition), + std::move(thenValue), nodeCount), + makeBooleanBinary(BinaryOperation::LogicAnd, std::move(negated), + std::move(elseValue), nodeCount), + nodeCount); +} + +struct PackedRegister { + Register reg; + llvm::SmallPtrSet operations; +}; + +[[nodiscard]] std::optional +matchPackedRegister(mlir::Value value, ExportState& state, + mlir::Block& evaluationBlock); + +[[nodiscard]] std::unique_ptr +exportExpressionImpl(mlir::Value value, ExportState& state, + mlir::Block& evaluationBlock, const size_t depth, + size_t& nodeCount) { + if (depth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { + throw std::runtime_error( + "QC classical expressions exceed the nesting limit of 64"); + } + if (++nodeCount > MAX_EXPORT_EXPRESSION_NODES) { + throw std::runtime_error( + "QC classical expression exceeds the size limit of 4096 nodes"); + } + auto* operation = value.getDefiningOp(); + if (operation == nullptr) { + throw std::runtime_error( + "Qiskit classical expressions cannot capture an SSA block argument"); + } + if (!llvm::isa(operation) && + operation->getBlock() != &evaluationBlock) { + throw std::runtime_error( + "Qiskit classical expressions cannot capture a computed SSA value " + "across a control-flow region"); + } + + auto result = std::make_unique(); + setExpressionType(*result, value.getType()); + if (result->type == ClassicalType::Uint) { + if (auto packed = matchPackedRegister(value, state, evaluationBlock)) { + result->kind = ExpressionKind::ClassicalRegister; + result->reg = std::move(packed->reg); + state.expressionOperations.insert(packed->operations.begin(), + packed->operations.end()); + return result; + } + } + if (auto constant = llvm::dyn_cast(operation)) { + result->kind = ExpressionKind::Value; + if (const auto integer = + llvm::dyn_cast(constant.getValue())) { + if (result->type == ClassicalType::Bool) { + result->boolValue = !integer.getValue().isZero(); + } else if (result->type == ClassicalType::Uint) { + result->uintValue = integer.getValue().getZExtValue(); + } else { + throw std::runtime_error( + "Qiskit Float expressions require a floating-point constant"); + } + state.expressionOperations.insert(operation); + return result; + } + const auto floating = llvm::dyn_cast(constant.getValue()); + if (!floating || result->type != ClassicalType::Float) { + throw std::runtime_error( + "Qiskit classical expression contains an unsupported constant"); + } + result->floatValue = floating.getValueAsDouble(); + if (!std::isfinite(result->floatValue)) { + throw std::runtime_error( + "Qiskit classical floating-point literals must be finite"); + } + state.expressionOperations.insert(operation); + return result; + } + if (auto load = llvm::dyn_cast(operation)) { + result->kind = ExpressionKind::ClassicalBit; + result->bit = classicalBitIndex(load, state); + state.expressionOperations.insert(operation); + return result; + } + if (auto ifOp = llvm::dyn_cast(operation)) { + if (ifOp.getNumResults() == 0U || + !llvm::all_of( + ifOp.getResultTypes(), + [](const mlir::Type type) { return type.isInteger(1); }) || + ifOp.getElseRegion().empty()) { throw std::runtime_error( - "QC measurement uses a dynamic classical destination"); + "Qiskit classical expressions support only Boolean scf.if " + "results with an else branch"); } - const auto checked = checkedIndex(*index, "classical-bit"); - if (checked >= info->second.size) { + const auto opResult = llvm::dyn_cast(value); + if (!opResult || opResult.getOwner() != operation) { throw std::runtime_error( - "QC measurement uses an out-of-bounds classical destination"); + "Qiskit classical expression does not refer to an scf.if result"); + } + const size_t resultIndex = opResult.getResultNumber(); + auto& thenBlock = ifOp.getThenRegion().front(); + auto& elseBlock = ifOp.getElseRegion().front(); + auto thenYield = + llvm::dyn_cast(thenBlock.getTerminator()); + auto elseYield = + llvm::dyn_cast(elseBlock.getTerminator()); + if (!thenYield || !elseYield || + thenYield.getNumOperands() != ifOp.getNumResults() || + elseYield.getNumOperands() != ifOp.getNumResults()) { + throw std::runtime_error( + "Qiskit Boolean scf.if expressions require one yielded value per " + "result in each branch"); + } + auto condition = exportExpressionImpl( + ifOp.getCondition(), state, *ifOp->getBlock(), depth + 1U, nodeCount); + std::unique_ptr thenValue; + std::unique_ptr elseValue; + for (const size_t index : llvm::seq(ifOp.getNumResults())) { + auto currentThen = exportExpressionImpl( + thenYield.getOperand(index), state, thenBlock, depth + 1U, nodeCount); + auto currentElse = exportExpressionImpl( + elseYield.getOperand(index), state, elseBlock, depth + 1U, nodeCount); + if (index == resultIndex) { + thenValue = std::move(currentThen); + elseValue = std::move(currentElse); + } } - if (!writtenBits[store.getReg()].insert(checked).second) { + const auto validateBranch = [&](mlir::Block& branch) { + for (auto& nested : branch.without_terminator()) { + if (!llvm::isa(nested) && + !state.expressionOperations.contains(&nested)) { + throw std::runtime_error( + "Qiskit Boolean scf.if expressions must be side-effect free"); + } + } + }; + validateBranch(thenBlock); + validateBranch(elseBlock); + if (!thenValue || !elseValue) { + throw std::runtime_error( + "Qiskit classical expression refers to an invalid scf.if result"); + } + state.expressionOperations.insert(operation); + return makeBooleanSelect(std::move(condition), std::move(thenValue), + std::move(elseValue), nodeCount); + } + + const auto unary = [&](const ExpressionKind kind, const mlir::Value operand) { + result->kind = kind; + result->left = exportExpressionImpl(operand, state, evaluationBlock, + depth + 1U, nodeCount); + state.expressionOperations.insert(operation); + return std::move(result); + }; + const auto binary = [&](const BinaryOperation kind, const mlir::Value left, + const mlir::Value right) { + result->kind = ExpressionKind::Binary; + result->binaryOperation = kind; + result->left = exportExpressionImpl(left, state, evaluationBlock, + depth + 1U, nodeCount); + result->right = exportExpressionImpl(right, state, evaluationBlock, + depth + 1U, nodeCount); + state.expressionOperations.insert(operation); + return std::move(result); + }; + + if (auto cast = llvm::dyn_cast(operation)) { + return unary(ExpressionKind::Cast, cast.getIn()); + } + if (auto cast = llvm::dyn_cast(operation)) { + return unary(ExpressionKind::Cast, cast.getIn()); + } + if (auto cast = llvm::dyn_cast(operation)) { + return unary(ExpressionKind::Cast, cast.getIn()); + } + if (auto cast = llvm::dyn_cast(operation)) { + return unary(ExpressionKind::Cast, cast.getIn()); + } + if (auto cast = llvm::dyn_cast(operation)) { + state.expressionOperations.insert(operation); + return exportExpressionImpl(cast.getIn(), state, evaluationBlock, + depth + 1U, nodeCount); + } + if (auto op = llvm::dyn_cast(operation)) { + auto kind = BinaryOperation::Equal; + switch (op.getPredicate()) { + case mlir::arith::CmpIPredicate::eq: + kind = BinaryOperation::Equal; + break; + case mlir::arith::CmpIPredicate::ne: + kind = BinaryOperation::NotEqual; + break; + case mlir::arith::CmpIPredicate::ult: + kind = BinaryOperation::Less; + break; + case mlir::arith::CmpIPredicate::ule: + kind = BinaryOperation::LessEqual; + break; + case mlir::arith::CmpIPredicate::ugt: + kind = BinaryOperation::Greater; + break; + case mlir::arith::CmpIPredicate::uge: + kind = BinaryOperation::GreaterEqual; + break; + default: throw std::runtime_error( - "QC to Qiskit export does not support duplicate classical " - "destinations"); + "Qiskit Uint expressions do not support signed comparisons"); } - if (!measurementDestinations.try_emplace(measure.getOperation(), store) - .second) { + return binary(kind, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + auto kind = BinaryOperation::Equal; + switch (op.getPredicate()) { + case mlir::arith::CmpFPredicate::OEQ: + kind = BinaryOperation::Equal; + break; + case mlir::arith::CmpFPredicate::UNE: + kind = BinaryOperation::NotEqual; + break; + case mlir::arith::CmpFPredicate::OLT: + kind = BinaryOperation::Less; + break; + case mlir::arith::CmpFPredicate::OLE: + kind = BinaryOperation::LessEqual; + break; + case mlir::arith::CmpFPredicate::OGT: + kind = BinaryOperation::Greater; + break; + case mlir::arith::CmpFPredicate::OGE: + kind = BinaryOperation::GreaterEqual; + break; + default: throw std::runtime_error( - "QC measurement has more than one classical destination"); + "Qiskit Float expressions require ordered comparisons"); } + return binary(kind, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(value.getType().isInteger(1) ? BinaryOperation::LogicAnd + : BinaryOperation::BitAnd, + op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(value.getType().isInteger(1) ? BinaryOperation::LogicOr + : BinaryOperation::BitOr, + op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::BitXor, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::ShiftLeft, op.getLhs(), op.getRhs()); } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::ShiftRight, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::Add, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::Subtract, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::Multiply, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::Divide, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::Add, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::Subtract, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::Multiply, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + return binary(BinaryOperation::Divide, op.getLhs(), op.getRhs()); + } + if (auto op = llvm::dyn_cast(operation)) { + result->unaryOperation = UnaryOperation::Negate; + return unary(ExpressionKind::Unary, op.getOperand()); + } + throw std::runtime_error( + "unsupported QC classical operation in Qiskit export: " + + operation->getName().getStringRef().str()); +} - for (auto& operation : function.getBody().front()) { - if (llvm::isa(operation)) { - continue; +[[nodiscard]] std::unique_ptr +exportExpression(mlir::Value value, ExportState& state, + mlir::Block& evaluationBlock) { + size_t nodeCount = 0U; + return exportExpressionImpl(value, state, evaluationBlock, 0U, nodeCount); +} + +[[nodiscard]] std::optional +matchPackedRegister(mlir::Value value, ExportState& state, + mlir::Block& evaluationBlock) { + auto type = llvm::dyn_cast(value.getType()); + if (!type || type.getWidth() == 0U || type.getWidth() > 64U) { + return std::nullopt; + } + std::vector> bits(type.getWidth()); + llvm::SmallPtrSet operations; + const std::function collect = + [&](const mlir::Value current, const uint32_t shift) { + auto* operation = current.getDefiningOp(); + if (operation == nullptr) { + return false; + } + if (auto constant = + llvm::dyn_cast(operation)) { + const auto integer = + llvm::dyn_cast(constant.getValue()); + if (!integer || !integer.getValue().isZero()) { + return false; + } + operations.insert(operation); + return true; + } + if (operation->getBlock() != &evaluationBlock) { + return false; + } + if (auto op = llvm::dyn_cast(operation)) { + operations.insert(operation); + return collect(op.getLhs(), shift) && collect(op.getRhs(), shift); + } + if (auto op = llvm::dyn_cast(operation)) { + const auto amount = constantUnsignedInteger(op.getRhs()); + if (!amount || *amount >= bits.size() || + *amount > std::numeric_limits::max() - shift) { + return false; + } + operations.insert(operation); + return collect(op.getLhs(), shift + static_cast(*amount)); + } + if (auto op = llvm::dyn_cast(operation)) { + operations.insert(operation); + return collect(op.getIn(), shift); + } + auto load = llvm::dyn_cast(operation); + if (!load || shift >= bits.size() || bits[shift]) { + return false; + } + try { + bits[shift] = classicalBitIndex(load, state); + } catch (const std::runtime_error&) { + return false; + } + operations.insert(operation); + return true; + }; + if (!collect(value, 0U) || + llvm::any_of(bits, [](const auto& bit) { return !bit.has_value(); })) { + return std::nullopt; + } + Register reg; + reg.bits.reserve(bits.size()); + llvm::DenseSet seenBits; + for (const auto bit : bits) { + if (!seenBits.insert(*bit).second) { + return std::nullopt; + } + reg.bits.push_back(*bit); + } + for (const auto& candidate : state.classicalRegisters) { + if (candidate.bits == reg.bits) { + reg.name = candidate.name; + break; + } + } + return PackedRegister{.reg = std::move(reg), + .operations = std::move(operations)}; +} + +void acceptPackedRegister(PackedRegister& packed, ExportState& state) { + state.expressionOperations.insert(packed.operations.begin(), + packed.operations.end()); +} + +[[nodiscard]] bool storesToValueRecursively(mlir::Operation& operation, + const mlir::Value value) { + bool stores = false; + operation.walk([&](mlir::Operation* nested) { + if (auto store = llvm::dyn_cast(nested); + store && store.getReg() == value) { + stores = true; + return mlir::WalkResult::interrupt(); + } + return mlir::WalkResult::advance(); + }); + return stores; +} + +void validateClassicalSnapshot(const mlir::Value expression, + mlir::Operation& consumer) { + llvm::DenseSet visited; + llvm::SmallVector loads; + const std::function collectLoads = + [&](const mlir::Value value) { + if (!visited.insert(value).second) { + return; + } + auto* operation = value.getDefiningOp(); + if (operation == nullptr) { + return; + } + if (auto load = llvm::dyn_cast(operation)) { + loads.push_back(load); + return; + } + if (auto ifOp = llvm::dyn_cast(operation); + ifOp && ifOp.getNumResults() != 0U) { + for (auto& region : ifOp->getRegions()) { + if (region.empty()) { + continue; + } + if (auto yield = llvm::dyn_cast( + region.front().getTerminator())) { + for (const auto yielded : yield.getOperands()) { + collectLoads(yielded); + } + } + } + } + for (const auto operand : operation->getOperands()) { + collectLoads(operand); + } + }; + collectLoads(expression); + for (auto load : loads) { + mlir::Operation* anchor = load; + auto* anchorBlock = load->getBlock(); + while (anchorBlock != consumer.getBlock()) { + auto* parent = anchorBlock->getParentOp(); + auto parentIf = llvm::dyn_cast_if_present(parent); + if (!parentIf || parentIf.getNumResults() == 0U) { + throw std::runtime_error( + "Qiskit control-flow expressions cannot capture a classical " + "snapshot across a region"); + } + anchor = parent; + anchorBlock = parent->getBlock(); + } + if (!anchor->isBeforeInBlock(&consumer)) { + throw std::runtime_error( + "Qiskit control-flow expressions cannot capture a classical " + "snapshot across a region"); + } + for (auto* operation = anchor->getNextNode(); operation != &consumer; + operation = operation->getNextNode()) { + if (operation == nullptr) { + throw std::runtime_error( + "Qiskit control-flow expression does not dominate its consumer"); + } + if (auto store = llvm::dyn_cast(operation); + store && store.getReg() == load.getReg()) { + throw std::runtime_error( + "Qiskit control-flow export cannot preserve a stale classical " + "snapshot"); + } + if (operation->getNumRegions() != 0U && + storesToValueRecursively(*operation, load.getReg())) { + throw std::runtime_error( + "Qiskit control-flow export cannot preserve a classical " + "snapshot across nested control flow"); + } } - if (auto alloc = llvm::dyn_cast(operation)) { - if (state.quantumBases.contains(alloc.getResult())) { + } +} + +[[nodiscard]] ClassicalTarget exportCondition(mlir::Value value, + ExportState& state, + mlir::Block& evaluationBlock, + mlir::Operation& consumer) { + if (!value.getType().isInteger(1)) { + throw std::runtime_error( + "Qiskit control-flow conditions must have Boolean type"); + } + validateClassicalSnapshot(value, consumer); + if (auto comparison = value.getDefiningOp(); + comparison && + comparison.getPredicate() == mlir::arith::CmpIPredicate::eq) { + for (const auto [actual, expected] : + std::array{std::pair{comparison.getLhs(), comparison.getRhs()}, + std::pair{comparison.getRhs(), comparison.getLhs()}}) { + const auto constant = constantUnsignedInteger(expected); + if (!constant) { continue; } + if (auto load = actual.getDefiningOp(); + load && actual.getType().isInteger(1) && *constant <= 1U) { + state.expressionOperations.insert(comparison); + state.expressionOperations.insert(expected.getDefiningOp()); + state.expressionOperations.insert(load); + return {.kind = ClassicalTargetKind::ClassicalBit, + .bit = classicalBitIndex(load, state), + .expectedBit = *constant != 0U}; + } + if (auto packed = matchPackedRegister(actual, state, evaluationBlock)) { + if (*constant >= (packed->reg.bits.size() == 64U + ? std::numeric_limits::max() + : uint64_t{1} << packed->reg.bits.size()) && + packed->reg.bits.size() != 64U) { + continue; + } + state.expressionOperations.insert(comparison); + state.expressionOperations.insert(expected.getDefiningOp()); + acceptPackedRegister(*packed, state); + return {.kind = ClassicalTargetKind::ClassicalRegister, + .reg = std::move(packed->reg), + .expectedRegister = *constant, + .width = + llvm::cast(actual.getType()).getWidth()}; + } + } + } + ClassicalTarget target{.kind = ClassicalTargetKind::Expression}; + target.expression = exportExpression(value, state, evaluationBlock); + return target; +} + +[[nodiscard]] ClassicalTarget exportSwitchTarget(mlir::Value value, + ExportState& state, + mlir::Block& evaluationBlock, + mlir::Operation& consumer) { + validateClassicalSnapshot(value, consumer); + if (auto cast = value.getDefiningOp()) { + state.expressionOperations.insert(cast); + value = cast.getIn(); + } + if (auto load = value.getDefiningOp(); + load && value.getType().isInteger(1)) { + state.expressionOperations.insert(load); + return {.kind = ClassicalTargetKind::ClassicalBit, + .bit = classicalBitIndex(load, state)}; + } + if (auto packed = matchPackedRegister(value, state, evaluationBlock)) { + acceptPackedRegister(*packed, state); + return {.kind = ClassicalTargetKind::ClassicalRegister, + .reg = std::move(packed->reg), + .width = llvm::cast(value.getType()).getWidth()}; + } + ClassicalTarget target{.kind = ClassicalTargetKind::Expression}; + target.expression = exportExpression(value, state, evaluationBlock); + if (target.expression->type == ClassicalType::Float) { + throw std::runtime_error("Qiskit switch targets must be Boolean or Uint"); + } + return target; +} + +[[nodiscard]] int64_t signedIntegerConstant(const mlir::Value value, + const std::string_view kind) { + auto constant = value.getDefiningOp(); + const auto integer = + constant ? llvm::dyn_cast(constant.getValue()) + : mlir::IntegerAttr{}; + if (!integer || integer.getValue().getBitWidth() > 64U) { + throw std::runtime_error(std::string(kind) + " must be a constant i64"); + } + return integer.getValue().getSExtValue(); +} + +[[nodiscard]] int64_t checkedAffine(const int64_t multiplier, + const int64_t value, const int64_t offset, + const std::string_view kind) { + const llvm::APInt wideMultiplier(128U, static_cast(multiplier), + true); + const llvm::APInt wideValue(128U, static_cast(value), true); + const llvm::APInt wideOffset(128U, static_cast(offset), true); + const auto result = (wideMultiplier * wideValue) + wideOffset; + if (!result.isSignedIntN(64U)) { + throw std::runtime_error(std::string(kind) + + " cannot be represented safely by Qiskit"); + } + return result.getSExtValue(); +} + +[[nodiscard]] uint64_t rangeLength(const int64_t lower, const int64_t upper, + const int64_t step) { + if (step <= 0) { + throw std::runtime_error( + "QC to Qiskit export requires a positive scf.for step"); + } + if (lower >= upper) { + return 0U; + } + const llvm::APInt lowerWide(65U, static_cast(lower), true); + const llvm::APInt upperWide(65U, static_cast(upper), true); + const llvm::APInt stepWide(65U, static_cast(step), true); + const auto count = ((upperWide - lowerWide - 1U).udiv(stepWide)) + 1U; + if (count.getActiveBits() > 64U) { + throw std::runtime_error("scf.for iteration count is too large for Qiskit"); + } + return count.getZExtValue(); +} + +struct LoopParameterProjection { + mlir::Value value; + int64_t multiplier = 1; + int64_t offset = 0; + llvm::SmallPtrSet operations; +}; + +[[nodiscard]] mlir::Operation* uniqueUser(const mlir::Value value) { + return value.hasOneUse() ? *value.getUsers().begin() : nullptr; +} + +[[nodiscard]] std::optional +matchLoopParameterProjection(mlir::scf::ForOp loop) { + auto* castOperation = uniqueUser(loop.getInductionVar()); + auto cast = + llvm::dyn_cast_if_present(castOperation); + if (!cast || !cast.getOut().getType().isInteger(64)) { + return std::nullopt; + } + LoopParameterProjection projection; + projection.operations.insert(castOperation); + auto current = cast.getOut(); + + if (auto* user = uniqueUser(current)) { + if (auto multiply = llvm::dyn_cast(user)) { + const auto other = + multiply.getLhs() == current ? multiply.getRhs() : multiply.getLhs(); + auto constant = other.getDefiningOp(); + if (!constant) { + return std::nullopt; + } + projection.multiplier = + signedIntegerConstant(other, "scf.for induction multiplier"); + projection.operations.insert(user); + current = multiply.getResult(); + } + } + if (auto* user = uniqueUser(current)) { + if (auto add = llvm::dyn_cast(user)) { + const auto other = add.getLhs() == current ? add.getRhs() : add.getLhs(); + auto constant = other.getDefiningOp(); + if (!constant) { + return std::nullopt; + } + projection.offset = + signedIntegerConstant(other, "scf.for induction offset"); + projection.operations.insert(user); + current = add.getResult(); + } + } + auto* conversionOperation = uniqueUser(current); + auto conversion = + llvm::dyn_cast_if_present(conversionOperation); + if (!conversion || !conversion.getOut().getType().isF64()) { + return std::nullopt; + } + projection.operations.insert(conversionOperation); + projection.value = conversion.getOut(); + return projection; +} + +[[nodiscard]] ExportedCircuit +collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, + size_t controlFlowDepth, bool topLevel); + +[[nodiscard]] std::vector allIndices(const uint32_t size) { + std::vector result(size); + std::iota(result.begin(), result.end(), 0U); + return result; +} + +[[nodiscard]] bool isFusableMeasurementStore(mlir::qc::MeasureOp measure, + mlir::cbit::StoreOp store) { + if (!measure.getResult().hasOneUse() || + store.getValue() != measure.getResult() || + measure->getBlock() != store->getBlock()) { + return false; + } + const auto index = mlir::getConstantIntValue(store.getIndex()); + if (!index) { + return false; + } + for (auto* operation = measure->getNextNode(); operation != store; + operation = operation->getNextNode()) { + if (operation == nullptr || + !llvm::isa(operation)) { + return false; + } + } + return true; +} + +void validateExpressionBlock(mlir::Block& block, const ExportState& state) { + for (auto& operation : block.without_terminator()) { + if (llvm::isa(operation) || + state.expressionOperations.contains(&operation)) { + continue; + } + throw std::runtime_error( + "Qiskit while-loop condition regions must contain only classical " + "expression operations"); + } +} + +[[nodiscard]] std::unique_ptr +collectIf(mlir::scf::IfOp ifOp, ExportState& state, const ExportScope& scope, + const size_t controlFlowDepth) { + if (ifOp.getNumResults() != 0U) { + throw std::runtime_error( + "Qiskit if/else export does not support SSA results"); + } + if (controlFlowDepth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { + throw std::runtime_error("QC control flow exceeds the nesting limit of 64"); + } + auto result = std::make_unique(); + result->kind = ControlFlowKind::IfElse; + result->target = exportCondition(ifOp.getCondition(), state, + *ifOp->getBlock(), *ifOp.getOperation()); + result->blocks.push_back(collectBlock(ifOp.getThenRegion().front(), state, + scope, controlFlowDepth + 1U, false)); + if (!ifOp.getElseRegion().empty()) { + result->blocks.push_back(collectBlock(ifOp.getElseRegion().front(), state, + scope, controlFlowDepth + 1U, false)); + } + result->qubits = allIndices(state.numQubits); + result->clbits = allIndices(state.numClbits); + return result; +} + +[[nodiscard]] std::unique_ptr +collectFor(mlir::scf::ForOp loop, ExportState& state, const ExportScope& scope, + const size_t controlFlowDepth) { + if (!loop.getInitArgs().empty() || loop.getNumResults() != 0U) { + throw std::runtime_error( + "Qiskit for-loop export does not support loop-carried values"); + } + if (controlFlowDepth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { + throw std::runtime_error("QC control flow exceeds the nesting limit of 64"); + } + const auto lower = mlir::getConstantIntValue(loop.getLowerBound()); + const auto upper = mlir::getConstantIntValue(loop.getUpperBound()); + const auto step = mlir::getConstantIntValue(loop.getStep()); + if (!lower || !upper || !step || *step <= 0) { + throw std::runtime_error( + "Qiskit for-loop export requires constant bounds and a positive step"); + } + + auto result = std::make_unique(); + result->kind = ControlFlowKind::For; + result->loop = { + .isRange = true, .start = *lower, .stop = *upper, .step = *step}; + auto bodyScope = scope; + if (!loop.getInductionVar().use_empty()) { + auto projection = matchLoopParameterProjection(loop); + if (!projection) { throw std::runtime_error( - "QC to Qiskit export encountered an unsupported memory allocation"); + "Qiskit for-loop export supports only a loop induction value used " + "as an f64 gate parameter"); + } + state.expressionOperations.insert(projection->operations.begin(), + projection->operations.end()); + if (projection->value.use_empty()) { + result->blocks.push_back(collectBlock(*loop.getBody(), state, bodyScope, + controlFlowDepth + 1U, false)); + result->qubits = allIndices(state.numQubits); + result->clbits = allIndices(state.numClbits); + return result; + } + std::string symbol; + size_t identity = 0U; + do { + identity = state.nextLoopParameter++; + symbol = "_mqt_loop_" + std::to_string(identity); + } while (state.parameterNames.contains(symbol)); + state.parameterNames.insert(symbol); + const Parameter loopParameter{ + .kind = ParameterKind::Symbol, + .text = symbol, + .identity = "loop:" + std::to_string(identity), + }; + result->loop.parameter = loopParameter; + bodyScope.parameters[projection->value] = loopParameter; + const auto count = rangeLength(*lower, *upper, *step); + if (count == 0U) { + result->loop.start = 0; + result->loop.stop = 0; + result->loop.step = 1; + } else { + result->loop.start = + checkedAffine(projection->multiplier, *lower, projection->offset, + "scf.for induction start"); + result->loop.step = checkedAffine(projection->multiplier, *step, 0, + "scf.for induction step"); + if (result->loop.step == 0) { + throw std::runtime_error( + "Qiskit for-loop export cannot represent a constant induction " + "projection"); + } + if (count > static_cast(std::numeric_limits::max())) { + throw std::runtime_error( + "scf.for iteration count is too large for Qiskit"); + } + result->loop.stop = + checkedAffine(result->loop.step, static_cast(count), + result->loop.start, "scf.for induction stop"); + } + } + result->blocks.push_back(collectBlock(*loop.getBody(), state, bodyScope, + controlFlowDepth + 1U, false)); + result->qubits = allIndices(state.numQubits); + result->clbits = allIndices(state.numClbits); + return result; +} + +[[nodiscard]] uint32_t switchTargetWidth(const ClassicalTarget& target) { + switch (target.kind) { + case ClassicalTargetKind::ClassicalBit: + return 1U; + case ClassicalTargetKind::ClassicalRegister: + return target.width; + case ClassicalTargetKind::Expression: + if (target.expression) { + return target.expression->width; } + break; + } + throw std::runtime_error("Qiskit switch export has no target expression"); +} + +[[nodiscard]] std::unique_ptr +collectWhile(mlir::scf::WhileOp loop, ExportState& state, + const ExportScope& scope, const size_t controlFlowDepth) { + if (controlFlowDepth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { + throw std::runtime_error("QC control flow exceeds the nesting limit of 64"); + } + auto& before = loop.getBefore().front(); + auto& after = loop.getAfter().front(); + auto condition = + llvm::dyn_cast(before.getTerminator()); + auto yield = llvm::dyn_cast(after.getTerminator()); + if (!loop.getInits().empty() || loop.getNumResults() != 0U || + before.getNumArguments() != 0U || after.getNumArguments() != 0U || + !condition || !condition.getArgs().empty() || !yield || + yield.getNumOperands() != 0U) { + throw std::runtime_error( + "Qiskit while-loop export does not support loop-carried values"); + } + auto result = std::make_unique(); + result->kind = ControlFlowKind::While; + result->target = exportCondition(condition.getCondition(), state, before, + *condition.getOperation()); + validateExpressionBlock(before, state); + result->blocks.push_back( + collectBlock(after, state, scope, controlFlowDepth + 1U, false)); + result->qubits = allIndices(state.numQubits); + result->clbits = allIndices(state.numClbits); + return result; +} + +[[nodiscard]] std::unique_ptr +collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportState& state, + const ExportScope& scope, const size_t controlFlowDepth) { + if (switchOp.getNumResults() != 0U) { + throw std::runtime_error( + "Qiskit switch export does not support SSA results"); + } + if (controlFlowDepth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { + throw std::runtime_error("QC control flow exceeds the nesting limit of 64"); + } + auto result = std::make_unique(); + result->kind = ControlFlowKind::Switch; + result->target = + exportSwitchTarget(switchOp.getArg(), state, *switchOp->getBlock(), + *switchOp.getOperation()); + const uint32_t targetWidth = switchTargetWidth(result->target); + for (const auto [index, label] : llvm::enumerate(switchOp.getCases())) { + if (label < 0) { + throw std::runtime_error( + "Qiskit switch export does not support negative case labels"); + } + if (targetWidth < 64U && + static_cast(label) >= (uint64_t{1} << targetWidth)) { + throw std::runtime_error("Qiskit switch case label " + + std::to_string(label) + " does not fit the " + + std::to_string(targetWidth) + "-bit target"); + } + result->switchCases.push_back({.labels = {static_cast(label)}}); + result->blocks.push_back( + collectBlock(switchOp.getCaseRegions()[index].front(), state, scope, + controlFlowDepth + 1U, false)); + } + result->switchCases.push_back({.isDefault = true}); + result->blocks.push_back(collectBlock(switchOp.getDefaultRegion().front(), + state, scope, controlFlowDepth + 1U, + false)); + result->qubits = allIndices(state.numQubits); + result->clbits = allIndices(state.numClbits); + return result; +} + +[[nodiscard]] ExportedCircuit +collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, + const size_t controlFlowDepth, const bool topLevel) { + ExportedCircuit circuit; + llvm::SmallVector deferredExpressions; + for (auto& operation : block) { if (llvm::isa(operation) || isParameterExpressionOperation(operation)) { continue; } + if (llvm::isa(operation)) { + if (!topLevel) { + throw std::runtime_error( + "Qiskit control-flow blocks cannot allocate or release circuit " + "resources"); + } + continue; + } if (auto load = llvm::dyn_cast(operation)) { if (state.qubits.contains(load.getResult())) { continue; } - throw std::runtime_error( - "QC to Qiskit export does not support classical or unknown memory " - "loads"); + deferredExpressions.push_back(&operation); + continue; + } + if (auto load = llvm::dyn_cast(operation)) { + static_cast(classicalBitIndex(load, state)); + deferredExpressions.push_back(&operation); + continue; } if (auto dealloc = llvm::dyn_cast(operation)) { - if (state.quantumBases.contains(dealloc.getMemref())) { + if (topLevel && state.quantumBases.contains(dealloc.getMemref())) { continue; } throw std::runtime_error( "QC to Qiskit export encountered an unsupported memory deallocation"); } - if (llvm::isa(operation)) { - throw std::runtime_error( - "QC to Qiskit export does not support classical loads or control " - "flow"); - } - if (llvm::isa(operation)) { - continue; - } - if (llvm::isa(operation)) { + if (auto store = llvm::dyn_cast(operation)) { + auto measure = store.getValue().getDefiningOp(); + if (!measure || !isFusableMeasurementStore(measure, store)) { + throw std::runtime_error( + "QC to Qiskit export does not support non-measurement classical " + "stores"); + } + const auto info = state.classicalRegisterInfo.find(store.getReg()); + const auto index = mlir::getConstantIntValue(store.getIndex()); + if (info == state.classicalRegisterInfo.end() || !index) { + throw std::runtime_error( + "QC measurement uses an unsupported classical destination"); + } + const auto checked = checkedIndex(*index, "classical-bit"); + if (checked >= info->second.size) { + throw std::runtime_error( + "QC measurement uses an out-of-bounds classical destination"); + } + if (!state.measurementDestinations[store.getReg()] + .insert(checked) + .second) { + throw std::runtime_error( + "QC to Qiskit export does not support duplicate classical " + "destinations"); + } + if (topLevel) { + state.unconditionalWrites[store.getReg()].insert(checked); + } continue; } if (auto phase = llvm::dyn_cast(operation)) { - addGlobalPhase(state, - exportParameter(phase.getTheta(), state.parameters)); + addGlobalPhase(circuit, + exportParameter(phase.getTheta(), scope.parameters)); continue; } if (auto measure = llvm::dyn_cast(operation)) { - const auto destination = - measurementDestinations.find(measure.getOperation()); - if (destination == measurementDestinations.end()) { + mlir::cbit::StoreOp destination; + for (auto& use : measure.getResult().getUses()) { + if (const auto store = + llvm::dyn_cast(use.getOwner())) { + if (destination) { + throw std::runtime_error( + "QC measurement has more than one classical destination"); + } + destination = store; + } + } + if (!destination) { throw std::runtime_error( "QC measurement is missing a static classical destination"); } - auto store = destination->second; - const auto info = state.classicalRegisterInfo.find(store.getReg()); - const auto index = mlir::getConstantIntValue(store.getIndex()); - if (info == state.classicalRegisterInfo.end() || !index) { + const auto info = state.classicalRegisterInfo.find(destination.getReg()); + const auto index = mlir::getConstantIntValue(destination.getIndex()); + if (info == state.classicalRegisterInfo.end()) { throw std::runtime_error( "QC measurement uses an unsupported classical destination"); } + if (!index) { + throw std::runtime_error( + "QC measurement uses a dynamic classical destination"); + } + if (!isFusableMeasurementStore(measure, destination)) { + throw std::runtime_error( + "QC measurement destination must follow the measurement in the " + "same block"); + } const auto checked = checkedIndex(*index, "classical-bit"); if (checked >= info->second.size) { throw std::runtime_error( "QC measurement uses an out-of-bounds classical destination"); } - state.instructions.push_back( + circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::Measure, .qubits = mapQubits(measure.getQubit(), state.qubits), .clbits = { @@ -941,31 +2080,74 @@ void collectFlatInstructions(mlir::func::FuncOp function, ExportState& state) { continue; } if (auto reset = llvm::dyn_cast(operation)) { - state.instructions.push_back( + circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::Reset, .qubits = mapQubits(reset.getQubit(), state.qubits)}); continue; } if (auto barrier = llvm::dyn_cast(operation)) { - state.instructions.push_back( + circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::Barrier, .qubits = mapQubits(barrier.getQubits(), state.qubits)}); continue; } if (llvm::isa(operation)) { - state.instructions.push_back( - collectUnitaryInstruction(operation, state.qubits, state.parameters)); + circuit.instructions.push_back( + collectUnitaryInstruction(operation, state.qubits, scope.parameters)); continue; } - if (llvm::isa(operation)) { - throw std::runtime_error( - "QC to Qiskit export cannot construct structured control flow " - "through the Qiskit 2.5 C API"); + if (auto ifOp = llvm::dyn_cast(operation)) { + if (ifOp.getNumResults() != 0U) { + if (!llvm::all_of(ifOp.getResultTypes(), [](const mlir::Type type) { + return type.isInteger(1); + })) { + throw std::runtime_error( + "Qiskit if/else export does not support SSA results except as " + "a Boolean classical expression"); + } + deferredExpressions.push_back(&operation); + continue; + } + circuit.instructions.push_back( + {.kind = ExportedInstruction::Kind::ControlFlow, + .controlFlow = collectIf(ifOp, state, scope, controlFlowDepth)}); + continue; + } + if (auto loop = llvm::dyn_cast(operation)) { + circuit.instructions.push_back( + {.kind = ExportedInstruction::Kind::ControlFlow, + .controlFlow = collectFor(loop, state, scope, controlFlowDepth)}); + continue; + } + if (auto loop = llvm::dyn_cast(operation)) { + circuit.instructions.push_back( + {.kind = ExportedInstruction::Kind::ControlFlow, + .controlFlow = collectWhile(loop, state, scope, controlFlowDepth)}); + continue; + } + if (auto switchOp = llvm::dyn_cast(operation)) { + circuit.instructions.push_back( + {.kind = ExportedInstruction::Kind::ControlFlow, + .controlFlow = + collectSwitch(switchOp, state, scope, controlFlowDepth)}); + continue; } if (llvm::isa(operation)) { - state.instructions.push_back( - collectUnitaryInstruction(operation, state.qubits, state.parameters)); + circuit.instructions.push_back( + collectUnitaryInstruction(operation, state.qubits, scope.parameters)); + continue; + } + if (llvm::isa(operation)) { + auto yield = llvm::cast(operation); + if (yield.getNumOperands() != 0U) { + throw std::runtime_error( + "Qiskit control-flow export does not support yielded SSA values"); + } + continue; + } + if (operation.getDialect() == + operation.getContext()->getLoadedDialect()) { + deferredExpressions.push_back(&operation); continue; } if (operation.getNumResults() == 1U && @@ -977,14 +2159,81 @@ void collectFlatInstructions(mlir::func::FuncOp function, ExportState& state) { throw std::runtime_error("unsupported QC operation in Qiskit export: " + operation.getName().getStringRef().str()); } + for (auto* operation : deferredExpressions) { + if (!state.expressionOperations.contains(operation)) { + throw std::runtime_error( + "QC to Qiskit export found classical execution outside a supported " + "control-flow expression"); + } + } + return circuit; +} - for (const auto& [reg, info] : state.classicalRegisterInfo) { - if (info.initialization == mlir::cbit::Initialization::Zero) { +void validateConstructibleGates(const ExportedCircuit& circuit, + const VersionedTranslation& translation) { + for (const auto& instruction : circuit.instructions) { + if (instruction.kind == ExportedInstruction::Kind::Gate && + !translation.supportsGate(instruction.gate)) { + const auto& descriptor = + mlir::qc::getStandardGateDescriptor(instruction.gate.gate); + throw std::runtime_error( + "Qiskit output cannot construct standard gate '" + + descriptor.operationSymbol.str() + "' with " + + std::to_string(instruction.gate.controls) + " controls"); + } + if (instruction.kind != ExportedInstruction::Kind::ControlFlow || + !instruction.controlFlow) { continue; } - if (writtenBits[reg].size() != info.size) { - throw std::runtime_error( - "QC to Qiskit export cannot return undefined classical bits"); + for (const auto& block : instruction.controlFlow->blocks) { + validateConstructibleGates(block, translation); + } + } +} + +void emitCircuit(ExportedCircuit& circuit, CircuitWriter& writer, + const VersionedTranslation& translation) { + writer.setGlobalPhase(circuit.globalPhase); + for (auto& instruction : circuit.instructions) { + switch (instruction.kind) { + case ExportedInstruction::Kind::Gate: + writer.addGate(instruction.gate, instruction.qubits, + instruction.parameters); + break; + case ExportedInstruction::Kind::Measure: + writer.addMeasure(instruction.qubits.at(0), instruction.clbits.at(0)); + break; + case ExportedInstruction::Kind::Reset: + writer.addReset(instruction.qubits.at(0)); + break; + case ExportedInstruction::Kind::Barrier: + writer.addBarrier(instruction.qubits); + break; + case ExportedInstruction::Kind::Unitary: + writer.addUnitary(instruction.matrix, instruction.qubits, + instruction.unitaryControls); + break; + case ExportedInstruction::Kind::ControlFlow: { + if (!instruction.controlFlow) { + throw std::runtime_error( + "Qiskit export encountered an empty control-flow plan"); + } + auto& control = *instruction.controlFlow; + std::vector> blocks; + blocks.reserve(control.blocks.size()); + for (auto& block : control.blocks) { + auto blockWriter = translation.createCircuit( + static_cast(control.qubits.size()), + static_cast(control.clbits.size())); + emitCircuit(block, *blockWriter, translation); + blocks.push_back(std::move(blockWriter)); + } + writer.addControlFlow(control.kind, std::move(control.target), + std::move(control.loop), + std::move(control.switchCases), std::move(blocks), + control.qubits, control.clbits); + break; + } } } } @@ -1012,8 +2261,21 @@ nb::object exportCircuit(const mlir::QCProgram& program, "target qubit count"); } collectResources(function, state, target); - collectFlatInstructions(function, state); - validateExportParameters(state); + const ExportScope rootScope{.parameters = state.parameters}; + auto circuit = + collectBlock(function.getBody().front(), state, rootScope, 0U, true); + for (const auto& [reg, info] : state.classicalRegisterInfo) { + if (info.initialization == mlir::cbit::Initialization::Zero) { + continue; + } + const auto written = state.unconditionalWrites.find(reg); + if (written == state.unconditionalWrites.end() || + written->second.size() != info.size) { + throw std::runtime_error( + "QC to Qiskit export cannot return undefined classical bits"); + } + } + validateExportParameters(circuit, state.inputParameters); if (target != nullptr) { Register reg{.name = "q"}; reg.bits.resize(state.numQubits); @@ -1026,18 +2288,7 @@ nb::object exportCircuit(const mlir::QCProgram& program, state.numClbits, "classical"); auto translation = selectTranslation(); - for (const auto& instruction : state.instructions) { - if (instruction.kind != ExportedInstruction::Kind::Gate || - translation->supportsGate(instruction.gate)) { - continue; - } - const auto& descriptor = - mlir::qc::getStandardGateDescriptor(instruction.gate.gate); - throw std::runtime_error("Qiskit output cannot construct standard gate '" + - descriptor.operationSymbol.str() + "' with " + - std::to_string(instruction.gate.controls) + - " controls"); - } + validateConstructibleGates(circuit, *translation); auto writer = translation->createCircuit(looseQubits, looseClbits); for (const auto& reg : state.quantumRegisters) { writer->addQuantumRegister(reg.name, @@ -1047,28 +2298,7 @@ nb::object exportCircuit(const mlir::QCProgram& program, writer->addClassicalRegister(reg.name, static_cast(reg.bits.size())); } - writer->setGlobalPhase(state.globalPhase); - for (const auto& instruction : state.instructions) { - switch (instruction.kind) { - case ExportedInstruction::Kind::Gate: - writer->addGate(instruction.gate, instruction.qubits, - instruction.parameters); - break; - case ExportedInstruction::Kind::Measure: - writer->addMeasure(instruction.qubits.at(0), instruction.clbits.at(0)); - break; - case ExportedInstruction::Kind::Reset: - writer->addReset(instruction.qubits.at(0)); - break; - case ExportedInstruction::Kind::Barrier: - writer->addBarrier(instruction.qubits); - break; - case ExportedInstruction::Kind::Unitary: - writer->addUnitary(instruction.matrix, instruction.qubits, - instruction.unitaryControls); - break; - } - } + emitCircuit(circuit, *writer, *translation); return writer->finish(); } diff --git a/bindings/mlir/qiskit/QiskitTranslation.h b/bindings/mlir/qiskit/QiskitTranslation.h index 733cb913a6..150fea8b61 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -284,6 +284,12 @@ class CircuitWriter { virtual void addUnitary(const std::vector>& matrix, const std::vector& qubits, uint32_t numControls) = 0; + virtual void + addControlFlow(ControlFlowKind kind, ClassicalTarget target, Loop loop, + std::vector switchCases, + std::vector> blocks, + const std::vector& qubits, + const std::vector& clbits) = 0; /** Transfer the native circuit to a new owned Python QuantumCircuit. */ [[nodiscard]] virtual nb::object finish() = 0; }; diff --git a/docs/mlir/python_compiler_collection.md b/docs/mlir/python_compiler_collection.md index ed65eafc96..f8e2652d99 100644 --- a/docs/mlir/python_compiler_collection.md +++ b/docs/mlir/python_compiler_collection.md @@ -159,8 +159,14 @@ This compiler route does not construct an intermediate interfaces remain independent and retain their existing version range and behavior. -Import and export have different contracts because Qiskit 2.5 can inspect more -program structures than its C API can construct. +The version-specific adapter uses Qiskit's native C API for flat circuit +construction. Qiskit 2.5 provides C inspection functions, but no C constructors +for classical expressions or structured control flow. During export, the adapter +finalizes each validated block independently and then uses Qiskit's public +Python classes to construct and insert the control-flow operations at their +recorded positions. This post-processing is confined to the Qiskit 2.5 adapter +in {code}`bindings/mlir/qiskit/Qiskit2_5.cpp`; the generic translation remains +frontend-neutral. | Circuit feature | Import | Export | | ----------------------------------------------------------------- | -------------------- | -------------- | @@ -169,10 +175,10 @@ program structures than its C API can construct. | Measurement, reset, and barrier | Supported | Supported | | Canonical named registers and leading loose bits | Supported | Supported | | Custom instructions with finite, acyclic definitions | Recursively expanded | Not applicable | -| Nested `if`/`else`, `for`, `while`, and `switch` | Supported | Rejected | -| Classical-bit and register conditions | Supported | Rejected | -| Constant Boolean, `Uint` up to 64 bits, and `Float` expressions | Supported | Rejected | -| Clbit and ClassicalRegister expression variables | Supported | Rejected | +| Nested `if`/`else`, `for`, `while`, and `switch` | Supported | Supported | +| Classical-bit and register conditions | Supported | Supported | +| Constant Boolean, `Uint` up to 64 bits, and `Float` expressions | Supported | Supported | +| Clbit and ClassicalRegister expression variables | Supported | Supported | | Standalone classical runtime variables | Rejected | Rejected | | Free symbols and supported real parameter expressions | Supported | Supported | | Parameter-vector elements | Rejected | Not emitted | @@ -199,6 +205,38 @@ after their symbols and expressions are resolved. Definition expansion rejects missing definitions, cycles, operand arity mismatches, nesting beyond 64 levels, and more than 10 million expanded operations. +Structured-control export accepts result-free {code}`scf.if`, constant-range +{code}`scf.for` without loop-carried values, expression-based {code}`scf.while` +without carried state, and result-free {code}`scf.index_switch`. A pure +result-bearing {code}`scf.if` is accepted only when every result is Boolean and +both branches contain expression operations. A live {code}`scf.for` induction +value must reduce to an affine {code}`f64` gate parameter. The exporter +preserves one Qiskit parameter identity for that value throughout its lexical +body. Switch labels must be nonnegative constants that fit the target width. + +Nested blocks may capture existing qubits and classical bits but may not +allocate or release circuit resources. Control flow and classical expressions +may nest up to 64 levels, and expression trees may contain at most 4,096 nodes. +Boolean, unsigned-integer up to 64 bits, and floating-point expression +operations must have a direct Qiskit equivalent. Unsupported operations, signed +interpretations, invalid widths, non-finite constants, dynamic bounds, +loop-carried values, and other SSA results fail during validation. The sole +exception is Core's canonical constant-zero `i64` exit-code sentinel for a +circuit without classical outputs. + +Conditions and switch targets may read a zero-initialized public CBit register. +An undefined public CBit may be read only after an unconditional top-level +measurement write to that bit, and every bit of an undefined returned register +must be written unconditionally. Branch-local writes do not establish definite +initialization. A captured classical snapshot must not cross a later CBit write +or a nested write to the same register. + +Each exported measurement must write to one static public CBit in the same +block, and destinations must be unique. Its destination store must follow the +measurement directly, apart from constant operations. A conditional or otherwise +delayed destination store is rejected because Qiskit cannot preserve it as one +measurement instruction. + Dense numeric unitaries remain explicit matrix operations during import and export. Target compilation synthesizes supported one- and two-qubit matrices to the target gate set. Dense unitary operations support at most eight qubits. @@ -210,9 +248,11 @@ A circuit remains valid when {code}`circ.layout` is present. The importer translates the circuit operations and deliberately does not preserve physical or virtual layout metadata. -Input validation finishes before an MLIR module is created. Output validation -finishes before a Qiskit circuit is allocated. Unsupported programs therefore -fail without modifying the source object or exposing a partial result. +Input validation finishes before an MLIR module is created. Generic output +validation finishes before Qiskit construction starts; the version-specific +adapter validates its constructed blocks before returning the top-level circuit. +Unsupported programs therefore fail without modifying the source object or +exposing a partial result. The binding imports Qiskit only when circuit translation is requested. It accepts versions in the registered {code}`>=2.5.0,<2.6.0` range and verifies the diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index e9dd5ba332..ab17f970a2 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -579,6 +579,92 @@ def test_flat_export_rejects_undefined_returned_bits() -> None: program.to_qiskit() +def test_qiskit_export_accepts_canonical_zero_output_sentinel() -> None: + """Accept the sole constant-zero i64 result used for circuits without Clbits.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> i64 attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + qc.x %q : !qc.qubit + %zero = arith.constant 0 : i64 + qc.dealloc %q : !qc.qubit + return %zero : i64 + } +} +""" + ) + source = program.ir + + restored = program.to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == ["x"] + assert program.ir == source + + +def test_qiskit_export_rejects_float_function_result() -> None: + """Reject a non-CBit floating result without changing its source.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> f64 attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %value = arith.constant 0.5 : f64 + qc.dealloc %q : !qc.qubit + return %value : f64 + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="supports only CBit function return values"): + program.to_qiskit() + + assert program.ir == source + + +def test_qiskit_export_rejects_noncanonical_i64_function_result() -> None: + """Reject a nonzero i64 result instead of treating it as the output sentinel.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> i64 attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %value = arith.constant 1 : i64 + qc.dealloc %q : !qc.qubit + return %value : i64 + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="supports only CBit function return values"): + program.to_qiskit() + + assert program.ir == source + + +def test_qiskit_export_rejects_mixed_sentinel_and_cbit_results() -> None: + """Reject the zero sentinel when it is mixed with a public CBit result.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> (i64, !cbit.reg<1>) attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : i64 + qc.dealloc %q : !qc.qubit + return %zero, %classical : i64, !cbit.reg<1> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="supports only CBit function return values"): + program.to_qiskit() + + assert program.ir == source + + def test_qiskit_round_trip_preserves_anonymous_clbits() -> None: """Represent loose Qiskit clbits as one anonymous public CBit register.""" circuit = QuantumCircuit(1) @@ -964,7 +1050,7 @@ def test_noncanonical_register_membership_is_rejected(resource: str, layout: str def test_nested_structured_control_and_bound_loop_parameter() -> None: - """Import nested control flow while keeping induction values lexical.""" + """Round-trip structured control while keeping induction values lexical.""" circuit = QuantumCircuit(2, 2) with circuit.for_loop(range(1, 5, 2), None, None, None, None, label=None) as iteration: circuit.rx(iteration, 0) @@ -979,14 +1065,622 @@ def test_nested_structured_control_and_bound_loop_parameter() -> None: circuit.z(1) program = compile_program(circuit) + source = program.ir assert "scf.for" in program.ir assert "scf.if" in program.ir assert "scf.while" in program.ir assert "scf.index_switch" in program.ir - with pytest.raises(RuntimeError, match=r"classical loads or control flow|cannot construct structured control flow"): + restored = program.to_qiskit() + + assert program.ir == source + assert [instruction.operation.name for instruction in restored.data] == [ + "for_loop", + "while_loop", + "switch_case", + ] + loop = restored.data[0].operation + loop_parameter = loop.params[1] + loop_body = loop.blocks[0] + assert loop_body.data[0].operation.params[0].uuid == loop_parameter.uuid + assert loop_body.data[1].operation.name == "if_else" + QCProgram.from_qiskit(restored) + + +def test_control_flow_and_controlled_unitary_preserve_instruction_order() -> None: + """Keep both deferred instruction kinds at their original positions.""" + circuit = QuantumCircuit(2, 1) + circuit.h(0) + controlled = library.UnitaryGate(np.asarray([[0.0, 1.0], [1.0, 0.0]])).control(1) + with circuit.if_test((circuit.clbits[0], True)): + circuit.append(controlled, [0, 1]) + circuit.z(1) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == ["h", "if_else", "z"] + body_operation = restored.data[1].operation.blocks[0].data[0].operation + assert isinstance(body_operation, AnnotatedOperation) + assert isinstance(body_operation.modifiers[0], ControlModifier) + + +def test_nested_register_condition_uses_local_captured_bits() -> None: + """Pack a root register from the matching block-local captured bits.""" + circuit = QuantumCircuit(1, 3) + with circuit.if_test((circuit.cregs[0], 5)), circuit.if_test((circuit.cregs[0], 2)): + circuit.x(0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + outer = restored.data[0].operation + assert outer.condition[0] == restored.cregs[0] + inner = outer.blocks[0].data[0].operation + assert isinstance(inner.condition, expr.Expr) + assert {variable.var for variable in expr.iter_vars(inner.condition)} <= set(outer.blocks[0].clbits) + QCProgram.from_qiskit(restored) + + +def test_composite_expression_preserves_classical_register_leaf() -> None: + """Keep a packed public register as one expression variable.""" + circuit = QuantumCircuit(1, 3) + condition = expr.logic_and(expr.equal(circuit.cregs[0], 5), circuit.clbits[0]) + with circuit.if_test(condition): + circuit.x(0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + restored_condition = restored.data[0].operation.condition + assert isinstance(restored_condition, expr.Expr) + variables = {variable.var for variable in expr.iter_vars(restored_condition)} + assert restored.cregs[0] in variables + assert restored.clbits[0] in variables + QCProgram.from_qiskit(restored) + + +def test_repeated_cbit_uint_expression_falls_back_to_expression_tree() -> None: + """Do not misidentify repeated source bits as a packed classical register.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : index + %one = arith.constant 1 : i2 + %three = arith.constant 3 : i2 + %bit = cbit.load %classical[%zero] : !cbit.reg<1> + %wide = arith.extui %bit : i1 to i2 + %shifted = arith.shli %wide, %one : i2 + %repeated = arith.ori %wide, %shifted : i2 + %condition = arith.cmpi eq, %repeated, %three : i2 + scf.if %condition { + qc.x %q : !qc.qubit + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + restored = program.to_qiskit() + + assert program.ir == source + condition = restored.data[0].operation.condition + assert isinstance(condition, expr.Expr) + assert {variable.var for variable in expr.iter_vars(condition)} == {restored.clbits[0]} + QCProgram.from_qiskit(restored) + + +def test_free_parameter_identity_is_shared_with_control_flow_blocks() -> None: + """Canonicalize one scalar Parameter across root and nested writers.""" + theta = Parameter("theta") + circuit = QuantumCircuit(1, 1, global_phase=theta / 2) + circuit.rz(theta, 0) + with circuit.if_test((circuit.clbits[0], True)): + circuit.rx(theta + 1, 0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + restored_theta = next(iter(restored.parameters)) + assert restored.global_phase.parameters == {restored_theta} + assert restored.data[0].operation.params[0] == restored_theta + nested_parameter = restored.data[1].operation.blocks[0].data[0].operation.params[0] + assert nested_parameter.parameters == {restored_theta} + QCProgram.from_qiskit(restored) + + +def test_nested_if_while_switch_preserve_capture_identity() -> None: + """Map nested control-flow operands through each block-local bit list.""" + circuit = QuantumCircuit(2, 2) + with ( + circuit.if_test(expr.logic_and(circuit.clbits[0], expr.logic_not(circuit.clbits[1]))), + circuit.while_loop((circuit.clbits[1], 0), None, None, None, label=None), + circuit.switch(circuit.clbits[0], None, None, None, label=None) as case, + ): + with case(0): + circuit.x(0) + with case(case.DEFAULT): + circuit.cx(0, 1) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + outer = restored.data[0] + assert [restored.find_bit(bit).index for bit in outer.clbits] == [0, 1] + outer_body = outer.operation.blocks[0] + while_instruction = outer_body.data[0] + assert [outer_body.find_bit(bit).index for bit in while_instruction.clbits] == [0, 1] + while_body = while_instruction.operation.blocks[0] + switch_instruction = while_body.data[0] + assert [while_body.find_bit(bit).index for bit in switch_instruction.clbits] == [0, 1] + assert switch_instruction.operation.name == "switch_case" + QCProgram.from_qiskit(restored) + + +def test_empty_if_else_branches_round_trip() -> None: + """Preserve an explicit else branch when both branches are empty.""" + circuit = QuantumCircuit(1, 1) + with circuit.if_test((circuit.clbits[0], True)) as else_: + pass + with else_: + pass + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + operation = restored.data[0].operation + assert operation.name == "if_else" + assert len(operation.blocks) == 2 + assert all(not block.data for block in operation.blocks) + QCProgram.from_qiskit(restored) + + +@pytest.mark.parametrize( + ("values", "expected"), + [(range(5, -2, -2), [5, 3, 1, -1]), (range(3, 3, -1), [])], + ids=["negative-step", "zero-iterations"], +) +def test_for_loop_range_edges_round_trip(values: range, expected: list[int]) -> None: + """Preserve descending induction values and empty iteration sets.""" + circuit = QuantumCircuit(1) + with circuit.for_loop(values, None, None, None, None, label=None) as iteration: + circuit.rx(iteration, 0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + loop = restored.data[0].operation + assert loop.name == "for_loop" + assert list(loop.params[0]) == expected + assert loop.blocks[0].data[0].operation.params[0].uuid == loop.params[1].uuid + QCProgram.from_qiskit(restored) + + +def test_nested_for_loop_induction_values_remain_lexically_scoped() -> None: + """Keep nested induction variables distinct while retaining outer captures.""" + circuit = QuantumCircuit(1) + with circuit.for_loop(range(2), None, None, None, None, label=None) as outer: + circuit.rz(outer, 0) + with circuit.for_loop(range(4, 0, -2), None, None, None, None, label=None) as inner: + circuit.rx(inner, 0) + circuit.ry(outer, 0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + outer_loop = restored.data[0].operation + outer_body = outer_loop.blocks[0] + inner_loop = outer_body.data[1].operation + outer_parameter = outer_loop.params[1] + inner_parameter = inner_loop.params[1] + assert outer_parameter.uuid != inner_parameter.uuid + assert outer_body.data[0].operation.params[0].uuid == outer_parameter.uuid + assert outer_body.data[2].operation.params[0].uuid == outer_parameter.uuid + assert inner_loop.blocks[0].data[0].operation.params[0].uuid == inner_parameter.uuid + QCProgram.from_qiskit(restored) + + +def test_generated_loop_parameter_name_avoids_free_symbol_collision() -> None: + """Choose a loop symbol name distinct from every free program input.""" + free = Parameter("_mqt_loop_0") + circuit = QuantumCircuit(1) + circuit.rz(free, 0) + with circuit.for_loop(range(2), None, None, None, None, label=None) as iteration: + circuit.rx(iteration, 0) + + restored = compile_program(circuit).to_qiskit() + + assert restored.data[0].operation.params[0].name == "_mqt_loop_0" + loop = restored.data[1].operation + assert loop.params[1].name == "_mqt_loop_1" + assert loop.blocks[0].data[0].operation.params[0].uuid == loop.params[1].uuid + + +def test_for_loop_parameter_identity_is_shared_across_if_branches() -> None: + """Use one Python Parameter object for a loop and all nested branch gates.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : index + %lower = arith.constant 1 : index + %upper = arith.constant 6 : index + %step = arith.constant 2 : index + scf.for %iteration = %lower to %upper step %step { + %integer = arith.index_cast %iteration : index to i64 + %parameter = arith.sitofp %integer : i64 to f64 + %condition = cbit.load %classical[%zero] : !cbit.reg<1> + scf.if %condition { + qc.rx(%parameter) %q : !qc.qubit + } else { + qc.ry(%parameter) %q : !qc.qubit + } + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + + restored = program.to_qiskit() + + loop = restored.data[0].operation + parameter = loop.params[1] + branch = loop.blocks[0].data[0].operation + assert branch.blocks[0].data[0].operation.params[0].uuid == parameter.uuid + assert branch.blocks[1].data[0].operation.params[0].uuid == parameter.uuid + + +def test_dead_for_loop_parameter_projection_is_ignored() -> None: + """Do not require a Qiskit loop symbol when its projection is unused.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %lower = arith.constant 0 : index + %upper = arith.constant 2 : index + %step = arith.constant 1 : index + scf.for %iteration = %lower to %upper step %step { + %integer = arith.index_cast %iteration : index to i64 + %unused = arith.sitofp %integer : i64 to f64 + qc.x %q : !qc.qubit + } + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + source = program.ir + + restored = program.to_qiskit() + + assert program.ir == source + loop = restored.data[0].operation + assert loop.name == "for_loop" + assert loop.params[1] is None + assert loop.blocks[0].count_ops() == {"x": 1} + + +def test_switch_case_label_width_is_preflighted() -> None: + """Reject a switch label that cannot fit its one-bit target.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : index + %bit = cbit.load %classical[%zero] : !cbit.reg<1> + %index = arith.index_castui %bit : i1 to index + scf.index_switch %index + case 2 { + qc.x %q : !qc.qubit + scf.yield + } + default { + scf.yield + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="case label 2 does not fit the 1-bit target"): + program.to_qiskit() + + assert program.ir == source + + +def test_shared_expression_dag_expansion_is_bounded() -> None: + """Bound tree expansion when both operands reuse the same SSA value.""" + lines = [ + "module {", + ' func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} {', + " %q = qc.alloc : !qc.qubit", + ' %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1>', + " %zero = arith.constant 0 : index", + " %value0 = cbit.load %classical[%zero] : !cbit.reg<1>", + ] + lines.extend(f" %value{index} = arith.andi %value{index - 1}, %value{index - 1} : i1" for index in range(1, 14)) + lines.extend([ + " scf.if %value13 {", + " qc.x %q : !qc.qubit", + " }", + " qc.dealloc %q : !qc.qubit", + " return %classical : !cbit.reg<1>", + " }", + "}", + ]) + program = QCProgram.from_mlir_str("\n".join(lines)) + source = program.ir + + with pytest.raises(RuntimeError, match="size limit of 4096 nodes"): program.to_qiskit() + assert program.ir == source + + +def test_result_bearing_control_flow_rejection_preserves_source() -> None: + """Reject unsupported SSA results before changing the source program.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %condition = arith.constant true + %result = scf.if %condition -> (i64) { + %one = arith.constant 1 : i64 + scf.yield %one : i64 + } else { + %zero = arith.constant 0 : i64 + scf.yield %zero : i64 + } + qc.x %q : !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="does not support SSA results"): + program.to_qiskit() + + assert program.ir == source + + +def test_stale_classical_snapshot_rejection_preserves_source() -> None: + """Reject a condition loaded before a later write to the same register.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : index + %stale = cbit.load %classical[%zero] : !cbit.reg<1> + %measured = qc.measure %q : !qc.qubit -> i1 + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + scf.if %stale { + qc.x %q : !qc.qubit + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="cannot preserve a stale classical snapshot"): + program.to_qiskit() + + assert program.ir == source + + +def test_measurement_store_after_control_flow_rejection_preserves_source() -> None: + """Reject a delayed write that would change a captured bit snapshot.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %measured_qubit = qc.alloc : !qc.qubit + %controlled_qubit = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : index + %old = cbit.load %classical[%zero] : !cbit.reg<1> + %measured = qc.measure %measured_qubit : !qc.qubit -> i1 + scf.if %old { + qc.x %controlled_qubit : !qc.qubit + } + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + qc.dealloc %measured_qubit : !qc.qubit + qc.dealloc %controlled_qubit : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="destination must follow the measurement"): + program.to_qiskit() + + assert program.ir == source + + +def test_multi_result_boolean_select_expressions_round_trip() -> None: + """Export every Boolean result of one side-effect-free scf.if expression.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<2> attributes {passthrough = ["entry_point"]} { + %first_qubit = qc.alloc : !qc.qubit + %second_qubit = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<2> + %zero = arith.constant 0 : index + %one = arith.constant 1 : index + %false = arith.constant false + %selector = cbit.load %classical[%zero] : !cbit.reg<2> + %conditions:2 = scf.if %selector -> (i1, i1) { + %other = cbit.load %classical[%one] : !cbit.reg<2> + scf.yield %false, %other : i1, i1 + } else { + %other = cbit.load %classical[%one] : !cbit.reg<2> + scf.yield %other, %false : i1, i1 + } + scf.if %conditions#0 { + qc.x %first_qubit : !qc.qubit + } + scf.if %conditions#1 { + qc.z %second_qubit : !qc.qubit + } + qc.dealloc %first_qubit : !qc.qubit + qc.dealloc %second_qubit : !qc.qubit + return %classical : !cbit.reg<2> + } +} +""" + ) + source = program.ir + + restored = program.to_qiskit() + + assert program.ir == source + assert [instruction.operation.name for instruction in restored.data] == ["if_else", "if_else"] + QCProgram.from_qiskit(restored) + + +def test_undefined_cbits_can_be_read_after_unconditional_measurements() -> None: + """Treat preceding top-level measurement writes as definite initialization.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : index + %measured = qc.measure %q : !qc.qubit -> i1 + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + %condition = cbit.load %classical[%zero] : !cbit.reg<1> + scf.if %condition { + qc.x %q : !qc.qubit + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + + restored = program.to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == ["measure", "if_else"] + QCProgram.from_qiskit(restored) + + +def test_undefined_cbit_load_before_measurement_is_rejected() -> None: + """Reject a read that precedes definite initialization of an output bit.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : index + %condition = cbit.load %classical[%zero] : !cbit.reg<1> + scf.if %condition { + qc.x %q : !qc.qubit + } + %measured = qc.measure %q : !qc.qubit -> i1 + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + + with pytest.raises(RuntimeError, match="loads an undefined classical bit"): + program.to_qiskit() + + +def test_conditional_measurement_does_not_initialize_returned_cbit() -> None: + """Do not count a branch-local measurement as a definite output write.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %zero = arith.constant 0 : index + %condition = arith.constant true + scf.if %condition { + %measured = qc.measure %q : !qc.qubit -> i1 + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + + with pytest.raises(RuntimeError, match="cannot return undefined classical bits"): + program.to_qiskit() + + +@pytest.mark.parametrize( + ("expression", "error"), + [ + ( + """%left = arith.constant 5 : i8 + %right = arith.constant 2 : i8 + %remainder = arith.remui %left, %right : i8 + %expected = arith.constant 1 : i8 + %condition = arith.cmpi eq, %remainder, %expected : i8""", + "unsupported QC classical operation in Qiskit export: arith.remui", + ), + ( + """%left = arith.constant 0 : i65 + %right = arith.constant 1 : i65 + %condition = arith.cmpi eq, %left, %right : i65""", + "unsigned classical values must be between 1 and 64 bits", + ), + ( + """%left = arith.constant 0 : i8 + %right = arith.constant 1 : i8 + %condition = arith.cmpi slt, %left, %right : i8""", + "Uint expressions do not support signed comparisons", + ), + ( + """%infinity = arith.constant 0x7FF0000000000000 : f64 + %zero = arith.constant 0.0 : f64 + %condition = arith.cmpf oeq, %infinity, %zero : f64""", + "floating-point literals must be finite", + ), + ], + ids=["unsupported-op", "width", "signed-compare", "nonfinite"], +) +def test_unsupported_export_expressions_fail_closed(expression: str, error: str) -> None: + """Reject unsupported expression forms before modifying the source program.""" + program = QCProgram.from_mlir_str( + f"""module {{ + func.func @main() attributes {{passthrough = ["entry_point"]}} {{ + %q = qc.alloc : !qc.qubit + {expression} + scf.if %condition {{ + qc.x %q : !qc.qubit + }} + qc.dealloc %q : !qc.qubit + return + }} +}} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match=error): + program.to_qiskit() + + assert program.ir == source + def test_qiskit_import_zero_initializes_clbits_before_control_flow() -> None: """Initialize Qiskit clbits before a condition reads them.""" @@ -1016,14 +1710,19 @@ def test_qiskit_import_zero_initializes_clbits_before_control_flow() -> None: ], ) def test_bool_uint_and_float_expressions(condition: expr.Expr, operation: str) -> None: - """Lower representative constant classical expressions.""" + """Round-trip representative Bool, Uint, and Float expressions.""" circuit = QuantumCircuit(1) with circuit.if_test(condition): circuit.x(0) program = QCProgram.from_qiskit(circuit) + source = program.ir + restored = program.to_qiskit() assert operation in program.ir + assert program.ir == source + assert restored.data[0].operation.name == "if_else" + QCProgram.from_qiskit(restored) def _round_trip_qiskit_import(circuit: QuantumCircuit) -> str: From 68dd8b1a29ad17b61ca93830b4ca7d4f3208f585 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Wed, 19 Aug 2026 17:58:31 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=90=9B=20Export=20measurements=20acro?= =?UTF-8?q?ss=20independent=20target=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- .agent/plans/qiskit-measurement-deferral.md | 238 ++++++++++++ bindings/mlir/qiskit/QiskitExport.cpp | 146 +++++-- docs/mlir/python_compiler_collection.md | 17 +- test/python/test_mlir_qiskit_translation.py | 401 ++++++++++++++++++++ 4 files changed, 774 insertions(+), 28 deletions(-) create mode 100644 .agent/plans/qiskit-measurement-deferral.md diff --git a/.agent/plans/qiskit-measurement-deferral.md b/.agent/plans/qiskit-measurement-deferral.md new file mode 100644 index 0000000000..3b4d8d1a8a --- /dev/null +++ b/.agent/plans/qiskit-measurement-deferral.md @@ -0,0 +1,238 @@ +# Export measurement destinations across independent target work + +This ExecPlan is a living document. The sections `Progress`, +`Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must +be kept up to date as work proceeds. + +This ExecPlan must be maintained in accordance with `.agent/PLANS.md` from the +repository root. + +## Purpose / Big Picture + +Target compilation can leave a measurement result's CBit store after reset, +unitary operations, or later measurements and their destination stores. Qiskit +represents a measurement and its classical destination as one instruction, so +its write is immediate. `QCProgram.to_qiskit()` accepts that delayed MLIR store +when every intervening operation is independent of its destination bit. The +Qiskit instruction order retains the measurement before the intervening work +while attaching the destination to it. + +QC cleanup may also forward a load from the just-written CBit to the measurement +SSA result. The result then has both a destination-store use and classical- +expression uses, so a one-use restriction incorrectly rejects common +measurement-controlled programs. The exporter treats such a result as its unique +destination CBit only when the store precedes every other use. Snapshot +validation rejects intervening or nested writes before a consumer. + +## Progress + +- [x] (2026-08-19 15:54Z) Split this follow-up from the completed structured + exporter branch and inspect the earlier combined regression. +- [x] (2026-08-19 15:55Z) Relax only the measurement/store adjacency predicate + and document its classical-state equivalence argument. +- [x] (2026-08-19 15:56Z) Add a sparse target-mapping regression and a direct + CBit MLIR regression. +- [x] (2026-08-19 16:19Z) Restack onto the audited import and exporter parents, + strengthen the stale-snapshot negative, and pass the complete translation + file and repository lint. +- [x] (2026-08-19 20:15Z) Restack onto the post-merge exporter foundation after + its scalar-input reachability fix and pass all 199 translation tests. +- [x] (2026-08-19 20:54Z) Diagnose the three Benchpress failures as forwarded + measurement-result uses, add destination provenance and snapshot + validation, rebuild, and pass 203 active translation tests. +- [x] (2026-08-19 21:16Z) Restack the Qiskit branches onto the latest scalar + foundation, pass the complete translation file and lint, and validate the + combined Qiskit/classical tree with all 4,156 native tests, all 219 Qiskit + translation tests, and all 40 Benchpress integration tests. +- [x] (2026-08-19 21:19Z) Narrow snapshot invalidation from register-wide to + bit-precise, add direct and nested different-bit regressions, rebuild, and + pass all six affected tests, all 206 active translation tests, and lint. +- [x] (2026-08-19 22:52Z) Accept reversed stores for measurements with distinct + static destination bits, add alias regressions, build the binding, pass + all 209 active translation tests, and pass repository lint before + independent audit. + +## Surprises & Discoveries + +- Observation: The sparse-target example retains all three measurements but may + schedule synthesized quantum gates before a measurement result's CBit store. + Evidence: the strict exporter rejected the compiled program even though those + intervening gates cannot access classical state. + +- Observation: A Qiskit measurement always owns its destination Clbit and + therefore writes it immediately. Evidence: the normalized writer API exposes + `addMeasure(qubit, clbit)` rather than separate measure and store operations. + +- Observation: CBit canonicalization forwards a load after a static store to the + stored SSA value. Evidence: target-independent `QCOProgram.cleanup()` reduces + `measure; store; load; if` to `measure; store; if %result`. + +- Observation: The three post-merge Benchpress failures contain no operation + between `qc.measure` and `cbit.store`. Evidence: the store is adjacent and a + later `scf.if` is the result's second use; `hasOneUse()` alone produced the + misleading adjacency error. + +- Observation: A mapped three-qubit W-state can place two complete measurement + and store pairs between an earlier measurement and its store. Evidence: the + final QC program orders the destinations as + `measure a; measure b; store b; measure c; store c; store a`, and all three + stores target distinct static bits. + +## Decision Log + +- Decision: Accept `arith.constant`, `qc.measure`, `qc.reset`, and + `qc::UnitaryOpInterface` operations between a measurement and its CBit store. + Also accept another measurement's store when both destinations are static and + distinct. Rationale: these operations cannot observe or overwrite the delayed + destination, so attaching that destination to the earlier measurement is + unobservable. Date/Author: 2026-08-20 / Codex. + +- Decision: Keep control flow, dynamic or aliasing stores, other CBit + operations, other arithmetic, memory effects, and unknown operations + fail-closed in that gap. Rationale: any may observe, overwrite, or condition + behavior on the classical result. Date/Author: 2026-08-20 / Codex. + +- Decision: Record the unique static destination bit and store for each accepted + measurement result during export preflight. Rationale: later supported + expressions can use the Clbit that the measurement already writes, without + mutating the source or allocating a partial output circuit. Date/Author: + 2026-08-19 / Codex. + +- Decision: Require the destination store to precede every non-store use and + apply classical-snapshot overwrite checks from that store to each consumer. + Rationale: substituting a mutable Clbit for an immutable SSA result is valid + only while the destination still contains that result. Date/Author: 2026-08-19 + / Codex. + +- Decision: Guard the exact target-compiled QASM regression when the independent + Qiskit branch lacks the sibling classical-control capability/mapping stack. + Rationale: main's pre-stack mapper aborts on `qco.if`; the always-running + cleanup regression covers the exporter, and combined Benchpress validation + supplies the end-to-end evidence. Date/Author: 2026-08-19 / Codex. + +## Outcomes & Retrospective + +The exporter fuses delayed measurement destinations across independent quantum +work and measurement writes to distinct static bits. It also exports a +cleanup-forwarded measurement result as its unique destination CBit when the +store dominates every other use and no later write makes that replacement stale. +A different static bit may be written safely; a same-bit or dynamic-index write +remains fail-closed. Direct-result, use-before-store, nested-overwrite, +distinct-bit, cleanup-QASM, and guarded target-compilation regressions cover the +boundary. + +The release MLIR binding builds with the final source patch. The four focused +measurement-store tests pass. All 209 active tests in +`test/python/test_mlir_qiskit_translation.py` pass, with one target-stack-only +case guarded on this independent branch. Repository lint also passes. Before the +final bit-precision correction, the combined tree passed all 4,156 native tests, +all 219 Qiskit translation tests, and all 40 Benchpress integration tests. The +exact six different-bit Benchpress failures that motivated that correction are +covered by direct and nested regressions. The new reversed-store case covers the +mapped W-state shape. The diff remains uncommitted for independent audit, and +nothing is pushed. + +## Context and Orientation + +`bindings/mlir/qiskit/QiskitExport.cpp` recursively collects a validated +`ExportedCircuit` before creating a Qiskit object. `isFusableMeasurementStore` +requires one unique static `cbit.store` destination in the same block. It walks +operations between measurement and store, permits only independent quantum work +and measurement writes to distinct static bits, and verifies that the store +precedes every other result use. `ExportState` records the accepted result's +destination bit and store for expression export and snapshot validation. + +`test/python/test_mlir_qiskit_translation.py` contains direct MLIR, QASM +cleanup, and target-compilation regressions. +`docs/mlir/python_compiler_collection.md` states the exact measurement-store and +result-use restrictions. + +## Plan of Work + +Retain the quantum-only intervening-operation allowlist. Extend it to later +measurements and their provably distinct static stores. Keep same-bit and +dynamic stores fail-closed. Require a unique destination store, prove the store +precedes every other use, and record the accepted destination in `ExportState`. + +Export a recorded measurement result as a classical-bit expression. Extend +snapshot discovery to start at its destination store and reuse the top-level and +nested same-bit write checks. A dynamic-index write may target the snapshot bit +and therefore remains fail-closed. Keep source IR, writer construction, import +code, and unsupported-use preflight unchanged. + +Add regressions for reversed measurement-store order, same-bit and dynamic +intervening stores, a direct measurement-result condition, a consumer before its +store, and top-level and nested overwrites before later consumers. Add an +always-running OpenQASM 2 cleanup case, distinct direct and nested bit writes, +and a guarded exact target-compiled reproducer. Retain the sparse-mapping, +delayed quantum-work, multiple-destination, and stale-load negatives. + +Finally, format, rebuild the release binding, run focused and complete Qiskit +translation tests, run repository lint, and inspect the commit-relative diff. +Prepare the uncommitted diff for independent audit. Do not commit or push. + +## Concrete Steps + +Run from the repository root: + + clang-format --dry-run --Werror bindings/mlir/qiskit/QiskitExport.cpp + uvx ruff check test/python/test_mlir_qiskit_translation.py + uvx rumdl check docs/mlir/python_compiler_collection.md \ + .agent/plans/qiskit-measurement-deferral.md + git diff --check + +Build and test against the worktree extension: + + cmake --build build/release --target mqt-core-mlir-bindings --parallel 8 + pytest test/python/test_mlir_qiskit_translation.py \ + -k 'measurement_result or sparse_target_measurement or measurement_store' + pytest test/python/test_mlir_qiskit_translation.py + uvx nox -s lint + +## Validation and Acceptance + +The delayed-store regression must preserve `measure`, `reset`, `x` order. The +reversed-store regression must preserve measurement order and write distinct +destination bits. Same-bit and dynamic intervening stores must fail without +changing the source. A measurement-result condition after its store must become +a Qiskit condition on the destination CBit. A use before the store and a result +whose destination is overwritten before a consumer must also fail without +changing the source. The exact target-compiled QASM case must pass when the +classical stack is assembled. + +The release binding, complete translation file, and lint must pass. The final +diff from the structured-export parent must remain limited to measurement +destination/result preflight, focused regressions, documentation, and this plan. +No reader, writer, generic control-flow, scalar-parameter, or CBit +definite-write behavior may change. + +## Idempotence and Recovery + +Build, format, lint, and test commands are repeatable. If compiled IR changes, +inspect it before adjusting a regression; do not broaden the predicate without a +new equivalence argument. The work is isolated on a child branch, so its +exporter parent remains recoverable. + +## Artifacts and Notes + +The expected source boundary is: + + allowed between measure and store = + arith.constant | qc.measure | qc.reset | qc::UnitaryOpInterface | + statically disjoint measurement cbit.store + + measurement-result expression = + unique static destination CBit, store before every other use, + no intervening or nested write that may target its destination bit + +All other operations remain disallowed. + +## Interfaces and Dependencies + +No public interface or dependency changes are required. The implementation adds +internal result-to-destination maps to `ExportState` and uses existing MLIR +operation classes and QC dialect interfaces. + +Revision note: Created when measurement-ordering support was split from the +structured-control exporter. Expanded after post-merge integration exposed +cleanup-forwarded result uses and reversed stores for distinct measurements. diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 5fe46f949e..6e21093f53 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -377,6 +377,8 @@ struct ExportState { llvm::DenseMap classicalRegisterInfo; llvm::DenseMap> unconditionalWrites; llvm::DenseMap> measurementDestinations; + llvm::DenseMap measurementResultBits; + llvm::DenseMap measurementResultStores; llvm::DenseSet expressionOperations; std::vector quantumRegisters; std::vector classicalRegisters; @@ -1105,6 +1107,12 @@ exportExpressionImpl(mlir::Value value, ExportState& state, auto result = std::make_unique(); setExpressionType(*result, value.getType()); + if (const auto measured = state.measurementResultBits.find(value); + measured != state.measurementResultBits.end()) { + result->kind = ExpressionKind::ClassicalBit; + result->bit = measured->second; + return result; + } if (result->type == ClassicalType::Uint) { if (auto packed = matchPackedRegister(value, state, evaluationBlock)) { result->kind = ExpressionKind::ClassicalRegister; @@ -1445,12 +1453,23 @@ void acceptPackedRegister(PackedRegister& packed, ExportState& state) { packed.operations.end()); } -[[nodiscard]] bool storesToValueRecursively(mlir::Operation& operation, - const mlir::Value value) { +[[nodiscard]] bool +mayOverwriteClassicalSnapshot(mlir::cbit::StoreOp store, const mlir::Value reg, + const std::optional index) { + if (store.getReg() != reg) { + return false; + } + const auto storeIndex = mlir::getConstantIntValue(store.getIndex()); + return !index || !storeIndex || *storeIndex == *index; +} + +[[nodiscard]] bool +storesToSnapshotRecursively(mlir::Operation& operation, const mlir::Value reg, + const std::optional index) { bool stores = false; operation.walk([&](mlir::Operation* nested) { if (auto store = llvm::dyn_cast(nested); - store && store.getReg() == value) { + store && mayOverwriteClassicalSnapshot(store, reg, index)) { stores = true; return mlir::WalkResult::interrupt(); } @@ -1460,20 +1479,38 @@ void acceptPackedRegister(PackedRegister& packed, ExportState& state) { } void validateClassicalSnapshot(const mlir::Value expression, - mlir::Operation& consumer) { + mlir::Operation& consumer, + const ExportState& state) { + struct Snapshot { + mlir::Operation* anchor; + mlir::Value reg; + std::optional index; + }; llvm::DenseSet visited; - llvm::SmallVector loads; - const std::function collectLoads = + llvm::SmallVector snapshots; + const std::function collectSnapshots = [&](const mlir::Value value) { if (!visited.insert(value).second) { return; } + if (const auto measured = state.measurementResultStores.find(value); + measured != state.measurementResultStores.end()) { + auto store = llvm::cast(measured->second); + snapshots.push_back( + {.anchor = store.getOperation(), + .reg = store.getReg(), + .index = mlir::getConstantIntValue(store.getIndex())}); + return; + } auto* operation = value.getDefiningOp(); if (operation == nullptr) { return; } if (auto load = llvm::dyn_cast(operation)) { - loads.push_back(load); + snapshots.push_back( + {.anchor = load.getOperation(), + .reg = load.getReg(), + .index = mlir::getConstantIntValue(load.getIndex())}); return; } if (auto ifOp = llvm::dyn_cast(operation); @@ -1485,19 +1522,19 @@ void validateClassicalSnapshot(const mlir::Value expression, if (auto yield = llvm::dyn_cast( region.front().getTerminator())) { for (const auto yielded : yield.getOperands()) { - collectLoads(yielded); + collectSnapshots(yielded); } } } } for (const auto operand : operation->getOperands()) { - collectLoads(operand); + collectSnapshots(operand); } }; - collectLoads(expression); - for (auto load : loads) { - mlir::Operation* anchor = load; - auto* anchorBlock = load->getBlock(); + collectSnapshots(expression); + for (const auto& snapshot : snapshots) { + auto* anchor = snapshot.anchor; + auto* anchorBlock = anchor->getBlock(); while (anchorBlock != consumer.getBlock()) { auto* parent = anchorBlock->getParentOp(); auto parentIf = llvm::dyn_cast_if_present(parent); @@ -1521,13 +1558,15 @@ void validateClassicalSnapshot(const mlir::Value expression, "Qiskit control-flow expression does not dominate its consumer"); } if (auto store = llvm::dyn_cast(operation); - store && store.getReg() == load.getReg()) { + store && + mayOverwriteClassicalSnapshot(store, snapshot.reg, snapshot.index)) { throw std::runtime_error( "Qiskit control-flow export cannot preserve a stale classical " "snapshot"); } if (operation->getNumRegions() != 0U && - storesToValueRecursively(*operation, load.getReg())) { + storesToSnapshotRecursively(*operation, snapshot.reg, + snapshot.index)) { throw std::runtime_error( "Qiskit control-flow export cannot preserve a classical " "snapshot across nested control flow"); @@ -1544,7 +1583,7 @@ void validateClassicalSnapshot(const mlir::Value expression, throw std::runtime_error( "Qiskit control-flow conditions must have Boolean type"); } - validateClassicalSnapshot(value, consumer); + validateClassicalSnapshot(value, consumer, state); if (auto comparison = value.getDefiningOp(); comparison && comparison.getPredicate() == mlir::arith::CmpIPredicate::eq) { @@ -1591,7 +1630,7 @@ void validateClassicalSnapshot(const mlir::Value expression, ExportState& state, mlir::Block& evaluationBlock, mlir::Operation& consumer) { - validateClassicalSnapshot(value, consumer); + validateClassicalSnapshot(value, consumer, state); if (auto cast = value.getDefiningOp()) { state.expressionOperations.insert(cast); value = cast.getIn(); @@ -1735,19 +1774,63 @@ collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, [[nodiscard]] bool isFusableMeasurementStore(mlir::qc::MeasureOp measure, mlir::cbit::StoreOp store) { - if (!measure.getResult().hasOneUse() || - store.getValue() != measure.getResult() || + if (store.getValue() != measure.getResult() || measure->getBlock() != store->getBlock()) { return false; } + mlir::cbit::StoreOp destination; + for (auto& use : measure.getResult().getUses()) { + auto candidate = llvm::dyn_cast(use.getOwner()); + if (!candidate || candidate.getValue() != measure.getResult()) { + continue; + } + if (destination) { + return false; + } + destination = candidate; + } + if (destination != store) { + return false; + } const auto index = mlir::getConstantIntValue(store.getIndex()); if (!index) { return false; } for (auto* operation = measure->getNextNode(); operation != store; operation = operation->getNextNode()) { - if (operation == nullptr || - !llvm::isa(operation)) { + // A Qiskit measurement writes its destination immediately. Fusing this + // delayed store is equivalent while intervening operations cannot observe + // its destination. A later measurement may write another static bit. Keep + // aliasing or dynamic stores and all other classical operations + // fail-closed. + if (operation == nullptr) { + return false; + } + if (llvm::isa(operation)) { + continue; + } + auto interveningStore = llvm::dyn_cast(operation); + if (!interveningStore || + !interveningStore.getValue().getDefiningOp()) { + return false; + } + const auto interveningIndex = + mlir::getConstantIntValue(interveningStore.getIndex()); + if (!interveningIndex || mayOverwriteClassicalSnapshot( + interveningStore, store.getReg(), index)) { + return false; + } + } + for (auto& use : measure.getResult().getUses()) { + auto* user = use.getOwner(); + if (user == store.getOperation()) { + continue; + } + while (user != nullptr && user->getBlock() != measure->getBlock()) { + user = user->getParentOp(); + } + if (user == nullptr || !store->isBeforeInBlock(user)) { return false; } } @@ -2072,11 +2155,28 @@ collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, throw std::runtime_error( "QC measurement uses an out-of-bounds classical destination"); } + const auto destinationBit = + checkedAdd(info->second.base, checked, "classical-bit"); circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::Measure, .qubits = mapQubits(measure.getQubit(), state.qubits), - .clbits = { - checkedAdd(info->second.base, checked, "classical-bit")}}); + .clbits = {destinationBit}}); + state.measurementResultBits.try_emplace(measure.getResult(), + destinationBit); + state.measurementResultStores.try_emplace(measure.getResult(), + destination.getOperation()); + for (auto& use : measure.getResult().getUses()) { + auto* user = use.getOwner(); + if (user == destination.getOperation()) { + continue; + } + while (user != nullptr && user->getBlock() != measure->getBlock()) { + user = user->getParentOp(); + } + if (user != nullptr) { + validateClassicalSnapshot(measure.getResult(), *user, state); + } + } continue; } if (auto reset = llvm::dyn_cast(operation)) { diff --git a/docs/mlir/python_compiler_collection.md b/docs/mlir/python_compiler_collection.md index f8e2652d99..f5c3ef08f8 100644 --- a/docs/mlir/python_compiler_collection.md +++ b/docs/mlir/python_compiler_collection.md @@ -229,13 +229,20 @@ An undefined public CBit may be read only after an unconditional top-level measurement write to that bit, and every bit of an undefined returned register must be written unconditionally. Branch-local writes do not establish definite initialization. A captured classical snapshot must not cross a later CBit write -or a nested write to the same register. +that targets or may dynamically target the same bit, directly or in nested +control flow. Each exported measurement must write to one static public CBit in the same -block, and destinations must be unique. Its destination store must follow the -measurement directly, apart from constant operations. A conditional or otherwise -delayed destination store is rejected because Qiskit cannot preserve it as one -measurement instruction. +block, and destinations must be unique. Constants, measurements, and +quantum-only reset or unitary operations may appear between a measurement and +its destination store. Another measurement destination store may also appear +when its static register bit is distinct from the delayed destination. Dynamic +or aliasing stores and other classical operations remain unsupported in this +gap. The measurement result may feed supported classical expressions after its +store; the exporter represents those uses by the destination CBit and rejects a +later or nested write that would make the CBit a stale replacement for the SSA +result. A conditional before the store is rejected because Qiskit cannot +preserve it as one measurement instruction. Dense numeric unitaries remain explicit matrix operations during import and export. Target compilation synthesizes supported one- and two-qubit matrices to diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index ab17f970a2..aeb3ee2666 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -537,6 +537,124 @@ def test_target_compiled_openqasm2_measurements_export() -> None: assert restored.count_ops() == {"measure": 2, "x": 1} +def test_cleanup_forwards_measurement_result_condition_to_qiskit() -> None: + """Recover a CBit condition after cleanup forwards its measurement load.""" + program = QCProgram.from_qasm_str( + """OPENQASM 2.0; +include "qelib1.inc"; +qreg q[2]; +creg c[2]; +measure q[0] -> c[0]; +if (c == 1) x q[1]; +""" + ) + source = program.ir + optimized = program.to_qco(copy=True) + + optimized.cleanup() + restored = optimized.to_qc(copy=True).to_qiskit() + + assert restored.count_ops() == {"measure": 1, "if_else": 1} + assert restored.data[1].operation.blocks[0].count_ops() == {"x": 1} + assert program.ir == source + + +def test_cleanup_preserves_distinct_measurement_snapshot_bits() -> None: + """Keep distinct static writes from invalidating measurement snapshots.""" + program = QCProgram.from_qasm_str( + """OPENQASM 2.0; +include "qelib1.inc"; +qreg q[3]; +creg c[2]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; +if (c == 3) x q[2]; +""" + ) + source = program.ir + optimized = program.to_qco(copy=True) + + optimized.cleanup() + restored = optimized.to_qc(copy=True).to_qiskit() + + assert restored.count_ops() == {"measure": 2, "if_else": 1} + assert restored.data[2].operation.blocks[0].count_ops() == {"x": 1} + assert program.ir == source + + +@pytest.mark.skipif( + not hasattr(CompilerTarget, "ClassicalControl"), + reason="requires the classical-control target capability stack", +) +def test_target_compiled_measurement_result_condition_exports() -> None: + """Export the target-compiled form used by the Benchpress integration.""" + control = CompilerTarget.ClassicalControl # ty: ignore[unresolved-attribute] + target = CompilerTarget( # ty: ignore[no-matching-overload] + 2, + classical_control=[control.CONDITIONAL], + ) + program = QCProgram.from_qasm_str( + """OPENQASM 2.0; +include "qelib1.inc"; +qreg q[2]; +creg c[2]; +measure q[0] -> c[0]; +if (c == 1) x q[1]; +""" + ) + source = program.ir + mapped = program.to_qco(copy=True) + + mapped.compile_for_target(target) + restored = mapped.to_qc(copy=True).to_qiskit(target=target) + + assert restored.num_qubits == 2 + assert restored.count_ops() == {"measure": 1, "if_else": 1} + assert restored.data[1].operation.blocks[0].count_ops() == {"x": 1} + assert program.ir == source + + +def test_sparse_target_measurement_store_after_quantum_work_exports() -> None: + """Fuse a delayed destination without moving its measurement past later gates.""" + target = CompilerTarget( + "line", + [CompilerTarget.Site(index) for index in range(3)], + couplings=[(0, 1), (1, 2)], + operations=[ + CompilerTarget.Operation("x", 1, 0), + CompilerTarget.Operation("sx", 1, 0), + CompilerTarget.Operation("rz", 1, 1), + CompilerTarget.Operation("cz", 2, 0), + CompilerTarget.Operation("measure", 1, 0), + ], + ) + program = QCProgram.from_qasm_str( + """OPENQASM 2.0; +include "qelib1.inc"; +qreg q[3]; +creg c[3]; +h q[0]; +cu1(pi/2) q[1],q[0]; +h q[1]; +cu1(pi/4) q[2],q[0]; +cu1(pi/2) q[2],q[1]; +h q[2]; +measure q -> c; +""" + ) + source = program.ir + mapped = program.to_qco(copy=True) + + mapped.compile_for_target(target) + restored = mapped.to_qc(copy=True).to_qiskit(target=target) + + names = [instruction.operation.name for instruction in restored.data] + first_measurement = names.index("measure") + assert any(name != "measure" for name in names[first_measurement + 1 :]) + assert names.count("measure") == 3 + assert program.ir == source + + def test_openqasm3_measurement_export_uses_undefined_cbit_register() -> None: """Represent OpenQASM 3 output initialization without poison values.""" program = QCProgram.from_qasm_str( @@ -1451,6 +1569,288 @@ def test_result_bearing_control_flow_rejection_preserves_source() -> None: assert program.ir == source +def test_measurement_store_after_quantum_operations_round_trips() -> None: + """Write the destination early when only quantum operations intervene.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %measured = qc.measure %q : !qc.qubit -> i1 + qc.reset %q : !qc.qubit + qc.x %q : !qc.qubit + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + restored = program.to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == ["measure", "reset", "x"] + assert restored.find_bit(restored.data[0].clbits[0]).index == 0 + assert program.ir == source + + +def test_measurement_stores_in_reverse_order_round_trip() -> None: + """Write each measurement to its distinct static destination.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<2> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %one = arith.constant 1 : index + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<2> + %first = qc.measure %q : !qc.qubit -> i1 + qc.x %q : !qc.qubit + %second = qc.measure %q : !qc.qubit -> i1 + cbit.store %second, %classical[%one] : !cbit.reg<2> + cbit.store %first, %classical[%zero] : !cbit.reg<2> + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<2> + } +} +""" + ) + source = program.ir + + restored = program.to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == [ + "measure", + "x", + "measure", + ] + assert [ + restored.find_bit(instruction.clbits[0]).index + for instruction in restored.data + if instruction.operation.name == "measure" + ] == [0, 1] + assert program.ir == source + + +def test_measurement_store_rejects_intervening_same_bit_write() -> None: + """Do not move a destination write across a write to the same bit.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %first = qc.measure %q : !qc.qubit -> i1 + %second = qc.measure %q : !qc.qubit -> i1 + cbit.store %second, %classical[%zero] : !cbit.reg<1> + cbit.store %first, %classical[%zero] : !cbit.reg<1> + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="destination must follow the measurement"): + program.to_qiskit() + + assert program.ir == source + + +def test_measurement_store_rejects_intervening_dynamic_write() -> None: + """Treat a dynamic measurement destination as a possible alias.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<2> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<2> + %bit = cbit.load %classical[%zero] : !cbit.reg<2> + %dynamic = arith.index_castui %bit : i1 to index + %first = qc.measure %q : !qc.qubit -> i1 + %second = qc.measure %q : !qc.qubit -> i1 + cbit.store %second, %classical[%dynamic] : !cbit.reg<2> + cbit.store %first, %classical[%zero] : !cbit.reg<2> + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<2> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="destination must follow the measurement"): + program.to_qiskit() + + assert program.ir == source + + +def test_measurement_result_condition_uses_destination_bit() -> None: + """Represent a forwarded measurement result by its unique public CBit.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %measured = qc.measure %q : !qc.qubit -> i1 + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + scf.if %measured { + qc.x %q : !qc.qubit + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + restored = program.to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == [ + "measure", + "if_else", + ] + assert restored.data[1].operation.blocks[0].count_ops() == {"x": 1} + QCProgram.from_qiskit(restored) + assert program.ir == source + + +def test_measurement_result_use_before_destination_is_rejected() -> None: + """Do not move a measurement write across a consumer of its SSA result.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %measured = qc.measure %q : !qc.qubit -> i1 + scf.if %measured { + qc.x %q : !qc.qubit + } + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="destination must follow the measurement"): + program.to_qiskit() + + assert program.ir == source + + +def test_measurement_result_snapshot_rejects_destination_overwrite() -> None: + """Reject replacing an SSA result by a destination overwritten before use.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %false = arith.constant false + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %measured = qc.measure %q : !qc.qubit -> i1 + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + cbit.store %false, %classical[%zero] : !cbit.reg<1> + scf.if %measured { + qc.x %q : !qc.qubit + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="stale classical snapshot"): + program.to_qiskit() + + assert program.ir == source + + +def test_measurement_result_snapshot_rejects_nested_destination_overwrite() -> None: + """Do not replace an SSA result by a CBit overwritten before its use.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %true = arith.constant true + %false = arith.constant false + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<1> + %measured = qc.measure %q : !qc.qubit -> i1 + cbit.store %measured, %classical[%zero] : !cbit.reg<1> + %condition = scf.if %true -> (i1) { + cbit.store %false, %classical[%zero] : !cbit.reg<1> + scf.yield %measured : i1 + } else { + scf.yield %measured : i1 + } + scf.if %condition { + qc.x %q : !qc.qubit + } + qc.dealloc %q : !qc.qubit + return %classical : !cbit.reg<1> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="snapshot across nested control flow"): + program.to_qiskit() + + assert program.ir == source + + +def test_measurement_result_snapshot_accepts_nested_different_bit_write() -> None: + """Keep a measurement snapshot across a nested write to another bit.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<2> attributes {passthrough = ["entry_point"]} { + %zero = arith.constant 0 : index + %one = arith.constant 1 : index + %true = arith.constant true + %condition_qubit = qc.alloc : !qc.qubit + %nested_qubit = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) source_name = "c" : !cbit.reg<2> + %measured = qc.measure %condition_qubit : !qc.qubit -> i1 + cbit.store %measured, %classical[%zero] : !cbit.reg<2> + scf.if %true { + %nested = qc.measure %nested_qubit : !qc.qubit -> i1 + cbit.store %nested, %classical[%one] : !cbit.reg<2> + } + scf.if %measured { + qc.x %condition_qubit : !qc.qubit + } + qc.dealloc %condition_qubit : !qc.qubit + qc.dealloc %nested_qubit : !qc.qubit + return %classical : !cbit.reg<2> + } +} +""" + ) + source = program.ir + + restored = program.to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == [ + "measure", + "if_else", + "if_else", + ] + assert restored.data[1].operation.blocks[0].count_ops() == {"measure": 1} + assert restored.data[2].operation.blocks[0].count_ops() == {"x": 1} + assert program.ir == source + + def test_stale_classical_snapshot_rejection_preserves_source() -> None: """Reject a condition loaded before a later write to the same register.""" program = QCProgram.from_mlir_str( @@ -1461,6 +1861,7 @@ def test_stale_classical_snapshot_rejection_preserves_source() -> None: %zero = arith.constant 0 : index %stale = cbit.load %classical[%zero] : !cbit.reg<1> %measured = qc.measure %q : !qc.qubit -> i1 + qc.x %q : !qc.qubit cbit.store %measured, %classical[%zero] : !cbit.reg<1> scf.if %stale { qc.x %q : !qc.qubit From 1835d0675343d0047f4d200689dbe4cd38136894 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 10:50:40 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Minimize=20forwarded?= =?UTF-8?q?=20measurement=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep measurement-store adjacency strict and map only validated post-store measurement results to their destination CBits. Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/QiskitExport.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 1baff12e2b..8b5f6a75f8 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -330,6 +330,7 @@ struct ExportState { llvm::DenseMap classicalRegisterInfo; llvm::DenseMap> unconditionalWrites; llvm::DenseMap> measurementDestinations; + llvm::DenseMap measurementResultBits; llvm::DenseSet expressionOperations; std::vector quantumRegisters; std::vector classicalRegisters; @@ -1080,6 +1081,12 @@ exportExpressionImpl(mlir::Value value, ExportState& state, auto result = std::make_unique(); setExpressionType(*result, value.getType()); + if (const auto measured = state.measurementResultBits.find(value); + measured != state.measurementResultBits.end()) { + result->kind = ExpressionKind::ClassicalBit; + result->bit = measured->second; + return result; + } if (result->type == ClassicalType::Uint) { if (auto packed = matchPackedRegister(value, state, evaluationBlock)) { result->kind = ExpressionKind::ClassicalRegister; @@ -1696,8 +1703,7 @@ void validateControlFlowDepth(const size_t controlFlowDepth) { [[nodiscard]] bool isFusableMeasurementStore(mlir::qc::MeasureOp measure, mlir::cbit::StoreOp store) { - if (!measure.getResult().hasOneUse() || - store.getValue() != measure.getResult() || + if (store.getValue() != measure.getResult() || measure->getBlock() != store->getBlock()) { return false; } @@ -1972,11 +1978,14 @@ collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportState& state, if (topLevel) { state.unconditionalWrites[destination.getReg()].insert(checked); } + const auto destinationBit = + checkedAdd(info->second.base, checked, "classical-bit"); circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::Measure, .qubits = mapQubits(measure.getQubit(), state.qubits), - .clbits = { - checkedAdd(info->second.base, checked, "classical-bit")}}); + .clbits = {destinationBit}}); + state.measurementResultBits.try_emplace(measure.getResult(), + destinationBit); continue; } if (auto reset = llvm::dyn_cast(operation)) { From 59124802e1045deb97bcc18346832e1fb9e86100 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 10:50:53 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=85=20Cover=20forwarded=20measurement?= =?UTF-8?q?=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- test/python/test_mlir_qiskit_translation.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 6b6c5c6e90..53dd23ce0b 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -549,6 +549,27 @@ def test_target_compiled_openqasm2_measurements_export() -> None: assert restored.count_ops() == {"measure": 2, "x": 1} +def test_cleanup_forwards_measurement_results_to_qiskit_condition() -> None: + """Export a condition after cleanup forwards its measurement loads.""" + program = QCProgram.from_qasm_str( + """OPENQASM 2.0; +include "qelib1.inc"; +qreg q[3]; +creg c[2]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; +if (c == 3) x q[2]; +""" + ) + optimized = program.to_qco(copy=True) + optimized.cleanup() + + restored = optimized.to_qc(copy=True).to_qiskit() + + assert restored.count_ops() == {"measure": 2, "if_else": 1} + assert restored.data[2].operation.blocks[0].count_ops() == {"x": 1} + + def test_openqasm3_measurement_export_uses_undefined_cbit_register() -> None: """Represent OpenQASM 3 output initialization without poison values.""" program = QCProgram.from_qasm_str( From 235afb10eddedd643d33c643fe88eab2f834f06e Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 10:51:05 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=93=9D=20Narrow=20measurement-result?= =?UTF-8?q?=20export=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that mapping owns topological ordering and document only the supported post-store result forwarding. Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- .agent/plans/qiskit-measurement-deferral.md | 278 +++++++------------- docs/mlir/python_compiler_collection.md | 3 +- 2 files changed, 97 insertions(+), 184 deletions(-) diff --git a/.agent/plans/qiskit-measurement-deferral.md b/.agent/plans/qiskit-measurement-deferral.md index 3b4d8d1a8a..45c9e6a463 100644 --- a/.agent/plans/qiskit-measurement-deferral.md +++ b/.agent/plans/qiskit-measurement-deferral.md @@ -1,4 +1,4 @@ -# Export measurement destinations across independent target work +# Export forwarded Qiskit measurement results This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must @@ -9,230 +9,142 @@ repository root. ## Purpose / Big Picture -Target compilation can leave a measurement result's CBit store after reset, -unitary operations, or later measurements and their destination stores. Qiskit -represents a measurement and its classical destination as one instruction, so -its write is immediate. `QCProgram.to_qiskit()` accepts that delayed MLIR store -when every intervening operation is independent of its destination bit. The -Qiskit instruction order retains the measurement before the intervening work -while attaching the destination to it. - -QC cleanup may also forward a load from the just-written CBit to the measurement -SSA result. The result then has both a destination-store use and classical- -expression uses, so a one-use restriction incorrectly rejects common -measurement-controlled programs. The exporter treats such a result as its unique -destination CBit only when the store precedes every other use. Snapshot -validation rejects intervening or nested writes before a consumer. +MQT Core converts a QC measurement and its following CBit store to one Qiskit +measurement instruction. CBit cleanup can replace a later load from that CBit +with the measurement's SSA result. The result then has a store use and a later +classical-expression use. After this change, `QCProgram.to_qiskit()` exports +that form by using the measurement's destination CBit in the Qiskit expression. + +The destination store must remain directly after the measurement, apart from +constants. This rule makes the store precede every other supported use. ## Progress -- [x] (2026-08-19 15:54Z) Split this follow-up from the completed structured - exporter branch and inspect the earlier combined regression. -- [x] (2026-08-19 15:55Z) Relax only the measurement/store adjacency predicate - and document its classical-state equivalence argument. -- [x] (2026-08-19 15:56Z) Add a sparse target-mapping regression and a direct - CBit MLIR regression. -- [x] (2026-08-19 16:19Z) Restack onto the audited import and exporter parents, - strengthen the stale-snapshot negative, and pass the complete translation - file and repository lint. -- [x] (2026-08-19 20:15Z) Restack onto the post-merge exporter foundation after - its scalar-input reachability fix and pass all 199 translation tests. -- [x] (2026-08-19 20:54Z) Diagnose the three Benchpress failures as forwarded - measurement-result uses, add destination provenance and snapshot - validation, rebuild, and pass 203 active translation tests. -- [x] (2026-08-19 21:16Z) Restack the Qiskit branches onto the latest scalar - foundation, pass the complete translation file and lint, and validate the - combined Qiskit/classical tree with all 4,156 native tests, all 219 Qiskit - translation tests, and all 40 Benchpress integration tests. -- [x] (2026-08-19 21:19Z) Narrow snapshot invalidation from register-wide to - bit-precise, add direct and nested different-bit regressions, rebuild, and - pass all six affected tests, all 206 active translation tests, and lint. -- [x] (2026-08-19 22:52Z) Accept reversed stores for measurements with distinct - static destination bits, add alias regressions, build the binding, pass - all 209 active translation tests, and pass repository lint before - independent audit. +- [x] (2026-08-19 20:54Z) Reproduce the cleanup-forwarded measurement condition + from the Benchpress integration. +- [x] (2026-08-25 08:37Z) Merge the current structured-control exporter and + current `main`, and resolve the old stack in favor of the reviewed base. +- [x] (2026-08-25 08:48Z) Remove delayed-store scheduling support that belongs + to the mapping pass, retain strict adjacency, and reduce the exporter to + one result-to-CBit lookup. +- [x] (2026-08-25 08:48Z) Add one focused cleanup regression and update the + exporter support documentation. +- [x] (2026-08-25 08:50Z) Build the release binding, pass the focused regression + and all 219 translation tests, regenerate unchanged stubs, pass lint, and + inspect the final parent-relative diff. +- [x] (2026-08-25 08:52Z) Prepare focused gitmoji commits and a pull request + description that records the reduced scope and current validation. ## Surprises & Discoveries -- Observation: The sparse-target example retains all three measurements but may - schedule synthesized quantum gates before a measurement result's CBit store. - Evidence: the strict exporter rejected the compiled program even though those - intervening gates cannot access classical state. +- Observation: The failing cleanup program already has an adjacent measurement + and store. Evidence: its QC IR is `qc.measure`, `cbit.store`, and then an + `scf.if` whose condition contains the measurement result. The old + `hasOneUse()` check caused the misleading adjacency error. -- Observation: A Qiskit measurement always owns its destination Clbit and - therefore writes it immediately. Evidence: the normalized writer API exposes - `addMeasure(qubit, clbit)` rather than separate measure and store operations. +- Observation: The earlier sparse-target failure came from the mapping pass's + generic topological sort. Evidence: the independent mapping change replaces + that sort with a quantum-wire traversal that places each result producer + before its earliest classical user. The exporter does not need a second + scheduler. -- Observation: CBit canonicalization forwards a load after a static store to the - stored SSA value. Evidence: target-independent `QCOProgram.cleanup()` reduces - `measure; store; load; if` to `measure; store; if %result`. +- Observation: Successful Qiskit export already forbids every write that could + replace the recorded destination. Evidence: measurement destinations must be + unique, dynamic destinations fail, and non-measurement CBit stores fail. + Separate destination-snapshot bookkeeping is therefore unnecessary. -- Observation: The three post-merge Benchpress failures contain no operation - between `qc.measure` and `cbit.store`. Evidence: the store is adjacent and a - later `scf.if` is the result's second use; `hasOneUse()` alone produced the - misleading adjacency error. +## Decision Log -- Observation: A mapped three-qubit W-state can place two complete measurement - and store pairs between an earlier measurement and its store. Evidence: the - final QC program orders the destinations as - `measure a; measure b; store b; measure c; store c; store a`, and all three - stores target distinct static bits. +- Decision: Remove only the one-use check and keep the existing same-block + adjacency check. Rationale: strict adjacency already places the store before + every additional supported use. Date/Author: 2026-08-25 / Codex. -## Decision Log +- Decision: Record only the public CBit index for each accepted measurement + result. Rationale: the existing destination validation prevents overwrites in + every program that can reach Qiskit construction. Date/Author: 2026-08-25 / + Codex. -- Decision: Accept `arith.constant`, `qc.measure`, `qc.reset`, and - `qc::UnitaryOpInterface` operations between a measurement and its CBit store. - Also accept another measurement's store when both destinations are static and - distinct. Rationale: these operations cannot observe or overwrite the delayed - destination, so attaching that destination to the earlier measurement is - unobservable. Date/Author: 2026-08-20 / Codex. - -- Decision: Keep control flow, dynamic or aliasing stores, other CBit - operations, other arithmetic, memory effects, and unknown operations - fail-closed in that gap. Rationale: any may observe, overwrite, or condition - behavior on the classical result. Date/Author: 2026-08-20 / Codex. - -- Decision: Record the unique static destination bit and store for each accepted - measurement result during export preflight. Rationale: later supported - expressions can use the Clbit that the measurement already writes, without - mutating the source or allocating a partial output circuit. Date/Author: - 2026-08-19 / Codex. - -- Decision: Require the destination store to precede every non-store use and - apply classical-snapshot overwrite checks from that store to each consumer. - Rationale: substituting a mutable Clbit for an immutable SSA result is valid - only while the destination still contains that result. Date/Author: 2026-08-19 - / Codex. - -- Decision: Guard the exact target-compiled QASM regression when the independent - Qiskit branch lacks the sibling classical-control capability/mapping stack. - Rationale: main's pre-stack mapper aborts on `qco.if`; the always-running - cleanup regression covers the exporter, and combined Benchpress validation - supplies the end-to-end evidence. Date/Author: 2026-08-19 / Codex. +- Decision: Do not accept quantum operations, later measurements, or reversed + stores between a measurement and its destination. Rationale: the mapping pass + owns operation ordering, and accepting those forms in the exporter duplicates + that fix. Date/Author: 2026-08-25 / Codex. ## Outcomes & Retrospective -The exporter fuses delayed measurement destinations across independent quantum -work and measurement writes to distinct static bits. It also exports a -cleanup-forwarded measurement result as its unique destination CBit when the -store dominates every other use and no later write makes that replacement stale. -A different static bit may be written safely; a same-bit or dynamic-index write -remains fail-closed. Direct-result, use-before-store, nested-overwrite, -distinct-bit, cleanup-QASM, and guarded target-compilation regressions cover the -boundary. - -The release MLIR binding builds with the final source patch. The four focused -measurement-store tests pass. All 209 active tests in -`test/python/test_mlir_qiskit_translation.py` pass, with one target-stack-only -case guarded on this independent branch. Repository lint also passes. Before the -final bit-precision correction, the combined tree passed all 4,156 native tests, -all 219 Qiskit translation tests, and all 40 Benchpress integration tests. The -exact six different-bit Benchpress failures that motivated that correction are -covered by direct and nested regressions. The new reversed-store case covers the -mapped W-state shape. The diff remains uncommitted for independent audit, and -nothing is pushed. +The old branch mixed two problems: mapper scheduling and cleanup-forwarded SSA +uses. The current change keeps only the exporter problem. The focused cleanup +test fails on the reviewed parent with +`QC measurement destination must follow the measurement in the same block` and +passes with this change. The release binding builds, all 219 translation tests +pass, stub generation produces no diff, and repository lint passes. ## Context and Orientation -`bindings/mlir/qiskit/QiskitExport.cpp` recursively collects a validated -`ExportedCircuit` before creating a Qiskit object. `isFusableMeasurementStore` -requires one unique static `cbit.store` destination in the same block. It walks -operations between measurement and store, permits only independent quantum work -and measurement writes to distinct static bits, and verifies that the store -precedes every other result use. `ExportState` records the accepted result's -destination bit and store for expression export and snapshot validation. +`bindings/mlir/qiskit/QiskitExport.cpp` validates a complete `ExportedCircuit` +before it creates a Qiskit object. `collectBlock` finds the unique static CBit +store for each `qc.measure`. `exportExpressionImpl` converts supported MLIR +classical values to Qiskit expressions. `ExportState` carries validated resource +indices between those functions. -`test/python/test_mlir_qiskit_translation.py` contains direct MLIR, QASM -cleanup, and target-compilation regressions. -`docs/mlir/python_compiler_collection.md` states the exact measurement-store and -result-use restrictions. +`test/python/test_mlir_qiskit_translation.py` contains the end-to-end Qiskit +translation tests. `docs/mlir/python_compiler_collection.md` documents the +measurement destination and result-use rules. ## Plan of Work -Retain the quantum-only intervening-operation allowlist. Extend it to later -measurements and their provably distinct static stores. Keep same-bit and -dynamic stores fail-closed. Require a unique destination store, prove the store -precedes every other use, and record the accepted destination in `ExportState`. - -Export a recorded measurement result as a classical-bit expression. Extend -snapshot discovery to start at its destination store and reuse the top-level and -nested same-bit write checks. A dynamic-index write may target the snapshot bit -and therefore remains fail-closed. Keep source IR, writer construction, import -code, and unsupported-use preflight unchanged. - -Add regressions for reversed measurement-store order, same-bit and dynamic -intervening stores, a direct measurement-result condition, a consumer before its -store, and top-level and nested overwrites before later consumers. Add an -always-running OpenQASM 2 cleanup case, distinct direct and nested bit writes, -and a guarded exact target-compiled reproducer. Retain the sparse-mapping, -delayed quantum-work, multiple-destination, and stale-load negatives. +In `ExportState`, map each accepted measurement result to its public CBit index. +In `isFusableMeasurementStore`, remove the one-use restriction and keep the +existing constant-only gap rule. In `exportExpressionImpl`, emit a classical-bit +leaf when the input value is a recorded measurement result. -Finally, format, rebuild the release binding, run focused and complete Qiskit -translation tests, run repository lint, and inspect the commit-relative diff. -Prepare the uncommitted diff for independent audit. Do not commit or push. +Add one OpenQASM cleanup regression that exports two measurements and a gate +controlled by both results. Update the documentation with the post-store +expression rule. ## Concrete Steps Run from the repository root: - clang-format --dry-run --Werror bindings/mlir/qiskit/QiskitExport.cpp - uvx ruff check test/python/test_mlir_qiskit_translation.py - uvx rumdl check docs/mlir/python_compiler_collection.md \ - .agent/plans/qiskit-measurement-deferral.md + clang-format -i bindings/mlir/qiskit/QiskitExport.cpp + cmake --build build/python/Release --target mqt-core-mlir-bindings --parallel 8 + pytest -q test/python/test_mlir_qiskit_translation.py \ + -k cleanup_forwards_measurement_results + pytest -q test/python/test_mlir_qiskit_translation.py + uvx nox -s lint git diff --check -Build and test against the worktree extension: - - cmake --build build/release --target mqt-core-mlir-bindings --parallel 8 - pytest test/python/test_mlir_qiskit_translation.py \ - -k 'measurement_result or sparse_target_measurement or measurement_store' - pytest test/python/test_mlir_qiskit_translation.py - uvx nox -s lint +The focused command must report one pass. The complete file must have no +failures. The binding change does not alter a public Python signature, so stub +generation must produce no diff. ## Validation and Acceptance -The delayed-store regression must preserve `measure`, `reset`, `x` order. The -reversed-store regression must preserve measurement order and write distinct -destination bits. Same-bit and dynamic intervening stores must fail without -changing the source. A measurement-result condition after its store must become -a Qiskit condition on the destination CBit. A use before the store and a result -whose destination is overwritten before a consumer must also fail without -changing the source. The exact target-compiled QASM case must pass when the -classical stack is assembled. - -The release binding, complete translation file, and lint must pass. The final -diff from the structured-export parent must remain limited to measurement -destination/result preflight, focused regressions, documentation, and this plan. -No reader, writer, generic control-flow, scalar-parameter, or CBit -definite-write behavior may change. +An OpenQASM 2 program that measures two qubits and applies `x` to a third qubit +when both results equal one must still export after `QCOProgram.cleanup()`. The +result must contain two Qiskit measurements and one `if_else` instruction. ## Idempotence and Recovery -Build, format, lint, and test commands are repeatable. If compiled IR changes, -inspect it before adjusting a regression; do not broaden the predicate without a -new equivalence argument. The work is isolated on a child branch, so its -exporter parent remains recoverable. +Formatting, building, and testing are repeatable. The work remains isolated on +the measurement-result branch. If validation exposes another producer shape, +inspect its final QC IR before changing the store-order rule; do not add an +exporter scheduling policy. ## Artifacts and Notes -The expected source boundary is: - - allowed between measure and store = - arith.constant | qc.measure | qc.reset | qc::UnitaryOpInterface | - statically disjoint measurement cbit.store +The regression on the reviewed parent is: - measurement-result expression = - unique static destination CBit, store before every other use, - no intervening or nested write that may target its destination bit + RuntimeError: QC measurement destination must follow the measurement in the same block -All other operations remain disallowed. +The final branch changes only the measurement-result preflight, two focused +tests, this plan, and the related documentation relative to its parent. ## Interfaces and Dependencies -No public interface or dependency changes are required. The implementation adds -internal result-to-destination maps to `ExportState` and uses existing MLIR -operation classes and QC dialect interfaces. +No public interface or dependency changes are required. The implementation uses +the existing MLIR `Value`, `Operation`, `cbit::StoreOp`, and Qiskit expression +types. Operation reordering remains outside this exporter change. -Revision note: Created when measurement-ordering support was split from the -structured-control exporter. Expanded after post-merge integration exposed -cleanup-forwarded result uses and reversed stores for distinct measurements. +Revision note: Rewritten on 2026-08-25 after the mapping stack took ownership of +topological ordering. The plan now covers only cleanup-forwarded measurement +results. diff --git a/docs/mlir/python_compiler_collection.md b/docs/mlir/python_compiler_collection.md index 1d61c96fcc..e42c7c452a 100644 --- a/docs/mlir/python_compiler_collection.md +++ b/docs/mlir/python_compiler_collection.md @@ -231,7 +231,8 @@ Each exported measurement must write to one static public CBit in the same block, and destinations must be unique. Its destination store must follow the measurement directly, apart from constant operations. A conditional or otherwise delayed destination store is rejected because Qiskit cannot preserve it as one -measurement instruction. +measurement instruction. The measurement result may feed supported classical +expressions after that store and is exported as the destination CBit. Dense numeric unitaries remain explicit matrix operations during import and export. Target compilation synthesizes supported one- and two-qubit matrices to