From 948d8d012bed63cda72842c55808ce4b8c02dccc Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Wed, 19 Aug 2026 17:05:49 +0200 Subject: [PATCH 01/38] =?UTF-8?q?=E2=9C=A8=20Import=20captured=20Qiskit=20?= =?UTF-8?q?expressions?= 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 c5c32c434a..59bedc994f 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -663,8 +663,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"); } @@ -679,8 +681,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: { @@ -688,22 +698,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: { @@ -729,9 +755,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"); @@ -901,6 +927,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"); @@ -910,12 +937,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_); @@ -1208,6 +1237,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; @@ -1218,11 +1248,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"); } @@ -1267,7 +1304,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 { @@ -1303,8 +1340,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; } @@ -1330,8 +1373,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"); @@ -1405,28 +1450,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 { @@ -1454,15 +1509,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 6a91dfec8f..49501f493a 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -582,8 +582,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) { @@ -597,8 +599,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; } @@ -618,7 +631,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()); @@ -657,8 +671,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; @@ -764,8 +780,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( @@ -861,7 +879,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"); @@ -886,7 +911,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())) { @@ -1390,22 +1422,48 @@ void validateCircuit(const CircuitReader& circuit, 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); @@ -1443,7 +1501,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 67c02b66e9..f6780ca7a4 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -191,6 +191,8 @@ enum class ExpressionKind : uint8_t { Cast, Value, Index, + ClassicalBit, + ClassicalRegister, }; enum class BinaryOperation : uint8_t { BitAnd, @@ -227,6 +229,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 e89e19f9db..a2196479a8 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -35,6 +35,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 @@ -1024,6 +1025,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 f7b305a19c6aee991d4ad3c762ed0ef2fb5a15bd Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Fri, 21 Aug 2026 15:36:49 +0200 Subject: [PATCH 02/38] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Qiskit=20classical?= =?UTF-8?q?=20expression=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/Qiskit2_5.cpp | 302 ++++---------------- bindings/mlir/qiskit/QiskitImport.cpp | 103 ++++++- test/python/test_mlir_qiskit_translation.py | 93 +++++- 3 files changed, 244 insertions(+), 254 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 59bedc994f..5461c4b6b5 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -577,212 +578,6 @@ void normalizePythonGate(const nb::handle operation, Instruction& result, "Qiskit operation has an invalid name"); } -[[nodiscard]] ClassicalType normalizeType(const QkExprTypeInfo type) { - switch (type.ty) { - case QkExprType_Bool: - return ClassicalType::Bool; - case QkExprType_Uint: - if (type.width == 0U || type.width > 64U) { - throw std::runtime_error("Qiskit unsigned classical values wider than 64 " - "bits are not supported"); - } - return ClassicalType::Uint; - case QkExprType_Float: - return ClassicalType::Float; - case QkExprType_Duration: - throw std::runtime_error( - "Qiskit circuit import does not support duration expressions"); - } - throw std::runtime_error( - "Qiskit returned an unknown classical expression type"); -} - -void setType(Expression& result, const QkExprTypeInfo type) { - result.type = normalizeType(type); - if (result.type == ClassicalType::Bool) { - result.width = 1U; - } else if (result.type == ClassicalType::Float) { - result.width = 64U; - } else { - result.width = static_cast(type.width); - } -} - -[[nodiscard]] BinaryOperation -normalizeBinaryOperation(const QkBinaryOpType op) { - switch (op) { - case QkBinaryOpType_BitAnd: - return BinaryOperation::BitAnd; - case QkBinaryOpType_BitOr: - return BinaryOperation::BitOr; - case QkBinaryOpType_BitXor: - return BinaryOperation::BitXor; - case QkBinaryOpType_LogicAnd: - return BinaryOperation::LogicAnd; - case QkBinaryOpType_LogicOr: - return BinaryOperation::LogicOr; - case QkBinaryOpType_Equal: - return BinaryOperation::Equal; - case QkBinaryOpType_NotEqual: - return BinaryOperation::NotEqual; - case QkBinaryOpType_Less: - return BinaryOperation::Less; - case QkBinaryOpType_LessEqual: - return BinaryOperation::LessEqual; - case QkBinaryOpType_Greater: - return BinaryOperation::Greater; - case QkBinaryOpType_GreaterEqual: - return BinaryOperation::GreaterEqual; - case QkBinaryOpType_ShiftLeft: - return BinaryOperation::ShiftLeft; - case QkBinaryOpType_ShiftRight: - return BinaryOperation::ShiftRight; - case QkBinaryOpType_Add: - return BinaryOperation::Add; - case QkBinaryOpType_Sub: - return BinaryOperation::Subtract; - case QkBinaryOpType_Mul: - return BinaryOperation::Multiply; - case QkBinaryOpType_Div: - return BinaryOperation::Divide; - } - throw std::runtime_error( - "Qiskit returned an unknown binary expression operation"); -} - -[[nodiscard]] UnaryOperation normalizeUnaryOperation(const QkUnaryOpType op) { - switch (op) { - case QkUnaryOpType_BitNot: - return UnaryOperation::BitNot; - case QkUnaryOpType_LogicNot: - return UnaryOperation::LogicNot; - case QkUnaryOpType_Negate: - return UnaryOperation::Negate; - } - throw std::runtime_error( - "Qiskit returned an unknown unary expression operation"); -} - -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"); - } - if (depth >= MAX_EXPRESSION_DEPTH) { - throw std::runtime_error( - "Qiskit classical expressions exceed the nesting limit of 64"); - } - auto result = std::make_unique(); - switch (qk_expr_kind(expression)) { - case QkExprNodeKind_Binary: { - const auto info = qk_expr_binary_info(expression); - result->kind = ExpressionKind::Binary; - result->binaryOperation = normalizeBinaryOperation(info.op); - setType(*result, info.ty); - 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: { - const auto info = qk_expr_unary_info(expression); - result->kind = ExpressionKind::Unary; - result->unaryOperation = normalizeUnaryOperation(info.op); - setType(*result, info.ty); - 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, - 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, - 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: { - const auto* value = qk_expr_as_value(expression); - const auto type = qk_value_type_info(value); - result->kind = ExpressionKind::Value; - setType(*result, type); - switch (result->type) { - case ClassicalType::Bool: - result->boolValue = qk_value_bool(value); - break; - case ClassicalType::Uint: - result->uintValue = qk_value_uint(value); - break; - case ClassicalType::Float: - result->floatValue = qk_value_float(value); - if (!std::isfinite(result->floatValue)) { - throw std::runtime_error( - "Qiskit classical floating-point literals must be finite"); - } - break; - } - return result; - } - case QkExprNodeKind_Var: - 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"); - } - throw std::runtime_error( - "Qiskit returned an unknown classical expression node"); -} - -[[nodiscard]] Register normalizeRegister(const QkClassicalRegister* reg, - const QkCircuit* rootCircuit) { - // qk_str_free requires the mutable allocation returned by Qiskit. - // NOLINTNEXTLINE(misc-const-correctness) - char* const name = qk_classical_register_name(reg); - if (name == nullptr) { - throwPythonError("Qiskit failed to read a classical-register name"); - } - Register result{.name = name}; - qk_str_free(name); - result.bits.resize(qk_classical_register_num_bits(reg)); - if (!result.bits.empty()) { - qk_classical_register_circuit_bits(reg, rootCircuit, result.bits.data()); - } - return result; -} - class OwnedParameter final { public: OwnedParameter() : value_(qk_param_zero()) { @@ -1337,49 +1132,59 @@ class NativeControlFlowReader final : public ControlFlowReader { [[nodiscard]] ClassicalTarget condition() const override { ClassicalTarget result; - 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) { + const auto condition = pythonAttribute( + operation_, "condition", "Qiskit control flow has no condition"); + const auto expressionModule = + nb::module_::import_("qiskit.circuit.classical.expr"); + if (nb::isinstance(condition, expressionModule.attr("Expr"))) { + result.kind = ClassicalTargetKind::Expression; + result.expression = normalizePythonExpressionOnly(condition); + return result; + } + + const auto tupleType = nb::module_::import_("builtins").attr("tuple"); + if (!nb::isinstance(condition, tupleType) || nb::len(condition) != 2U) { + throw std::runtime_error("Qiskit control-flow condition has an invalid " + "shape"); + } + const nb::handle target = condition[0]; + uint64_t expected = 0U; + if (!nb::try_cast(condition[1], expected)) { + throw std::runtime_error( + "Qiskit control-flow condition has an invalid value"); + } + + const auto circuitModule = nb::module_::import_("qiskit.circuit"); + if (nb::isinstance(target, circuitModule.attr("Clbit"))) { + if (expected > 1U) { throw std::runtime_error( - "Qiskit classical-bit condition has an invalid shape"); + "Qiskit classical-bit condition must compare against zero or one"); } result.kind = ClassicalTargetKind::ClassicalBit; - result.bit = rootClbitIndex(condition[0]); - result.expectedBit = bit.condition; + result.bit = rootClbitIndex(target); + result.expectedBit = expected != 0U; return result; } - case QkConditionType_ClReg: { - const auto conditionWidth = - qk_control_flow_condition_reg_cond_bit_width(controlFlow_); - if (conditionWidth > 64U) { + if (nb::isinstance(target, circuitModule.attr("ClassicalRegister"))) { + const auto size = nb::len(target); + if (size == 0U || size > 64U) { throw std::runtime_error( - "Qiskit register conditions wider than 64 bits are not supported"); + "Qiskit register conditions require between 1 and 64 bits"); } result.kind = ClassicalTargetKind::ClassicalRegister; - result.reg = normalizeRegister( - qk_control_flow_condition_reg(controlFlow_), rootCircuit_); - if (result.reg.bits.empty() || result.reg.bits.size() > 64U) { - throw std::runtime_error( - "Qiskit register conditions require between 1 and 64 bits"); + result.reg.name = pythonStringAttribute( + target, "name", "Qiskit condition register has no name"); + result.reg.bits.reserve(size); + for (const nb::handle bit : nb::iter(target)) { + result.reg.bits.push_back(rootClbitIndex(bit)); } result.width = static_cast( - std::max(conditionWidth, result.reg.bits.size())); - result.expectedRegister = - qk_control_flow_condition_reg_cond_uint(controlFlow_); + std::max(size, std::bit_width(expected))); + result.expectedRegister = expected; return result; } - case QkConditionType_Expr: - result.kind = ClassicalTargetKind::Expression; - 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"); + throw std::runtime_error("Qiskit control flow has an unknown condition " + "target"); } [[nodiscard]] Loop loop() const override { @@ -1688,16 +1493,21 @@ class NativeControlFlowReader final : public ControlFlowReader { 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)) { + case ClassicalType::Bool: { + uint64_t boolValue = 0U; + if (!nb::try_cast(value, boolValue) || boolValue > 1U) { throw std::runtime_error( "Qiskit Boolean expression has an invalid value"); } + result->boolValue = boolValue != 0U; break; + } case ClassicalType::Uint: - if (!nb::try_cast(value, result->uintValue)) { + if (!nb::try_cast(value, result->uintValue) || + (result->width < 64U && + result->uintValue >= (uint64_t{1} << result->width))) { throw std::runtime_error( - "Qiskit Uint expression has an invalid value"); + "Qiskit Uint literal does not fit its declared width"); } break; case ClassicalType::Float: @@ -1799,16 +1609,6 @@ class NativeControlFlowReader final : public ControlFlowReader { "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; diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 49501f493a..7c3afca930 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -615,6 +615,21 @@ circuitRegisters(const CircuitReader& circuit, const bool quantum) { if (operand.getType() == resultType) { return operand; } + if (expression.type == ClassicalType::Bool) { + if (const auto source = + llvm::dyn_cast(operand.getType())) { + return mlir::arith::CmpIOp::create( + builder, mlir::arith::CmpIPredicate::ne, operand, + integerConstant(builder, source.getWidth(), 0U)) + .getResult(); + } + if (operand.getType().isF64()) { + return mlir::arith::CmpFOp::create(builder, + mlir::arith::CmpFPredicate::UNE, + operand, floatConstant(builder, 0.0)) + .getResult(); + } + } if (const auto target = llvm::dyn_cast(resultType)) { if (llvm::isa(operand.getType())) { return castInteger(builder, operand, target); @@ -1438,6 +1453,19 @@ void validateExpression(const Expression& expression, } validateExpression(*operand, rootClbits); }; + const auto sameType = [](const Expression& first, const Expression& second) { + return first.type == second.type && first.width == second.width; + }; + const auto hasType = [](const Expression& value, const ClassicalType type) { + return value.type == type; + }; + const auto requireCompatible = [](const bool compatible) { + if (!compatible) { + throw std::runtime_error( + "Qiskit classical expression has incompatible operator and operand " + "types"); + } + }; switch (expression.kind) { case ExpressionKind::Value: return; @@ -1464,14 +1492,85 @@ void validateExpression(const Expression& expression, } return; } - case ExpressionKind::Unary: + case ExpressionKind::Unary: { + requireOperand(expression.left); + const auto& operand = *expression.left; + switch (expression.unaryOperation) { + case UnaryOperation::BitNot: + requireCompatible((hasType(operand, ClassicalType::Bool) || + hasType(operand, ClassicalType::Uint)) && + sameType(expression, operand)); + return; + case UnaryOperation::LogicNot: + requireCompatible(hasType(expression, ClassicalType::Bool) && + hasType(operand, ClassicalType::Bool)); + return; + case UnaryOperation::Negate: + requireCompatible(hasType(expression, ClassicalType::Float) && + hasType(operand, ClassicalType::Float)); + return; + } + return; + } case ExpressionKind::Cast: requireOperand(expression.left); return; - case ExpressionKind::Binary: + case ExpressionKind::Binary: { + requireOperand(expression.left); + requireOperand(expression.right); + const auto& left = *expression.left; + const auto& right = *expression.right; + switch (expression.binaryOperation) { + case BinaryOperation::BitAnd: + case BinaryOperation::BitOr: + case BinaryOperation::BitXor: + requireCompatible(sameType(left, right) && sameType(expression, left) && + (hasType(left, ClassicalType::Bool) || + hasType(left, ClassicalType::Uint))); + return; + case BinaryOperation::LogicAnd: + case BinaryOperation::LogicOr: + requireCompatible(hasType(expression, ClassicalType::Bool) && + hasType(left, ClassicalType::Bool) && + hasType(right, ClassicalType::Bool)); + return; + case BinaryOperation::Equal: + case BinaryOperation::NotEqual: + requireCompatible(hasType(expression, ClassicalType::Bool) && + sameType(left, right)); + return; + case BinaryOperation::Less: + case BinaryOperation::LessEqual: + case BinaryOperation::Greater: + case BinaryOperation::GreaterEqual: + requireCompatible(hasType(expression, ClassicalType::Bool) && + sameType(left, right) && + (hasType(left, ClassicalType::Uint) || + hasType(left, ClassicalType::Float))); + return; + case BinaryOperation::ShiftLeft: + case BinaryOperation::ShiftRight: + requireCompatible(hasType(left, ClassicalType::Uint) && + hasType(right, ClassicalType::Uint) && + sameType(expression, left)); + return; + case BinaryOperation::Add: + case BinaryOperation::Subtract: + case BinaryOperation::Multiply: + case BinaryOperation::Divide: + requireCompatible(sameType(left, right) && sameType(expression, left) && + (hasType(left, ClassicalType::Uint) || + hasType(left, ClassicalType::Float))); + return; + } + return; + } case ExpressionKind::Index: requireOperand(expression.left); requireOperand(expression.right); + requireCompatible(hasType(expression, ClassicalType::Bool) && + hasType(*expression.left, ClassicalType::Uint) && + hasType(*expression.right, ClassicalType::Uint)); return; } } diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index a2196479a8..5fb7e5f668 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -35,7 +35,7 @@ library, ) from qiskit.circuit.classical import expr, types -from qiskit.circuit.controlflow import CASE_DEFAULT +from qiskit.circuit.controlflow import CASE_DEFAULT, IfElseOp from qiskit.quantum_info import Operator, random_unitary from mqt.core.mlir import CompilerTarget, QCProgram, compile_program @@ -1011,6 +1011,7 @@ def test_qiskit_import_zero_initializes_clbits_before_control_flow() -> None: expr.greater(expr.cast(expr.lift(2, types.Uint(8)), types.Float()), 0.5), "arith.uitofp", ), + (expr.cast(expr.lift(0.5, types.Float()), types.Bool()), "arith.cmpf une"), (expr.greater(expr.negate(expr.lift(0.5, types.Float())), -1.0), "arith.negf"), ], ) @@ -1038,6 +1039,96 @@ def _cbit_load_indices(ir: str) -> list[int]: return [constants[name] for name in re.findall(r"(?m)^\s*%[-\w.$]+ = cbit\.load [^\[]+\[(%[-\w.$]+)\]", ir)] +def test_boolean_expression_literals_are_imported() -> None: + """Normalize Qiskit's integer-backed Boolean Value nodes.""" + false_literal = False + true_literal = True + circuit = QuantumCircuit(1) + with circuit.if_test(expr.logic_or(expr.lift(false_literal), expr.lift(true_literal))): + circuit.x(0) + + ir = QCProgram.from_qiskit(circuit).ir + + assert "arith.constant false" in ir + assert "arith.constant true" in ir + assert "arith.ori" in ir + + +def test_uint_register_cast_to_bool_tests_all_bits() -> None: + """Treat a Uint register as true when any bit is set.""" + circuit = QuantumCircuit(1, 2) + circuit.x(0) + circuit.measure(0, 1) + with circuit.if_test(expr.cast(circuit.cregs[0], types.Bool())): + circuit.z(0) + + ir = QCProgram.from_qiskit(circuit).ir + + assert "arith.cmpi ne" in ir + assert "arith.trunci" not in ir + + +def test_public_expression_condition_mutation_is_observed() -> None: + """Import the current public expression after condition mutation.""" + circuit = QuantumCircuit(1, 2) + with circuit.if_test(expr.logic_and(circuit.clbits[0], circuit.clbits[1])): + circuit.x(0) + operation = circuit.data[0].operation + assert isinstance(operation, IfElseOp) + operation.condition = expr.logic_or(circuit.clbits[0], circuit.clbits[1]) + + ir = QCProgram.from_qiskit(circuit).ir + + assert "arith.ori" in ir + assert "arith.andi" not in ir + + +def test_public_tuple_condition_mutation_is_observed() -> None: + """Import the current bit and value after tuple-condition mutation.""" + body = QuantumCircuit(1) + body.x(0) + circuit = QuantumCircuit(1, 2) + circuit.if_test((circuit.clbits[0], False), body, circuit.qubits, []) + operation = circuit.data[0].operation + assert isinstance(operation, IfElseOp) + operation.condition = (circuit.clbits[1], True) + + ir = QCProgram.from_qiskit(circuit).ir + + assert _cbit_load_indices(ir) == [1] + assert "arith.constant true" in ir + assert "arith.constant false" not in ir + + +def test_narrow_uint_switch_literal_is_rejected() -> None: + """Reject a Uint literal that does not fit its declared width.""" + circuit = QuantumCircuit(1, 1) + with circuit.switch(expr.Value(3, types.Uint(1)), None, None, None, label=None) as case, case(0): + circuit.x(0) + + with pytest.raises(RuntimeError, match=r"Uint literal.*does not fit"): + QCProgram.from_qiskit(circuit) + + +def test_malformed_public_expression_type_is_rejected() -> None: + """Reject a public expression whose declared result type is inconsistent.""" + invalid = expr.Binary( + expr.Binary.Op.ADD, + expr.Value(1, types.Uint(1)), + expr.Value(1, types.Uint(1)), + types.Bool(), + ) + circuit = QuantumCircuit(1) + with circuit.if_test(expr.equal(1, 1)): + circuit.x(0) + operation = circuit.data[0].operation + assert isinstance(operation, IfElseOp) + operation.condition = invalid + + with pytest.raises(RuntimeError, match="incompatible operator and operand types"): + QCProgram.from_qiskit(circuit) + + 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) From 9bd4855233d41fc2c5229f05ba815cd5d6bb2ee0 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Fri, 21 Aug 2026 15:37:48 +0200 Subject: [PATCH 03/38] =?UTF-8?q?=F0=9F=93=9D=20Update=20Qiskit=20capture?= =?UTF-8?q?=20plan=20and=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qiskit-classical-expression-captures.md | 135 ++++++++++++++---- CHANGELOG.md | 3 +- 2 files changed, 106 insertions(+), 32 deletions(-) diff --git a/.agent/plans/qiskit-classical-expression-captures.md b/.agent/plans/qiskit-classical-expression-captures.md index 5d7a088a2e..7a5849a600 100644 --- a/.agent/plans/qiskit-classical-expression-captures.md +++ b/.agent/plans/qiskit-classical-expression-captures.md @@ -60,6 +60,23 @@ Qiskit control-flow operations during export. - [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. +- [x] (2026-08-21 12:30Z) Rebase the capture-only change onto current `main` + after symbolic Qiskit parameter support merged, dropping the superseded + parent commit while preserving the focused six-file feature delta. +- [x] (2026-08-21 13:05Z) Reproduce three review findings: stale native + condition operators after public Python mutation, an aborting out-of-range + `Uint` switch literal, and low-bit truncation for `Uint`-to-`Bool` casts. +- [x] (2026-08-21 13:25Z) Make conditions Python-authoritative, validate literal + widths, lower Boolean casts as nonzero comparisons, remove the dead hybrid + native-expression walker, normalize integer-backed Boolean values, and + preflight operator/type compatibility. +- [x] (2026-08-21 13:35Z) Rebuild and refresh the editable MLIR binding, pass + all 21 focused capture and corrective cases, and pass all 174 Qiskit + translation tests against the updated extension. +- [x] (2026-08-21 13:40Z) Pass the complete repository lint session, pinned + formatting hooks, and `git diff --check`. +- [x] (2026-08-21 13:45Z) Inspect the final diff, create separate gitmoji + implementation and documentation commits, and push only PR #2175. ## Surprises & Discoveries @@ -100,6 +117,32 @@ Qiskit control-flow operations during export. zero; resolving the Python condition bit through the enclosing map emits index one. +- Observation: Qiskit's public control-flow condition setter updates the Python + operation while the native control-flow view can retain the tree recorded at + insertion time. Evidence: mutating a public condition from logical AND to OR + leaves the native operator as AND, so combining native operators with Python + leaves silently imports a mixed, stale expression. + +- Observation: Qiskit accepts a public `expr.Value(3, Uint(1))` switch target, + so the importer must reject a value that does not fit its declared width + before constructing an LLVM `APInt`. Without the preflight, LLVM aborts the + Python process instead of reporting a recoverable import error. + +- Observation: Public Qiskit Boolean `Value` nodes expose their value as Python + integer zero or one. A strict nanobind conversion to C++ `bool` rejects both, + so normalization must accept only the integer range `[0, 1]` and convert it + explicitly. + +- Observation: A Qiskit cast to `Bool` tests whether the complete source value + is nonzero. Truncating a packed register to `i1` inspects only its least + significant bit; for example, `0b10` must be true rather than false. + +- Observation: Qiskit's low-level public expression constructors and public + condition setter permit a node whose declared result type conflicts with its + operator and operands. The Python-authoritative path therefore needs its own + recursive type preflight rather than relying on constructor helpers having + produced every tree. + ## Decision Log - Decision: Add `ClassicalBit` and `ClassicalRegister` to `ExpressionKind`, with @@ -107,7 +150,7 @@ Qiskit control-flow operations during export. 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. +- Decision: Keep the scalar `Parameter` model 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. @@ -128,6 +171,24 @@ Qiskit control-flow operations during export. established native control-flow reader for supported metadata. Date/Author: 2026-08-19 / Codex. +- Decision: Parse the complete public Python condition for expressions, Clbits, + registers, and comparison values; retain native metadata only for blocks, + capture maps, loops, and switch cases. Rationale: one authoritative tree + prevents stale native operators or values from being combined with current + Python capture identities. Date/Author: 2026-08-21 / Codex. + +- Decision: Range-check every unsigned literal against its normalized width and + lower casts to `Bool` with integer or unordered floating-point comparisons + against zero. Rationale: malformed public inputs must raise a runtime error, + and Boolean conversion must inspect the whole value, including NaN for Qiskit + floating-point expressions. Date/Author: 2026-08-21 / Codex. + +- Decision: Validate operator, operand, and result-type compatibility on the + normalized expression before emitting MLIR. Rationale: malformed public trees + must fail deterministically during preflight instead of producing ill-typed + semantics or partially constructing a program. Date/Author: 2026-08-21 / + 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 @@ -145,12 +206,13 @@ 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. +After rebasing onto current `main`, the corrective review pass rebuilt and +refreshed the MLIR binding, passed 21 focused cases covering current public +conditions, bounded literals, Boolean casts, and malformed expression typing, +and passed all 174 tests in the complete Qiskit translation file. The complete +repository lint session, pinned Clang and Python formatting, Rumdl, Ruff, `ty`, +targeted Clang-Tidy 21.1.1, and `git diff --check` all pass. Export-side writer +construction remains deliberately out of scope. ## Context and Orientation @@ -182,22 +244,24 @@ 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. +Next, update `bindings/mlir/qiskit/Qiskit2_5.cpp`. Normalize condition and +switch expression trees entirely from the current public Python operation. +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, and reject unsigned literals that do not fit +their declared width before MLIR construction. 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. +Lower casts to `Bool` as nonzero comparisons instead of integer truncation or +floating-point conversion. 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 @@ -220,16 +284,17 @@ Inspect the focused diff and formatting: 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: +Build the Qiskit binding with the configured Python release tree. If the +worktree has no compatible build tree yet, refresh the editable installation +through the repository's standard `uv` workflow first: - cmake --build build/release --parallel 8 + cmake --build build/python/Release --target mqt-core-mlir-bindings --parallel 8 + uv sync --inexact --no-dev --no-build-isolation-package mqt-core 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' + -k 'classical_expression or condition_only or switch_expression or bool_uint_and_float or boolean_expression or cast_to_bool or condition_mutation or narrow_uint or malformed_public_expression' Run the complete Qiskit translation test file after the focused tests pass: @@ -253,7 +318,12 @@ 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. +the enclosing block. Publicly mutating either an expression or tuple condition +must import the current Python operator, target, and comparison value. A packed +register containing `0b10` must cast to true. An unsigned literal outside its +declared width must raise `RuntimeError` rather than aborting the process. A +public expression whose declared result type conflicts with its operator and +operands must fail during preflight. Malformed block-capture lists and variables absent from the containing circuit must fail during validation with a clear runtime error. Existing literal @@ -272,7 +342,7 @@ reader. Do not add a private exporter fallback. ## Artifacts and Notes -The source branch begins at the focused scalar-symbol parent, which already +The focused capture branch is based directly on current `main`, 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 @@ -284,10 +354,11 @@ 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`. +circuit, and the root Python circuit. Its Python-authoritative condition and +expression normalization resolves all classical leaves and legacy Clbit or +register 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 @@ -301,4 +372,6 @@ 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. +index. Updated it after rebasing onto current `main` and addressing the final +review findings to record Python-authoritative conditions, bounded unsigned +literals, nonzero Boolean casts, and their focused regressions. diff --git a/CHANGELOG.md b/CHANGELOG.md index 90514b8e4f..3a750ac07e 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], [#2150]) ([**@burgholzer**], + collection ([#2031], [#2133], [#2140], [#2150], [#2175]) ([**@burgholzer**], [**@simon1hofmann**]) - ✨ Add conversions between `jeff` and QCO ([#1479], [#1548], [#1565], [#1637], [#1676], [#1706], [#1776], [#1836], [#1934], [#2000], [#2018], [#2105]) @@ -786,6 +786,7 @@ for previous changelogs._ +[#2175]: https://github.com/munich-quantum-toolkit/core/pull/2175 [#2168]: https://github.com/munich-quantum-toolkit/core/pull/2168 [#2158]: https://github.com/munich-quantum-toolkit/core/pull/2158 [#2157]: https://github.com/munich-quantum-toolkit/core/pull/2157 From 7cfa8685e3a0f235c99c00d05583831fb8607111 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Fri, 21 Aug 2026 15:57:30 +0200 Subject: [PATCH 04/38] =?UTF-8?q?=F0=9F=90=9B=20Delay=20Qiskit=20control-f?= =?UTF-8?q?low=20handle=20acquisition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acquire the native control-flow handle only after Python object initialization succeeds. Document the shared import test helpers. Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/Qiskit2_5.cpp | 8 ++++---- test/python/test_mlir_qiskit_translation.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 5461c4b6b5..2ae4a10022 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -1047,14 +1047,14 @@ class NativeControlFlowReader final : public ControlFlowReader { nb::object containingPythonCircuit, nb::object rootPythonCircuit) : rootCircuit_(rootCircuit), circuit_(circuit), parent_(parent), - controlFlow_( - qk_circuit_get_control_flow_instruction(circuit, index, parent)), instruction_(std::move(instruction)), operation_(pythonAttribute( instruction_, "operation", "Qiskit circuit instruction has no control-flow operation")), containingPythonCircuit_(std::move(containingPythonCircuit)), - rootPythonCircuit_(std::move(rootPythonCircuit)) { + rootPythonCircuit_(std::move(rootPythonCircuit)), + controlFlow_( + qk_circuit_get_control_flow_instruction(circuit, index, parent)) { if (controlFlow_ == nullptr) { throwPythonError("Qiskit failed to inspect a control-flow instruction"); } @@ -1612,11 +1612,11 @@ class NativeControlFlowReader final : public ControlFlowReader { 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_; + QkControlFlowInstruction* controlFlow_ = nullptr; }; std::unique_ptr diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 5fb7e5f668..fbeb253359 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -1027,12 +1027,28 @@ def test_bool_uint_and_float_expressions(condition: expr.Expr, operation: str) - def _round_trip_qiskit_import(circuit: QuantumCircuit) -> str: + """Import a Qiskit circuit and validate its MLIR round trip. + + Args: + circuit: Qiskit circuit to import. + + Returns: + The imported MLIR text. + """ 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]: + """Extract the constant indices used by CBit loads. + + Args: + ir: MLIR text to inspect. + + Returns: + The CBit load indices in occurrence order. + """ constants = { name: int(value) for name, value in re.findall(r"(?m)^\s*(%[-\w.$]+) = arith\.constant (\d+) : index$", ir) } From e16e32f1284adfc7f8a7cb877e471d5550167d0f Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Fri, 21 Aug 2026 16:16:03 +0200 Subject: [PATCH 05/38] =?UTF-8?q?=F0=9F=90=9B=20Bound=20Qiskit=20classical?= =?UTF-8?q?=20expression=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the total number of nodes during Python classical-expression normalization and reject trees larger than 4096 nodes before allocating the excess node. Assisted-by: GPT-5.6 via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/Qiskit2_5.cpp | 25 ++++++++++++++------- test/python/test_mlir_qiskit_translation.py | 19 ++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 2ae4a10022..89b2a414f8 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -72,6 +72,7 @@ namespace nb = nanobind; namespace { constexpr size_t MAX_EXPRESSION_DEPTH = 64U; +constexpr size_t MAX_EXPRESSION_NODES = 4096U; constexpr size_t MAX_ANNOTATED_OPERATION_DEPTH = 64U; [[nodiscard]] nb::object pythonAttribute(const nb::handle object, @@ -1138,7 +1139,8 @@ class NativeControlFlowReader final : public ControlFlowReader { nb::module_::import_("qiskit.circuit.classical.expr"); if (nb::isinstance(condition, expressionModule.attr("Expr"))) { result.kind = ClassicalTargetKind::Expression; - result.expression = normalizePythonExpressionOnly(condition); + size_t nodeCount = 0U; + result.expression = normalizePythonExpressionOnly(condition, nodeCount); return result; } @@ -1283,7 +1285,8 @@ class NativeControlFlowReader final : public ControlFlowReader { if (nb::isinstance(target, expressionModule.attr("Expr"))) { result.kind = ClassicalTargetKind::Expression; // Qiskit 2.5's native switch-target accessors abort for expressions. - result.expression = normalizePythonExpressionOnly(target); + size_t nodeCount = 0U; + result.expression = normalizePythonExpressionOnly(target, nodeCount); return result; } throw std::runtime_error("Qiskit switch has an unknown target type"); @@ -1473,11 +1476,17 @@ class NativeControlFlowReader final : public ControlFlowReader { [[nodiscard]] std::unique_ptr normalizePythonExpressionOnly(const nb::handle pythonExpression, + size_t& nodeCount, const size_t depth = 0U) const { if (depth >= MAX_EXPRESSION_DEPTH) { throw std::runtime_error( "Qiskit classical expressions exceed the nesting limit of 64"); } + if (nodeCount >= MAX_EXPRESSION_NODES) { + throw std::runtime_error( + "Qiskit classical expressions exceed the node limit of 4096"); + } + ++nodeCount; auto result = std::make_unique(); setPythonExpressionType(*result, pythonExpression); const auto className = pythonStringAttribute( @@ -1529,7 +1538,7 @@ class NativeControlFlowReader final : public ControlFlowReader { result->left = normalizePythonExpressionOnly( pythonAttribute(pythonExpression, "operand", "Qiskit unary expression has no operand"), - depth + 1U); + nodeCount, depth + 1U); return result; } if (className == "Binary") { @@ -1541,11 +1550,11 @@ class NativeControlFlowReader final : public ControlFlowReader { result->left = normalizePythonExpressionOnly( pythonAttribute(pythonExpression, "left", "Qiskit binary expression has no left operand"), - depth + 1U); + nodeCount, depth + 1U); result->right = normalizePythonExpressionOnly( pythonAttribute(pythonExpression, "right", "Qiskit binary expression has no right operand"), - depth + 1U); + nodeCount, depth + 1U); return result; } if (className == "Cast") { @@ -1553,7 +1562,7 @@ class NativeControlFlowReader final : public ControlFlowReader { result->left = normalizePythonExpressionOnly( pythonAttribute(pythonExpression, "operand", "Qiskit cast expression has no operand"), - depth + 1U); + nodeCount, depth + 1U); return result; } if (className == "Index") { @@ -1561,11 +1570,11 @@ class NativeControlFlowReader final : public ControlFlowReader { result->left = normalizePythonExpressionOnly( pythonAttribute(pythonExpression, "target", "Qiskit index expression has no target"), - depth + 1U); + nodeCount, depth + 1U); result->right = normalizePythonExpressionOnly( pythonAttribute(pythonExpression, "index", "Qiskit index expression has no index"), - depth + 1U); + nodeCount, depth + 1U); return result; } if (className == "Stretch") { diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index fbeb253359..bac25d5bf6 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -1307,6 +1307,25 @@ def test_excessively_nested_classical_expression_is_rejected() -> None: QCProgram.from_qiskit(circuit) +def test_oversized_classical_expression_is_rejected() -> None: + """Bound the total size of a balanced classical expression.""" + level = [expr.equal(1, 1) for _ in range(1025)] + while len(level) > 1: + level = [ + expr.logic_or(level[index], level[index + 1]) if index + 1 < len(level) else level[index] + for index in range(0, len(level), 2) + ] + circuit = QuantumCircuit(1) + with circuit.if_test(level[0]): + circuit.x(0) + source_data = list(circuit.data) + + with pytest.raises(RuntimeError, match="expressions exceed the node limit of 4096"): + QCProgram.from_qiskit(circuit) + + assert list(circuit.data) == source_data + + def test_excessively_nested_control_flow_is_rejected() -> None: """Bound control-flow traversal independently of definition depth.""" body = QuantumCircuit(1, 1) From 3be7095d161801554dfc1fb628bc69e255db2150 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Fri, 21 Aug 2026 16:12:42 +0200 Subject: [PATCH 06/38] =?UTF-8?q?=E2=9C=A8=20Export=20structured=20Qiskit?= =?UTF-8?q?=20control=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 --- bindings/mlir/qiskit/Qiskit2_5.cpp | 832 +++++++++- bindings/mlir/qiskit/QiskitExport.cpp | 1527 +++++++++++++++++-- bindings/mlir/qiskit/QiskitTranslation.h | 6 + test/python/test_mlir_qiskit_translation.py | 803 +++++++++- 4 files changed, 3033 insertions(+), 135 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 89b2a414f8..98bc9bc0fd 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,7 @@ #include #include #include +#include #include #include @@ -1635,6 +1637,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) @@ -1755,7 +2079,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"); @@ -1767,22 +2130,212 @@ 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->getSymbol() == nullptr || + loop.parameter->getSymbol()->name.empty())) { + throw std::runtime_error( + "Qiskit for-loop parameter has invalid symbol 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", @@ -1813,7 +2366,275 @@ 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* symbol = pending.loop.parameter->getSymbol(); + if (symbol == nullptr) { + throw std::runtime_error( + "Qiskit for-loop parameter has invalid symbol metadata"); + } + const auto found = parameters.find(symbol->name); + 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; } } @@ -1925,6 +2746,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 1dc865c452..31ebb0a1f5 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -28,6 +28,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -41,13 +44,16 @@ #include #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -61,6 +67,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, @@ -68,6 +79,7 @@ struct ExportedInstruction { Reset, Barrier, Unitary, + ControlFlow, }; Kind kind = Kind::Gate; StandardGateMapping gate; @@ -76,10 +88,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 = Parameter::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) + @@ -299,12 +331,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; + llvm::StringSet<> parameterNames; + size_t nextLoopParameter = 0U; uint32_t numQubits = 0; uint32_t numClbits = 0; }; @@ -325,19 +360,34 @@ void collectParameterNames(const Parameter& parameter, } } -void validateExportParameters(const ExportState& state) { - llvm::StringSet<> usedNames; +void validateExportParameters(const ExportedCircuit& circuit, + llvm::StringSet<>& usedNames) { const auto validate = [&](const Parameter& parameter) { validateExportParameter(parameter); collectParameterNames(parameter, usedNames); }; - 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, usedNames); + } } - for (const auto& input : state.inputParameters) { +} + +void validateExportParameters(const ExportedCircuit& circuit, + const std::vector& inputs) { + llvm::StringSet<> usedNames; + validateExportParameters(circuit, usedNames); + for (const auto& input : inputs) { const auto* symbol = input.getSymbol(); if (symbol == nullptr) { throw std::runtime_error("QC program input is not a parameter symbol"); @@ -360,35 +410,44 @@ void collectParameters(mlir::func::FuncOp function, ExportState& state) { 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 (!state.parameterNames.insert(name.getValue()).second) { + throw std::runtime_error( + "Qiskit circuit export requires unique parameter names"); + } auto parameter = Parameter::symbol(name.str()); state.parameters[argument] = parameter; state.inputParameters.push_back(std::move(parameter)); } } -void addGlobalPhase(ExportState& state, const Parameter& phase) { +void addGlobalPhase(ExportedCircuit& circuit, const Parameter& phase) { if (const auto* number = phase.getNumber()) { - if (const auto* globalNumber = state.globalPhase.getNumber()) { + if (const auto* globalNumber = circuit.globalPhase.getNumber()) { const auto sum = globalNumber->value + number->value; if (!std::isfinite(sum)) { throw std::runtime_error( "QC global phase cannot be represented by Qiskit"); } - state.globalPhase = Parameter::number(sum); + circuit.globalPhase = Parameter::number(sum); return; } if (std::abs(number->value) <= mlir::mqt::PARAMETER_COMPARISON_TOLERANCE) { return; } - } else if (const auto* globalNumber = state.globalPhase.getNumber(); + } else if (const auto* globalNumber = circuit.globalPhase.getNumber(); globalNumber != nullptr && std::abs(globalNumber->value) <= mlir::mqt::PARAMETER_COMPARISON_TOLERANCE) { - state.globalPhase = phase; + circuit.globalPhase = phase; return; } - state.globalPhase = binaryParameter(BinaryParameterKind::Add, - std::move(state.globalPhase), phase); + circuit.globalPhase = binaryParameter(BinaryParameterKind::Add, + std::move(circuit.globalPhase), phase); } [[nodiscard]] std::vector @@ -713,11 +772,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) { @@ -736,7 +794,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()); @@ -744,12 +802,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( @@ -776,112 +846,1226 @@ 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( - "QC measurement uses a dynamic classical destination"); + "Qiskit classical expression contains an unsupported constant"); } - const auto checked = checkedIndex(*index, "classical-bit"); - if (checked >= info->second.size) { + result->floatValue = floating.getValueAsDouble(); + if (!std::isfinite(result->floatValue)) { throw std::runtime_error( - "QC measurement uses an out-of-bounds classical destination"); + "Qiskit classical floating-point literals must be finite"); } - if (!writtenBits[store.getReg()].insert(checked).second) { + 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 to Qiskit export does not support duplicate classical " - "destinations"); + "Qiskit classical expressions support only Boolean scf.if " + "results with an else branch"); } - if (!measurementDestinations.try_emplace(measure.getOperation(), store) - .second) { + const auto opResult = llvm::dyn_cast(value); + if (!opResult || opResult.getOwner() != operation) { throw std::runtime_error( - "QC measurement has more than one 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); + } + } + 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); } - for (auto& operation : function.getBody().front()) { - if (llvm::isa(operation)) { - continue; + 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)) { + if (!cast.getType().isInteger(1)) { + return unary(ExpressionKind::Cast, cast.getIn()); + } + result->kind = ExpressionKind::Index; + if (auto shift = cast.getIn().getDefiningOp()) { + result->left = exportExpressionImpl( + shift.getLhs(), state, evaluationBlock, depth + 1U, nodeCount); + result->right = exportExpressionImpl( + shift.getRhs(), state, evaluationBlock, depth + 1U, nodeCount); + state.expressionOperations.insert(shift); + } else { + result->left = exportExpressionImpl(cast.getIn(), state, evaluationBlock, + depth + 1U, nodeCount); + if (++nodeCount > MAX_EXPORT_EXPRESSION_NODES) { + throw std::runtime_error( + "QC classical expression exceeds the size limit of 4096 nodes"); + } + auto zero = std::make_unique(); + setExpressionType(*zero, cast.getIn().getType()); + zero->kind = ExpressionKind::Value; + zero->uintValue = 0U; + result->right = std::move(zero); } - if (auto alloc = llvm::dyn_cast(operation)) { - if (state.quantumBases.contains(alloc.getResult())) { + state.expressionOperations.insert(operation); + return 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)) { + 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( + "Qiskit Uint expressions do not support signed comparisons"); + } + 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( + "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()); +} + +[[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"); + } + } + } +} + +[[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(); + } else if (value.getType().isIndex()) { + if (const auto constant = constantUnsignedInteger(value)) { + auto expression = std::make_unique(); + expression->kind = ExpressionKind::Value; + expression->type = ClassicalType::Uint; + expression->width = 64U; + expression->uintValue = *constant; + state.expressionOperations.insert(value.getDefiningOp()); + return {.kind = ClassicalTargetKind::Expression, + .width = 64U, + .expression = std::move(expression)}; + } + throw std::runtime_error( + "Qiskit switch targets require a constant index or an unsigned " + "integer-to-index cast"); + } + 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 auto loopParameter = Parameter::symbol(symbol); + 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 = { @@ -889,31 +2073,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 && @@ -925,14 +2152,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; + } } } } @@ -960,8 +2254,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); @@ -974,18 +2281,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, @@ -995,28 +2291,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 f6780ca7a4..5239590e25 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -348,6 +348,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/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index bac25d5bf6..06a6531851 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -578,6 +578,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 {mqt.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 {mqt.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 {mqt.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 {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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) @@ -963,7 +1049,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) @@ -978,14 +1064,667 @@ 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" + switch_cases = list(restored.data[2].operation.cases_specifier()) + assert [labels for labels, _ in switch_cases] == [(0,), (1,), (CASE_DEFAULT,)] + assert [[instruction.operation.name for instruction in body.data] for _, body in switch_cases] == [ + ["x"], + ["x"], + ["z"], + ] + 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 {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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 {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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 {mqt.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 {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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_constant_index_switch_round_trip() -> None: + """Lift a direct constant index selector into a Qiskit Uint expression.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() attributes {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %selector = arith.constant 0 : index + scf.index_switch %selector + case 0 { + qc.x %q : !qc.qubit + scf.yield + } + default { + qc.z %q : !qc.qubit + scf.yield + } + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + + restored = program.to_qiskit() + switch = restored.data[0].operation + expected = expr.lift(0, types.Uint(64)) + assert isinstance(switch.target, expr.Expr) + assert expr.structurally_equivalent(switch.target, expected) + assert [labels for labels, _ in switch.cases_specifier()] == [(0,), (CASE_DEFAULT,)] + QCProgram.from_qiskit(restored) + + +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 {mqt.entry_point} {", + " %q = qc.alloc : !qc.qubit", + ' %classical = cbit.alloc(#cbit.init) {mqt.register_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 {mqt.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 {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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 {mqt.entry_point} { + %measured_qubit = qc.alloc : !qc.qubit + %controlled_qubit = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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 {mqt.entry_point} { + %first_qubit = qc.alloc : !qc.qubit + %second_qubit = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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 {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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 {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="loads an undefined classical bit"): + program.to_qiskit() + + assert program.ir == source + + +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 {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %classical = cbit.alloc(#cbit.init) {mqt.register_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> + } +} +""" + ) + source = program.ir + + with pytest.raises(RuntimeError, match="cannot return undefined classical bits"): + program.to_qiskit() + + assert program.ir == source + + +@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 {{mqt.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 +1755,64 @@ 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 test_index_expression_round_trip_preserves_low_bit() -> None: + """Export integer truncation as bit indexing instead of a truthiness cast.""" + condition = expr.index(expr.lift(2, types.Uint(3)), expr.lift(0, types.Uint(3))) + circuit = QuantumCircuit(1) + with circuit.if_test(condition): + circuit.x(0) + + program = QCProgram.from_qiskit(circuit) + assert "arith.trunci" in program.ir + + restored = program.to_qiskit() + restored_condition = restored.data[0].operation.condition + assert isinstance(restored_condition, expr.Expr) + assert expr.structurally_equivalent(restored_condition, condition) + + round_trip_ir = QCProgram.from_qiskit(restored).ir + assert "arith.trunci" in round_trip_ir + assert "arith.cmpi ne" not in round_trip_ir + + +def test_integer_truncation_exports_as_low_bit_index() -> None: + """Preserve the low-bit semantics of a generic integer truncation.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() attributes {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %two = arith.constant 2 : i3 + %condition = arith.trunci %two : i3 to i1 + scf.if %condition { + qc.x %q : !qc.qubit + } + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + + restored = program.to_qiskit() + restored_condition = restored.data[0].operation.condition + expected = expr.index(expr.lift(2, types.Uint(3)), expr.lift(0, types.Uint(3))) + assert isinstance(restored_condition, expr.Expr) + assert expr.structurally_equivalent(restored_condition, expected) def _round_trip_qiskit_import(circuit: QuantumCircuit) -> str: @@ -1078,11 +1867,17 @@ def test_uint_register_cast_to_bool_tests_all_bits() -> None: with circuit.if_test(expr.cast(circuit.cregs[0], types.Bool())): circuit.z(0) - ir = QCProgram.from_qiskit(circuit).ir + program = QCProgram.from_qiskit(circuit) + ir = program.ir assert "arith.cmpi ne" in ir assert "arith.trunci" not in ir + restored = program.to_qiskit() + round_trip_ir = QCProgram.from_qiskit(restored).ir + assert "arith.cmpi ne" in round_trip_ir + assert "arith.trunci" not in round_trip_ir + def test_public_expression_condition_mutation_is_observed() -> None: """Import the current public expression after condition mutation.""" From a1d106b20cec4be9ad23f69fd2802ee3727d1edd Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Fri, 21 Aug 2026 16:22:08 +0200 Subject: [PATCH 07/38] =?UTF-8?q?=F0=9F=93=9D=20Document=20structured=20Qi?= =?UTF-8?q?skit=20control=20export?= 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 | 377 ++++++++++++++++++ CHANGELOG.md | 5 +- docs/mlir/python_compiler_collection.md | 60 ++- 3 files changed, 431 insertions(+), 11 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..20676204d6 --- /dev/null +++ b/.agent/plans/qiskit-structured-control-export.md @@ -0,0 +1,377 @@ +# 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. +- [x] (2026-08-21 15:55Z) Restack the export-only commit onto the updated #2175 + head, port it to the closed name-keyed `Parameter` API and current MQT + metadata, fix the two include-cleanliness findings, rebuild the binding, + and pass all 204 translation tests. +- [x] (2026-08-21 16:08Z) Preserve low-bit semantics when exporting integer + truncation, accept direct constant-index switch selectors, add three + focused round-trip regressions, and pass all 207 translation tests. +- [x] (2026-08-21 16:20Z) Complete the final scope and semantic reviews, pass + stub generation and repository lint, explicitly defer the complete + documentation build for this handoff, and prepare the two focused + implementation and documentation commits. +- [x] (2026-08-21 16:28Z) Restack both commits onto #2175's final + classical-expression node-bound fix, confirm the export patches remain + equivalent, rebuild the binding, and pass all 208 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, + closed scalar `Parameter` trees keyed by unique names, and + Python-authoritative import of captured classical expressions. Evidence: the + base contains `cbit.load`/`cbit.store` export discovery, the variant-backed + `Parameter` API, 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. + +- Observation: MLIR truncation to `i1` selects the low bit, while Qiskit's + Uint-to-Bool cast tests whether the complete integer is nonzero. Evidence: an + imported Qiskit index expression lowers to `arith.shrui` plus `arith.trunci`; + exporting that truncation as a cast reverses the result for values such as + binary `010`. + +## 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 the closed scalar parameter tree unchanged. Give each live + `scf.for` induction parameter a collision-free generated name through + `Parameter::symbol(...)`, shared by `Loop::parameter` and its lexical body. + Rationale: Qiskit's `ForLoopOp` must use the same Python `Parameter` object + that appears in body gates, while the current scalar model intentionally uses + unique names instead of a second identity field. Date/Author: 2026-08-21 / + 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. + +- Decision: Export an integer truncation to `i1` as a Qiskit bit-index + expression, recovering the original shifted index when present and otherwise + indexing bit zero. Lift a direct constant `index` switch selector to a 64-bit + Uint expression. Rationale: these mappings preserve MLIR semantics and cover + valid structured selectors without treating truncation as truthiness. + Date/Author: 2026-08-21 / 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 MLIR binding builds successfully after the final restack onto #2175. All 208 +tests in `test/python/test_mlir_qiskit_translation.py` pass against the +worktree-built extension. Stub generation and repository lint also pass. The +complete documentation build was explicitly deferred for this handoff. The +semantic diff leaves the refreshed import reader and current name-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. + +## Context and Orientation + +`bindings/mlir/qiskit/QiskitTranslation.h` defines normalized data shared by the +generic MLIR translator and each supported Qiskit version. `CircuitWriter` +accepts flat operations and `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. `ExportState` discovers qubit resources, returned +`!cbit.reg` values, scalar parameters, and recursively collected +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. Each SCF region becomes 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 preserves scalar symbols by their validated unique names. +`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 + +The implementation adds `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. + +`bindings/mlir/qiskit/Qiskit2_5.cpp` contains a `PythonClassicalBuilder` that +turns constants, captured Clbits, captured ClassicalRegisters, casts, indexing, +unary operations, and binary operations into Qiskit's public expression objects. +`NativeCircuitWriter` records control flow without adding a C placeholder. +During `finish`, it converts native circuits to Python, rebases each nested +block onto the parent's exact Qubit and Clbit objects, preserves canonical +scalar parameter objects across blocks, builds the public control-flow +operations, and inserts them at stable instruction positions. It validates block +shape, captures, loop metadata, switch labels, and bit counts before +construction. + +`bindings/mlir/qiskit/QiskitExport.cpp` preserves the existing scalar parameter +normalizer and resource discovery while adding 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. It uses only CBit +operations for classical state and preflights 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. + +Focused tests 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. The MLIR test functions +return all public classical registers, the existing import capture tests remain +unchanged, and `docs/mlir/python_compiler_collection.md` records the support +table and exact structured-export restrictions. + +## 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 --build build/python/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 + +Regenerate Python stubs after the binding changes and build the complete +documentation after updating the support table: + + uvx nox -s stubs + uvx nox --non-interactive -s docs + +The complete documentation build was explicitly deferred for the final handoff; +run it before merging if the pull-request checks do not cover it. + +Run the repository lint session last 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. + +## Artifacts and Notes + +The starting commit already passes captured-expression import tests and uses +unique symbol names in closed `Parameter` trees. 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. +Updated it again after #2175 changed the scalar representation and metadata +contract; only the export-specific delta was replayed. Recorded the final +low-bit and constant-index corrections together with the required stubs, lint, +deferred documentation build, and 208-test validation after the last parent +update. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a750ac07e..b249504526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,8 @@ releases may include breaking changes. #### Import and export - ✨ Add Qiskit circuit import and target-aware export to the compiler - collection ([#2031], [#2133], [#2140], [#2150], [#2175]) ([**@burgholzer**], - [**@simon1hofmann**]) + collection ([#2031], [#2133], [#2140], [#2150], [#2175], [#2176]) + ([**@burgholzer**], [**@simon1hofmann**]) - ✨ Add conversions between `jeff` and QCO ([#1479], [#1548], [#1565], [#1637], [#1676], [#1706], [#1776], [#1836], [#1934], [#2000], [#2018], [#2105]) ([**@denialhaag**], [**@burgholzer**]) @@ -786,6 +786,7 @@ for previous changelogs._ +[#2176]: https://github.com/munich-quantum-toolkit/core/pull/2176 [#2175]: https://github.com/munich-quantum-toolkit/core/pull/2175 [#2168]: https://github.com/munich-quantum-toolkit/core/pull/2168 [#2158]: https://github.com/munich-quantum-toolkit/core/pull/2158 diff --git a/docs/mlir/python_compiler_collection.md b/docs/mlir/python_compiler_collection.md index ed65eafc96..8246518fbd 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,40 @@ 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. An {code}`scf.index_switch` selector must be a constant index or a +supported Boolean/Uint expression converted with {code}`arith.index_castui`. +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 +250,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 From 799bcfd10f649983a1c19b6c2fea05ef6b21fc8d Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Sat, 22 Aug 2026 09:05:03 +0200 Subject: [PATCH 08/38] =?UTF-8?q?=F0=9F=90=9B=20Bound=20structured=20Qiski?= =?UTF-8?q?t=20export=20preflight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 Sol via Codex --- .../plans/qiskit-structured-control-export.md | 59 ++++++- bindings/mlir/qiskit/QiskitExport.cpp | 162 ++++++++++++------ test/python/test_mlir_qiskit_translation.py | 89 ++++++++++ 3 files changed, 248 insertions(+), 62 deletions(-) diff --git a/.agent/plans/qiskit-structured-control-export.md b/.agent/plans/qiskit-structured-control-export.md index 20676204d6..252c4785d9 100644 --- a/.agent/plans/qiskit-structured-control-export.md +++ b/.agent/plans/qiskit-structured-control-export.md @@ -72,6 +72,21 @@ follow-up with its own ExecPlan and branch. - [x] (2026-08-21 16:28Z) Restack both commits onto #2175's final classical-expression node-bound fix, confirm the export patches remain equivalent, rebuild the binding, and pass all 208 translation tests. +- [x] (2026-08-22 06:46Z) Merge the updated `main` after #2175 landed as a + squash commit, retain its finalized importer and minimized tests, remove + the duplicated pre-squash parent coverage, rebuild the binding, and pass + repository lint. +- [x] (2026-08-22 06:59Z) Bound speculative packed-register matching, replace + recursive classical-snapshot discovery with a bounded worklist, and omit + loop parameter metadata when the projected value reaches no emitted + parameter expression; pass the three focused regressions. +- [x] (2026-08-22 07:01Z) Rebuild the release binding, pass all 211 Qiskit + translation tests against that exact build, and pass focused format and + static checks; repository lint reformatted the plan and is ready for its + final clean rerun. +- [x] (2026-08-22 07:04Z) Pass the final clean repository lint run and an + independent review with no remaining actionable findings; the audit fix is + ready to commit and push. ## Surprises & Discoveries @@ -114,6 +129,23 @@ follow-up with its own ExecPlan and branch. exporting that truncation as a cast reverses the result for values such as binary `010`. +- Observation: Merging a stacked branch after its parent landed as a squash can + retain both the old and finalized parent tests without a textual conflict. + Evidence: the first merged Python diff contained 1,123 changed lines instead + of the export commit's 803; rebuilding it from `main` plus the export-only + patch restored the expected delta and kept the finalized #2175 cases. + +- Observation: The packed-register recognizer and snapshot validator ran before + the bounded classical-expression exporter. Evidence: a shared zero-valued + `arith.ori` DAG caused exponential speculative matching, while a long SSA + chain entered recursive snapshot discovery before the documented 4,096-node + and 64-level checks. + +- Observation: A loop projection can have SSA uses without contributing a + parameter to the emitted Qiskit body. Evidence: a projected value used only by + a dead `math.sin` expression caused finalization to report that the loop + parameter was absent from its body. + ## Decision Log - Decision: Change only `CircuitWriter`'s output interface and leave all reader @@ -170,6 +202,19 @@ follow-up with its own ExecPlan and branch. valid structured selectors without treating truncation as truthiness. Date/Author: 2026-08-21 / Codex. +- Decision: Treat the squash-merged `main` tree as authoritative for #2175 and + replay only the two structured-export commits while resolving the merge. + Rationale: this preserves the reviewed importer refactors and streamlined + parent coverage without changing #2176's scope. Date/Author: 2026-08-22 / + Codex. + +- Decision: Give speculative packed-register matching the same depth and node + budgets as expression export, use an iterative bounded snapshot walk, and add + loop metadata only when the generated symbol appears in an emitted body + parameter. Rationale: preflight must have predictable cost and must not expose + a Qiskit loop parameter that its body does not contain. Date/Author: + 2026-08-22 / Codex. + ## Outcomes & Retrospective Structured Qiskit control flow now exports recursively through a normalized, @@ -179,15 +224,18 @@ 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 MLIR binding builds successfully after the final restack onto #2175. All 208 -tests in `test/python/test_mlir_qiskit_translation.py` pass against the +The MLIR binding builds successfully after the final merge onto `main`. All 211 +tests in `test/python/test_mlir_qiskit_translation.py` pass against the exact worktree-built extension. Stub generation and repository lint also pass. The complete documentation build was explicitly deferred for this handoff. The semantic diff leaves the refreshed import reader and current name-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. +parameter trees. Speculative expression recognition and snapshot validation are +bounded before recursion can consume unbounded resources, and dead loop +parameter expressions no longer expose invalid Qiskit metadata. The +measurement-store relaxation remains out of scope for this completed plan and +will receive its own branch and ExecPlan. ## Context and Orientation @@ -374,4 +422,5 @@ Updated it again after #2175 changed the scalar representation and metadata contract; only the export-specific delta was replayed. Recorded the final low-bit and constant-index corrections together with the required stubs, lint, deferred documentation build, and 208-test validation after the last parent -update. +update. Recorded the squash-merge resolution and the bounded-preflight and dead +loop-parameter fixes found during the post-merge audit. diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 31ebb0a1f5..78fbebf434 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -68,6 +68,7 @@ namespace mqt::bindings::qiskit { namespace { constexpr size_t MAX_EXPORT_CONTROL_FLOW_DEPTH = 64U; +constexpr size_t MAX_EXPORT_EXPRESSION_DEPTH = 64U; constexpr size_t MAX_EXPORT_EXPRESSION_NODES = 4096U; struct ExportedControlFlow; @@ -360,6 +361,44 @@ void collectParameterNames(const Parameter& parameter, } } +[[nodiscard]] bool parameterUsesName(const Parameter& parameter, + const std::string_view name) { + if (const auto* symbol = parameter.getSymbol()) { + return symbol->name == name; + } + if (const auto* unary = parameter.getUnary()) { + return parameterUsesName(*unary->operand, name); + } + if (const auto* binary = parameter.getBinary()) { + return parameterUsesName(*binary->left, name) || + parameterUsesName(*binary->right, name); + } + return false; +} + +[[nodiscard]] bool circuitUsesParameterName(const ExportedCircuit& circuit, + const std::string_view name) { + if (parameterUsesName(circuit.globalPhase, name)) { + return true; + } + for (const auto& instruction : circuit.instructions) { + if (llvm::any_of(instruction.parameters, [&](const auto& parameter) { + return parameterUsesName(parameter, name); + })) { + return true; + } + if (!instruction.controlFlow) { + continue; + } + if (llvm::any_of(instruction.controlFlow->blocks, [&](const auto& block) { + return circuitUsesParameterName(block, name); + })) { + return true; + } + } + return false; +} + void validateExportParameters(const ExportedCircuit& circuit, llvm::StringSet<>& usedNames) { const auto validate = [&](const Parameter& parameter) { @@ -1041,7 +1080,7 @@ matchPackedRegister(mlir::Value value, ExportState& state, exportExpressionImpl(mlir::Value value, ExportState& state, mlir::Block& evaluationBlock, const size_t depth, size_t& nodeCount) { - if (depth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { + if (depth >= MAX_EXPORT_EXPRESSION_DEPTH) { throw std::runtime_error( "QC classical expressions exceed the nesting limit of 64"); } @@ -1351,8 +1390,13 @@ matchPackedRegister(mlir::Value value, ExportState& state, } std::vector> bits(type.getWidth()); llvm::SmallPtrSet operations; - const std::function collect = - [&](const mlir::Value current, const uint32_t shift) { + size_t nodeCount = 0U; + const std::function collect = + [&](const mlir::Value current, const uint32_t shift, const size_t depth) { + if (depth >= MAX_EXPORT_EXPRESSION_DEPTH || + ++nodeCount > MAX_EXPORT_EXPRESSION_NODES) { + return false; + } auto* operation = current.getDefiningOp(); if (operation == nullptr) { return false; @@ -1372,7 +1416,8 @@ matchPackedRegister(mlir::Value value, ExportState& state, } if (auto op = llvm::dyn_cast(operation)) { operations.insert(operation); - return collect(op.getLhs(), shift) && collect(op.getRhs(), shift); + return collect(op.getLhs(), shift, depth + 1U) && + collect(op.getRhs(), shift, depth + 1U); } if (auto op = llvm::dyn_cast(operation)) { const auto amount = constantUnsignedInteger(op.getRhs()); @@ -1381,11 +1426,12 @@ matchPackedRegister(mlir::Value value, ExportState& state, return false; } operations.insert(operation); - return collect(op.getLhs(), shift + static_cast(*amount)); + return collect(op.getLhs(), shift + static_cast(*amount), + depth + 1U); } if (auto op = llvm::dyn_cast(operation)) { operations.insert(operation); - return collect(op.getIn(), shift); + return collect(op.getIn(), shift, depth + 1U); } auto load = llvm::dyn_cast(operation); if (!load || shift >= bits.size() || bits[shift]) { @@ -1399,7 +1445,7 @@ matchPackedRegister(mlir::Value value, ExportState& state, operations.insert(operation); return true; }; - if (!collect(value, 0U) || + if (!collect(value, 0U, 0U) || llvm::any_of(bits, [](const auto& bit) { return !bit.has_value(); })) { return std::nullopt; } @@ -1445,38 +1491,39 @@ 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); - } - } - } + llvm::SmallVector worklist{expression}; + while (!worklist.empty()) { + const auto value = worklist.pop_back_val(); + if (!visited.insert(value).second) { + continue; + } + if (visited.size() > 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) { + continue; + } + if (auto load = llvm::dyn_cast(operation)) { + loads.push_back(load); + continue; + } + if (auto ifOp = llvm::dyn_cast(operation); + ifOp && ifOp.getNumResults() != 0U) { + for (auto& region : ifOp->getRegions()) { + if (region.empty()) { + continue; } - for (const auto operand : operation->getOperands()) { - collectLoads(operand); + if (auto yield = llvm::dyn_cast( + region.front().getTerminator())) { + worklist.append(yield.getOperands().begin(), + yield.getOperands().end()); } - }; - collectLoads(expression); + } + } + worklist.append(operation->operand_begin(), operation->operand_end()); + } for (auto load : loads) { mlir::Operation* anchor = load; auto* anchorBlock = load->getBlock(); @@ -1811,8 +1858,11 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, const ExportScope& scope, result->loop = { .isRange = true, .start = *lower, .stop = *upper, .step = *step}; auto bodyScope = scope; + std::optional projection; + std::optional loopParameter; + std::string loopParameterName; if (!loop.getInductionVar().use_empty()) { - auto projection = matchLoopParameterProjection(loop); + projection = matchLoopParameterProjection(loop); if (!projection) { throw std::runtime_error( "Qiskit for-loop export supports only a loop induction value used " @@ -1820,23 +1870,22 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, const ExportScope& scope, } 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 auto loopParameter = Parameter::symbol(symbol); - result->loop.parameter = loopParameter; - bodyScope.parameters[projection->value] = loopParameter; + if (!projection->value.use_empty()) { + size_t identity = 0U; + do { + identity = state.nextLoopParameter++; + loopParameterName = "_mqt_loop_" + std::to_string(identity); + } while (state.parameterNames.contains(loopParameterName)); + state.parameterNames.insert(loopParameterName); + loopParameter = Parameter::symbol(loopParameterName); + bodyScope.parameters[projection->value] = *loopParameter; + } + } + auto body = collectBlock(*loop.getBody(), state, bodyScope, + controlFlowDepth + 1U, false); + if (projection && loopParameter && + circuitUsesParameterName(body, loopParameterName)) { + result->loop.parameter = *loopParameter; const auto count = rangeLength(*lower, *upper, *step); if (count == 0U) { result->loop.start = 0; @@ -1862,8 +1911,7 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, const ExportScope& scope, result->loop.start, "scf.for induction stop"); } } - result->blocks.push_back(collectBlock(*loop.getBody(), state, bodyScope, - controlFlowDepth + 1U, false)); + result->blocks.push_back(std::move(body)); result->qubits = allIndices(state.numQubits); result->clbits = allIndices(state.numClbits); return result; diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index e8acf2971c..242cd46972 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -1367,6 +1367,38 @@ def test_dead_for_loop_parameter_projection_is_ignored() -> None: assert loop.blocks[0].count_ops() == {"x": 1} +def test_transitively_dead_for_loop_parameter_projection_is_ignored() -> None: + """Omit a loop symbol used only by a dead parameter expression.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() attributes {mqt.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 + %parameter = arith.sitofp %integer : i64 to f64 + %unused = math.sin %parameter : 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( @@ -1460,6 +1492,63 @@ def test_shared_expression_dag_expansion_is_bounded() -> None: assert program.ir == source +def test_shared_packed_register_candidate_expansion_is_bounded() -> None: + """Bound speculative packed-register matching on a shared SSA DAG.""" + lines = [ + "module {", + " func.func @main() attributes {mqt.entry_point} {", + " %q = qc.alloc : !qc.qubit", + " %value0 = arith.constant 0 : i64", + ] + lines.extend(f" %value{index} = arith.ori %value{index - 1}, %value{index - 1} : i64" for index in range(1, 31)) + lines.extend([ + " %condition = arith.cmpi eq, %value30, %value0 : i64", + " scf.if %condition {", + " qc.x %q : !qc.qubit", + " }", + " qc.dealloc %q : !qc.qubit", + " return", + " }", + "}", + ]) + 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_classical_snapshot_walk_is_bounded() -> None: + """Bound snapshot discovery before recursive expression export.""" + lines = [ + "module {", + " func.func @main() -> !cbit.reg<1> attributes {mqt.entry_point} {", + " %q = qc.alloc : !qc.qubit", + ' %classical = cbit.alloc(#cbit.init) {mqt.register_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}, %value0 : i1" for index in range(1, 4097)) + lines.extend([ + " scf.if %value4096 {", + " 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( From a65960869101d4165e67609d18eceb367d3e580c Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Sat, 22 Aug 2026 09:59:10 +0200 Subject: [PATCH 09/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20structure?= =?UTF-8?q?d=20Qiskit=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share native parameter storage across nested writers, replace deferred instruction insertion with stable native placeholders, and make the all-root-bit invariant explicit. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/Qiskit2_5.cpp | 520 +++++------------------ bindings/mlir/qiskit/QiskitExport.cpp | 214 +++------- bindings/mlir/qiskit/QiskitTranslation.h | 4 +- 3 files changed, 182 insertions(+), 556 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 23801e589a..c9f1d6adc2 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -1592,9 +1592,10 @@ NativeCircuitReader::controlFlow(const size_t index) const { class PythonClassicalBuilder final { public: explicit PythonClassicalBuilder(const nb::handle circuit) - : circuit_(nb::borrow(circuit)), - clbits_(pythonAttribute(circuit, "clbits", + : clbits_(pythonAttribute(circuit, "clbits", "Qiskit circuit has no classical bits")), + cregs_(pythonAttribute(circuit, "cregs", + "Qiskit circuit has no classical registers")), expressionModule_( nb::module_::import_("qiskit.circuit.classical.expr")), typesModule_(nb::module_::import_("qiskit.circuit.classical.types")) {} @@ -1696,10 +1697,8 @@ class PythonClassicalBuilder final { [[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)) { + for (const nb::handle candidateHandle : nb::iter(cregs_)) { if (nb::len(candidateHandle) != reg.bits.size()) { continue; } @@ -1905,16 +1904,21 @@ class PythonClassicalBuilder final { throw std::runtime_error("Qiskit classical expression has an unknown kind"); } - nb::object circuit_; nb::object clbits_; + nb::object cregs_; nb::object expressionModule_; nb::object typesModule_; }; +using NativeSymbolTable = + std::unordered_map>; + class NativeCircuitWriter final : public CircuitWriter { public: - NativeCircuitWriter(const uint32_t looseQubits, const uint32_t looseClbits) - : circuit_(qk_circuit_new(looseQubits, looseClbits)) { + NativeCircuitWriter(const uint32_t looseQubits, const uint32_t looseClbits, + std::shared_ptr symbols) + : circuit_(qk_circuit_new(looseQubits, looseClbits)), + symbols_(std::move(symbols)) { if (circuit_ == nullptr) { throwPythonError("Qiskit failed to allocate a circuit"); } @@ -2031,13 +2035,12 @@ 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); + void + addControlFlow(const ControlFlowKind kind, ClassicalTarget target, Loop loop, + std::vector switchCases, + std::vector> blocks) override { + const auto numQubits = qk_circuit_num_qubits(circuit_); + const auto numClbits = qk_circuit_num_clbits(circuit_); for (const auto& block : blocks) { const auto* const native = dynamic_cast(block.get()); @@ -2046,21 +2049,21 @@ class NativeCircuitWriter final : public CircuitWriter { "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()) { + qk_circuit_num_qubits(native->circuit_) != numQubits || + qk_circuit_num_clbits(native->circuit_) != numClbits) { 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}); + const auto instructionIndex = qk_circuit_num_instructions(circuit_); + checkExitCode(qk_circuit_barrier(circuit_, nullptr, 0U), + "adding control-flow placeholder"); + pendingControlFlow_.push_back({.instructionIndex = instructionIndex, + .kind = kind, + .target = std::move(target), + .loop = std::move(loop), + .switchCases = std::move(switchCases), + .blockWriters = std::move(blocks)}); } [[nodiscard]] nb::object finish() override { @@ -2085,15 +2088,8 @@ class NativeCircuitWriter final : public CircuitWriter { 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); + replacePendingControlledUnitaries(pythonCircuit); + replacePendingControlFlow(pythonCircuit); } catch (const nb::python_error& error) { throwPythonError("Qiskit failed to construct deferred instructions", error); @@ -2114,180 +2110,9 @@ class NativeCircuitWriter final : public CircuitWriter { 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->getSymbol() == nullptr || - loop.parameter->getSymbol()->name.empty())) { - throw std::runtime_error( - "Qiskit for-loop parameter has invalid symbol 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()); + void replacePendingControlledUnitaries(const nb::handle pythonCircuit) const { auto data = pythonAttribute(pythonCircuit, "data", "Qiskit circuit has no instruction data"); const auto circuitQubits = pythonAttribute(pythonCircuit, "qubits", @@ -2318,10 +2143,8 @@ class NativeCircuitWriter final : public CircuitWriter { pythonAttribute(placeholder, "replace", "Qiskit unitary placeholder cannot be replaced")( nb::arg("operation") = controlled, nb::arg("qubits") = qargs); - result.push_back({.instructionIndex = pending.instructionIndex, - .instruction = replacement}); + data[pending.instructionIndex] = replacement; } - return result; } [[nodiscard]] static nb::object rebaseCircuit(const nb::handle circuit, @@ -2336,115 +2159,14 @@ class NativeCircuitWriter final : public CircuitWriter { 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); - } + auto rebased = nb::module_::import_("qiskit.circuit") + .attr("QuantumCircuit")(exactQubits, 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); + circuit, nb::arg("inplace") = true, nb::arg("copy") = false); 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") @@ -2457,38 +2179,44 @@ class NativeCircuitWriter final : public CircuitWriter { return values; } - [[nodiscard]] static nb::object - constructControlFlowOperation(const PendingControlFlow& pending, - const PythonClassicalBuilder& classical, - const PythonParameterMap& parameters) { - const auto circuitModule = nb::module_::import_("qiskit.circuit"); + [[nodiscard]] static nb::object loopParameter(const Loop& loop, + const nb::handle body) { + if (!loop.parameter) { + return nb::borrow(nb::none()); + } + const auto* symbol = loop.parameter->getSymbol(); + if (symbol == nullptr) { + throw std::runtime_error( + "Qiskit for-loop parameter has invalid symbol metadata"); + } + const auto parameters = pythonAttribute( + body, "parameters", "Qiskit circuit has no parameter collection"); + for (const nb::handle parameter : nb::iter(parameters)) { + if (pythonStringAttribute(parameter, "name", + "Qiskit circuit parameter has no name") == + symbol->name) { + return nb::borrow(parameter); + } + } + throw std::runtime_error( + "Qiskit for-loop parameter is absent from its body"); + } + + [[nodiscard]] static nb::object constructControlFlowOperation( + const PendingControlFlow& pending, const std::vector& blocks, + const PythonClassicalBuilder& classical, const nb::handle circuitModule) { 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())); + classical.condition(pending.target), blocks.front(), + blocks.size() == 2U ? 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* symbol = pending.loop.parameter->getSymbol(); - if (symbol == nullptr) { - throw std::runtime_error( - "Qiskit for-loop parameter has invalid symbol metadata"); - } - const auto found = parameters.find(symbol->name); - 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()); - } + classical.condition(pending.target), blocks.front()); + case ControlFlowKind::For: + return circuitModule.attr("ForLoopOp")( + loopIndexSet(pending.loop), + loopParameter(pending.loop, blocks.front()), blocks.front()); case ControlFlowKind::Switch: { nb::list cases; for (size_t index = 0U; index < pending.switchCases.size(); ++index) { @@ -2505,7 +2233,7 @@ class NativeCircuitWriter final : public CircuitWriter { } labels = std::move(values); } - cases.append(nb::make_tuple(labels, pending.blocks[index])); + cases.append(nb::make_tuple(labels, blocks[index])); } return circuitModule.attr("SwitchCaseOp")( classical.switchTarget(pending.target), cases); @@ -2519,74 +2247,53 @@ class NativeCircuitWriter final : public CircuitWriter { "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"); + void replacePendingControlFlow(const nb::handle pythonCircuit) { + 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 auto circuitModule = nb::module_::import_("qiskit.circuit"); + const auto circuitInstruction = circuitModule.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"); + for (auto& pending : pendingControlFlow_) { + if (pending.instructionIndex >= nb::len(data)) { + throw std::runtime_error("Qiskit control-flow placeholder is missing"); } - auto operation = - constructControlFlowOperation(pending, classical, parameters); - 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, circuitQubits, circuitClbits)); + } catch (const std::exception& error) { + throw std::runtime_error( + "Qiskit failed to finalize control-flow block " + + std::to_string(index) + ": " + error.what()); + } + } + pending.blockWriters.clear(); + auto operation = constructControlFlowOperation(pending, blocks, classical, + circuitModule); if (pythonUnsignedAttribute(operation, "num_qubits", "Qiskit control flow has no qubit count") != - pending.qubits.size() || + nb::len(circuitQubits) || pythonUnsignedAttribute( operation, "num_clbits", "Qiskit control flow has no classical-bit count") != - pending.clbits.size()) { + nb::len(circuitClbits)) { 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; + data[pending.instructionIndex] = + circuitInstruction(operation, circuitQubits, circuitClbits); } } @@ -2615,13 +2322,15 @@ class NativeCircuitWriter final : public CircuitWriter { throw std::runtime_error( "cannot export a symbolic parameter without a name"); } - const auto found = symbols_.find(symbol->name); - if (found != symbols_.end()) { + const auto found = symbols_->find(symbol->name); + if (found != symbols_->end()) { return found->second->get(); } - auto [inserted, success] = symbols_.emplace( - symbol->name, std::make_unique(symbol->name)); - static_cast(success); + const auto inserted = + symbols_ + ->try_emplace(symbol->name, + std::make_unique(symbol->name)) + .first; return inserted->second->get(); } @@ -2699,7 +2408,7 @@ class NativeCircuitWriter final : public CircuitWriter { QkCircuit* circuit_ = nullptr; std::vector pendingControlledUnitaries_; std::vector pendingControlFlow_; - std::unordered_map> symbols_; + std::shared_ptr symbols_; }; class NativeTranslation final : public VersionedTranslation { @@ -2716,8 +2425,13 @@ class NativeTranslation final : public VersionedTranslation { [[nodiscard]] std::unique_ptr createCircuit(const uint32_t looseQubits, const uint32_t looseClbits) const override { - return std::make_unique(looseQubits, looseClbits); + return std::make_unique(looseQubits, looseClbits, + symbols_); } + +private: + std::shared_ptr symbols_ = + std::make_shared(); }; } // namespace diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 78fbebf434..40328f4537 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -105,12 +105,6 @@ struct ExportedControlFlow { Loop loop; std::vector switchCases; std::vector blocks; - std::vector qubits; - std::vector clbits; -}; - -struct ExportScope { - ExportedParameters parameters; }; [[noreturn]] void throwExportedParameterExpressionSizeError() { @@ -236,7 +230,7 @@ struct ExportScope { } void validateExportParameterImpl(const Parameter& parameter, const size_t depth, - size_t& nodes) { + size_t& nodes, llvm::StringSet<>& names) { if (depth > MAX_PARAMETER_EXPRESSION_DEPTH) { throwExportedParameterExpressionDepthError(); } @@ -254,23 +248,25 @@ void validateExportParameterImpl(const Parameter& parameter, const size_t depth, throw std::runtime_error( "QC parameter symbol name contains a null character"); } + names.insert(symbol->name); return; } if (const auto* unary = parameter.getUnary()) { - validateExportParameterImpl(*unary->operand, depth + 1U, nodes); + validateExportParameterImpl(*unary->operand, depth + 1U, nodes, names); return; } if (const auto* binary = parameter.getBinary()) { - validateExportParameterImpl(*binary->left, depth + 1U, nodes); - validateExportParameterImpl(*binary->right, depth + 1U, nodes); + validateExportParameterImpl(*binary->left, depth + 1U, nodes, names); + validateExportParameterImpl(*binary->right, depth + 1U, nodes, names); return; } throw std::runtime_error("unknown QC parameter expression"); } -void validateExportParameter(const Parameter& parameter) { +void validateExportParameter(const Parameter& parameter, + llvm::StringSet<>& names) { size_t nodes = 0U; - validateExportParameterImpl(parameter, 1U, nodes); + validateExportParameterImpl(parameter, 1U, nodes, names); } [[nodiscard]] bool isParameterExpressionOperation(mlir::Operation& operation) { @@ -345,22 +341,6 @@ struct ExportState { uint32_t numClbits = 0; }; -void collectParameterNames(const Parameter& parameter, - llvm::StringSet<>& names) { - if (const auto* symbol = parameter.getSymbol()) { - names.insert(symbol->name); - return; - } - if (const auto* unary = parameter.getUnary()) { - collectParameterNames(*unary->operand, names); - return; - } - if (const auto* binary = parameter.getBinary()) { - collectParameterNames(*binary->left, names); - collectParameterNames(*binary->right, names); - } -} - [[nodiscard]] bool parameterUsesName(const Parameter& parameter, const std::string_view name) { if (const auto* symbol = parameter.getSymbol()) { @@ -402,8 +382,7 @@ void collectParameterNames(const Parameter& parameter, void validateExportParameters(const ExportedCircuit& circuit, llvm::StringSet<>& usedNames) { const auto validate = [&](const Parameter& parameter) { - validateExportParameter(parameter); - collectParameterNames(parameter, usedNames); + validateExportParameter(parameter, usedNames); }; validate(circuit.globalPhase); for (const auto& instruction : circuit.instructions) { @@ -1123,7 +1102,6 @@ exportExpressionImpl(mlir::Value value, ExportState& state, 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()); @@ -1136,7 +1114,6 @@ exportExpressionImpl(mlir::Value value, ExportState& state, 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)) { @@ -1408,7 +1385,6 @@ matchPackedRegister(mlir::Value value, ExportState& state, if (!integer || !integer.getValue().isZero()) { return false; } - operations.insert(operation); return true; } if (operation->getBlock() != &evaluationBlock) { @@ -1475,16 +1451,12 @@ void acceptPackedRegister(PackedRegister& packed, ExportState& state) { [[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; + return operation + .walk([&](mlir::cbit::StoreOp store) { + return store.getReg() == value ? mlir::WalkResult::interrupt() + : mlir::WalkResult::advance(); + }) + .wasInterrupted(); } void validateClassicalSnapshot(const mlir::Value expression, @@ -1587,21 +1559,17 @@ void validateClassicalSnapshot(const mlir::Value expression, 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) { + if (packed->reg.bits.size() != 64U && + *constant >= (uint64_t{1} << packed->reg.bits.size())) { continue; } state.expressionOperations.insert(comparison); - state.expressionOperations.insert(expected.getDefiningOp()); acceptPackedRegister(*packed, state); return {.kind = ClassicalTargetKind::ClassicalRegister, .reg = std::move(packed->reg), @@ -1631,7 +1599,6 @@ void validateClassicalSnapshot(const mlir::Value expression, expression->type = ClassicalType::Uint; expression->width = 64U; expression->uintValue = *constant; - state.expressionOperations.insert(value.getDefiningOp()); return {.kind = ClassicalTargetKind::Expression, .width = 64U, .expression = std::move(expression)}; @@ -1657,6 +1624,7 @@ void validateClassicalSnapshot(const mlir::Value expression, if (target.expression->type == ClassicalType::Float) { throw std::runtime_error("Qiskit switch targets must be Boolean or Uint"); } + target.width = target.expression->width; return target; } @@ -1767,15 +1735,10 @@ matchLoopParameterProjection(mlir::scf::ForOp loop) { 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]] ExportedCircuit collectBlock(mlir::Block& block, + ExportState& state, + size_t controlFlowDepth, + bool topLevel); [[nodiscard]] bool isFusableMeasurementStore(mlir::qc::MeasureOp measure, mlir::cbit::StoreOp store) { @@ -1784,10 +1747,6 @@ collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, 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 || @@ -1811,12 +1770,8 @@ void validateExpressionBlock(mlir::Block& block, const ExportState& state) { } [[nodiscard]] std::unique_ptr -collectIf(mlir::scf::IfOp ifOp, ExportState& state, const ExportScope& scope, +collectIf(mlir::scf::IfOp ifOp, ExportState& state, 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"); } @@ -1825,18 +1780,16 @@ collectIf(mlir::scf::IfOp ifOp, ExportState& state, const ExportScope& scope, result->target = exportCondition(ifOp.getCondition(), state, *ifOp->getBlock(), *ifOp.getOperation()); result->blocks.push_back(collectBlock(ifOp.getThenRegion().front(), state, - scope, controlFlowDepth + 1U, false)); + controlFlowDepth + 1U, false)); if (!ifOp.getElseRegion().empty()) { result->blocks.push_back(collectBlock(ifOp.getElseRegion().front(), state, - scope, controlFlowDepth + 1U, false)); + 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, +collectFor(mlir::scf::ForOp loop, ExportState& state, const size_t controlFlowDepth) { if (!loop.getInitArgs().empty() || loop.getNumResults() != 0U) { throw std::runtime_error( @@ -1857,7 +1810,6 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, const ExportScope& scope, result->kind = ControlFlowKind::For; result->loop = { .isRange = true, .start = *lower, .stop = *upper, .step = *step}; - auto bodyScope = scope; std::optional projection; std::optional loopParameter; std::string loopParameterName; @@ -1878,11 +1830,11 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, const ExportScope& scope, } while (state.parameterNames.contains(loopParameterName)); state.parameterNames.insert(loopParameterName); loopParameter = Parameter::symbol(loopParameterName); - bodyScope.parameters[projection->value] = *loopParameter; + state.parameters[projection->value] = *loopParameter; } } - auto body = collectBlock(*loop.getBody(), state, bodyScope, - controlFlowDepth + 1U, false); + auto body = + collectBlock(*loop.getBody(), state, controlFlowDepth + 1U, false); if (projection && loopParameter && circuitUsesParameterName(body, loopParameterName)) { result->loop.parameter = *loopParameter; @@ -1912,29 +1864,12 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, const ExportScope& scope, } } result->blocks.push_back(std::move(body)); - 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) { + const size_t controlFlowDepth) { if (controlFlowDepth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { throw std::runtime_error("QC control flow exceeds the nesting limit of 64"); } @@ -1956,15 +1891,13 @@ collectWhile(mlir::scf::WhileOp loop, ExportState& state, *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); + collectBlock(after, state, controlFlowDepth + 1U, false)); return result; } [[nodiscard]] std::unique_ptr collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportState& state, - const ExportScope& scope, const size_t controlFlowDepth) { + const size_t controlFlowDepth) { if (switchOp.getNumResults() != 0U) { throw std::runtime_error( "Qiskit switch export does not support SSA results"); @@ -1977,7 +1910,7 @@ collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportState& state, result->target = exportSwitchTarget(switchOp.getArg(), state, *switchOp->getBlock(), *switchOp.getOperation()); - const uint32_t targetWidth = switchTargetWidth(result->target); + const uint32_t targetWidth = result->target.width; for (const auto [index, label] : llvm::enumerate(switchOp.getCases())) { if (label < 0) { throw std::runtime_error( @@ -1991,21 +1924,19 @@ collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportState& state, } result->switchCases.push_back({.labels = {static_cast(label)}}); result->blocks.push_back( - collectBlock(switchOp.getCaseRegions()[index].front(), state, scope, + collectBlock(switchOp.getCaseRegions()[index].front(), state, 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); + state, controlFlowDepth + 1U, false)); return result; } -[[nodiscard]] ExportedCircuit -collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, - const size_t controlFlowDepth, const bool topLevel) { +[[nodiscard]] ExportedCircuit collectBlock(mlir::Block& block, + ExportState& state, + const size_t controlFlowDepth, + const bool topLevel) { ExportedCircuit circuit; llvm::SmallVector deferredExpressions; for (auto& operation : block) { @@ -2043,38 +1974,16 @@ collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, "QC to Qiskit export encountered an unsupported memory deallocation"); } if (auto store = llvm::dyn_cast(operation)) { - auto measure = store.getValue().getDefiningOp(); - if (!measure || !isFusableMeasurementStore(measure, store)) { + if (!store.getValue().getDefiningOp()) { 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(circuit, - exportParameter(phase.getTheta(), scope.parameters)); + exportParameter(phase.getTheta(), state.parameters)); continue; } if (auto measure = llvm::dyn_cast(operation)) { @@ -2113,6 +2022,16 @@ collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, throw std::runtime_error( "QC measurement uses an out-of-bounds classical destination"); } + if (!state.measurementDestinations[destination.getReg()] + .insert(checked) + .second) { + throw std::runtime_error( + "QC to Qiskit export does not support duplicate classical " + "destinations"); + } + if (topLevel) { + state.unconditionalWrites[destination.getReg()].insert(checked); + } circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::Measure, .qubits = mapQubits(measure.getQubit(), state.qubits), @@ -2134,7 +2053,7 @@ collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, } if (llvm::isa(operation)) { circuit.instructions.push_back( - collectUnitaryInstruction(operation, state.qubits, scope.parameters)); + collectUnitaryInstruction(operation, state.qubits, state.parameters)); continue; } if (auto ifOp = llvm::dyn_cast(operation)) { @@ -2151,31 +2070,30 @@ collectBlock(mlir::Block& block, ExportState& state, ExportScope scope, } circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::ControlFlow, - .controlFlow = collectIf(ifOp, state, scope, controlFlowDepth)}); + .controlFlow = collectIf(ifOp, state, controlFlowDepth)}); continue; } if (auto loop = llvm::dyn_cast(operation)) { circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::ControlFlow, - .controlFlow = collectFor(loop, state, scope, controlFlowDepth)}); + .controlFlow = collectFor(loop, state, controlFlowDepth)}); continue; } if (auto loop = llvm::dyn_cast(operation)) { circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::ControlFlow, - .controlFlow = collectWhile(loop, state, scope, controlFlowDepth)}); + .controlFlow = collectWhile(loop, state, controlFlowDepth)}); continue; } if (auto switchOp = llvm::dyn_cast(operation)) { circuit.instructions.push_back( {.kind = ExportedInstruction::Kind::ControlFlow, - .controlFlow = - collectSwitch(switchOp, state, scope, controlFlowDepth)}); + .controlFlow = collectSwitch(switchOp, state, controlFlowDepth)}); continue; } if (llvm::isa(operation)) { circuit.instructions.push_back( - collectUnitaryInstruction(operation, state.qubits, scope.parameters)); + collectUnitaryInstruction(operation, state.qubits, state.parameters)); continue; } if (llvm::isa(operation)) { @@ -2233,7 +2151,8 @@ void validateConstructibleGates(const ExportedCircuit& circuit, } void emitCircuit(ExportedCircuit& circuit, CircuitWriter& writer, - const VersionedTranslation& translation) { + const VersionedTranslation& translation, + const uint32_t numQubits, const uint32_t numClbits) { writer.setGlobalPhase(circuit.globalPhase); for (auto& instruction : circuit.instructions) { switch (instruction.kind) { @@ -2263,16 +2182,13 @@ void emitCircuit(ExportedCircuit& circuit, CircuitWriter& writer, 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); + auto blockWriter = translation.createCircuit(numQubits, numClbits); + emitCircuit(block, *blockWriter, translation, numQubits, numClbits); 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); + std::move(control.switchCases), std::move(blocks)); break; } } @@ -2302,9 +2218,7 @@ nb::object exportCircuit(const mlir::QCProgram& program, "target qubit count"); } collectResources(function, state, target); - const ExportScope rootScope{.parameters = state.parameters}; - auto circuit = - collectBlock(function.getBody().front(), state, rootScope, 0U, true); + auto circuit = collectBlock(function.getBody().front(), state, 0U, true); for (const auto& [reg, info] : state.classicalRegisterInfo) { if (info.initialization == mlir::cbit::Initialization::Zero) { continue; @@ -2339,7 +2253,7 @@ nb::object exportCircuit(const mlir::QCProgram& program, writer->addClassicalRegister(reg.name, static_cast(reg.bits.size())); } - emitCircuit(circuit, *writer, *translation); + emitCircuit(circuit, *writer, *translation, state.numQubits, state.numClbits); return writer->finish(); } diff --git a/bindings/mlir/qiskit/QiskitTranslation.h b/bindings/mlir/qiskit/QiskitTranslation.h index 5239590e25..c7bdc5490c 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -351,9 +351,7 @@ class CircuitWriter { virtual void addControlFlow(ControlFlowKind kind, ClassicalTarget target, Loop loop, std::vector switchCases, - std::vector> blocks, - const std::vector& qubits, - const std::vector& clbits) = 0; + std::vector> blocks) = 0; /** Transfer the native circuit to a new owned Python QuantumCircuit. */ [[nodiscard]] virtual nb::object finish() = 0; }; From 140bfd4689418d2a46e929543f749e36b05af792 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Sat, 22 Aug 2026 09:59:32 +0200 Subject: [PATCH 10/38] =?UTF-8?q?=E2=9C=85=20Focus=20structured=20Qiskit?= =?UTF-8?q?=20export=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace repeated round trips with direct semantic assertions while retaining nested-only loop parameter identity, all-root-bit capture, zero-qubit control flow, and rejection coverage. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- test/python/test_mlir_qiskit_translation.py | 293 +++++--------------- 1 file changed, 77 insertions(+), 216 deletions(-) diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 242cd46972..c93b99e282 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -578,49 +578,6 @@ 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 {mqt.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 {mqt.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( @@ -634,13 +591,10 @@ def test_qiskit_export_rejects_noncanonical_i64_function_result() -> None: } """ ) - 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.""" @@ -656,13 +610,10 @@ def test_qiskit_export_rejects_mixed_sentinel_and_cbit_results() -> None: } """ ) - 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.""" @@ -1052,9 +1003,10 @@ def test_nested_structured_control_and_bound_loop_parameter() -> None: """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) - with circuit.if_test((circuit.clbits[0], False)): - circuit.cx(0, 1) + with circuit.if_test((circuit.clbits[0], False)) as else_: + circuit.ry(iteration, 1) + with else_: + circuit.rz(iteration, 1) with circuit.while_loop((circuit.cregs[0], 0), None, None, None, label=None): circuit.measure(0, 0) with circuit.switch(circuit.cregs[0], None, None, None, label=None) as case: @@ -1081,8 +1033,10 @@ def test_nested_structured_control_and_bound_loop_parameter() -> None: 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" + branch = loop_body.data[0].operation + assert branch.name == "if_else" + assert branch.blocks[0].data[0].operation.params[0].uuid == loop_parameter.uuid + assert branch.blocks[1].data[0].operation.params[0].uuid == loop_parameter.uuid switch_cases = list(restored.data[2].operation.cases_specifier()) assert [labels for labels, _ in switch_cases] == [(0,), (1,), (CASE_DEFAULT,)] assert [[instruction.operation.name for instruction in body.data] for _, body in switch_cases] == [ @@ -1110,37 +1064,22 @@ def test_control_flow_and_controlled_unitary_preserve_instruction_order() -> Non 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.""" +def test_root_register_expression_and_nested_condition_preserve_captures() -> None: + """Keep a root register leaf and pack its nested block-local condition.""" circuit = QuantumCircuit(1, 3) - with circuit.if_test((circuit.cregs[0], 5)), circuit.if_test((circuit.cregs[0], 2)): + condition = expr.logic_and(expr.equal(circuit.cregs[0], 5), circuit.clbits[0]) + with circuit.if_test(condition), 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] + assert isinstance(outer.condition, expr.Expr) + outer_variables = {variable.var for variable in expr.iter_vars(outer.condition)} + assert outer_variables == {restored.cregs[0], restored.clbits[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) + assert {variable.var for variable in expr.iter_vars(inner.condition)} == set(outer.blocks[0].clbits) def test_repeated_cbit_uint_expression_falls_back_to_expression_tree() -> None: @@ -1167,15 +1106,12 @@ def test_repeated_cbit_uint_expression_falls_back_to_expression_tree() -> None: } """ ) - 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: @@ -1193,7 +1129,6 @@ def test_free_parameter_identity_is_shared_with_control_flow_blocks() -> None: 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: @@ -1220,7 +1155,6 @@ def test_nested_if_while_switch_preserve_capture_identity() -> None: 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: @@ -1237,6 +1171,38 @@ def test_empty_if_else_branches_round_trip() -> None: assert operation.name == "if_else" assert len(operation.blocks) == 2 assert all(not block.data for block in operation.blocks) + + +def test_zero_qubit_cbit_only_control_flow_round_trip() -> None: + """Round-trip CBit-only structured control without allocating qubits.""" + program = QCProgram.from_mlir_str( + """module { + func.func @main() -> !cbit.reg<1> attributes {mqt.entry_point} { + %classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1> + %zero = arith.constant 0 : index + %phase = arith.constant 0.0 : f64 + qc.gphase(%phase) + %condition = cbit.load %classical[%zero] : !cbit.reg<1> + scf.if %condition { + } + return %classical : !cbit.reg<1> + } +} +""" + ) + + restored = program.to_qiskit() + + assert restored.num_qubits == 0 + assert restored.num_clbits == 1 + assert len(restored.data) == 1 + instruction = restored.data[0] + assert instruction.operation.name == "if_else" + assert instruction.qubits == () + assert instruction.clbits == (restored.clbits[0],) + block = instruction.operation.blocks[0] + assert block.num_qubits == 0 + assert block.num_clbits == 1 QCProgram.from_qiskit(restored) @@ -1257,7 +1223,6 @@ def test_for_loop_range_edges_round_trip(values: range, expected: list[int]) -> 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: @@ -1280,7 +1245,6 @@ def test_nested_for_loop_induction_values_remain_lexically_scoped() -> None: 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: @@ -1299,100 +1263,35 @@ def test_generated_loop_parameter_name_avoids_free_symbol_collision() -> None: 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 {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %classical = cbit.alloc(#cbit.init) {mqt.register_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 {mqt.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_transitively_dead_for_loop_parameter_projection_is_ignored() -> None: - """Omit a loop symbol used only by a dead parameter expression.""" +@pytest.mark.parametrize( + "dead_use", + ["", "%unused = math.sin %parameter : f64"], + ids=["direct", "transitive"], +) +def test_dead_for_loop_parameter_projection_is_ignored(dead_use: str) -> None: + """Omit a loop symbol whose projection has no emitted parameter use.""" program = QCProgram.from_mlir_str( - """module { - func.func @main() attributes {mqt.entry_point} { + f"""module {{ + func.func @main() attributes {{mqt.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 { + scf.for %iteration = %lower to %upper step %step {{ %integer = arith.index_cast %iteration : index to i64 %parameter = arith.sitofp %integer : i64 to f64 - %unused = math.sin %parameter : f64 + {dead_use} 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 @@ -1423,15 +1322,11 @@ def test_switch_case_label_width_is_preflighted() -> None: } """ ) - 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_constant_index_switch_round_trip() -> None: +def test_constant_index_switch_exports() -> None: """Lift a direct constant index selector into a Qiskit Uint expression.""" program = QCProgram.from_mlir_str( """module { @@ -1460,7 +1355,6 @@ def test_constant_index_switch_round_trip() -> None: assert isinstance(switch.target, expr.Expr) assert expr.structurally_equivalent(switch.target, expected) assert [labels for labels, _ in switch.cases_specifier()] == [(0,), (CASE_DEFAULT,)] - QCProgram.from_qiskit(restored) def test_shared_expression_dag_expansion_is_bounded() -> None: @@ -1484,13 +1378,9 @@ def test_shared_expression_dag_expansion_is_bounded() -> None: "}", ]) 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_shared_packed_register_candidate_expansion_is_bounded() -> None: """Bound speculative packed-register matching on a shared SSA DAG.""" @@ -1512,13 +1402,9 @@ def test_shared_packed_register_candidate_expansion_is_bounded() -> None: "}", ]) 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_classical_snapshot_walk_is_bounded() -> None: """Bound snapshot discovery before recursive expression export.""" @@ -1541,16 +1427,12 @@ def test_classical_snapshot_walk_is_bounded() -> None: "}", ]) 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.""" +def test_result_bearing_control_flow_is_rejected() -> None: + """Reject unsupported control-flow SSA results.""" program = QCProgram.from_mlir_str( """module { func.func @main() attributes {mqt.entry_point} { @@ -1570,15 +1452,11 @@ def test_result_bearing_control_flow_rejection_preserves_source() -> None: } """ ) - 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: +def test_stale_classical_snapshot_is_rejected() -> None: """Reject a condition loaded before a later write to the same register.""" program = QCProgram.from_mlir_str( """module { @@ -1598,15 +1476,11 @@ def test_stale_classical_snapshot_rejection_preserves_source() -> None: } """ ) - 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: +def test_delayed_measurement_store_is_rejected() -> None: """Reject a delayed write that would change a captured bit snapshot.""" program = QCProgram.from_mlir_str( """module { @@ -1628,15 +1502,11 @@ def test_measurement_store_after_control_flow_rejection_preserves_source() -> No } """ ) - 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: +def test_multi_result_boolean_select_expressions_export() -> None: """Export every Boolean result of one side-effect-free scf.if expression.""" program = QCProgram.from_mlir_str( """module { @@ -1668,13 +1538,18 @@ def test_multi_result_boolean_select_expressions_round_trip() -> None: } """ ) - 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) + first = restored.data[0].operation.condition + second = restored.data[1].operation.condition + expected_first = expr.logic_and(expr.logic_not(restored.clbits[0]), restored.clbits[1]) + expected_second = expr.logic_and(restored.clbits[0], restored.clbits[1]) + assert isinstance(first, expr.Expr) + assert isinstance(second, expr.Expr) + assert expr.structurally_equivalent(first, expected_first) + assert expr.structurally_equivalent(second, expected_second) def test_undefined_cbits_can_be_read_after_unconditional_measurements() -> None: @@ -1701,7 +1576,6 @@ def test_undefined_cbits_can_be_read_after_unconditional_measurements() -> None: 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: @@ -1724,13 +1598,10 @@ def test_undefined_cbit_load_before_measurement_is_rejected() -> None: } """ ) - source = program.ir with pytest.raises(RuntimeError, match="loads an undefined classical bit"): program.to_qiskit() - assert program.ir == source - def test_conditional_measurement_does_not_initialize_returned_cbit() -> None: """Do not count a branch-local measurement as a definite output write.""" @@ -1751,13 +1622,10 @@ def test_conditional_measurement_does_not_initialize_returned_cbit() -> None: } """ ) - source = program.ir with pytest.raises(RuntimeError, match="cannot return undefined classical bits"): program.to_qiskit() - assert program.ir == source - @pytest.mark.parametrize( ("expression", "error"), @@ -1850,16 +1718,13 @@ def test_bool_uint_and_float_expressions(condition: expr.Expr, operation: str) - 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 test_index_expression_round_trip_preserves_low_bit() -> None: +def test_index_expression_export_preserves_low_bit() -> None: """Export integer truncation as bit indexing instead of a truthiness cast.""" condition = expr.index(expr.lift(2, types.Uint(3)), expr.lift(0, types.Uint(3))) circuit = QuantumCircuit(1) @@ -1874,10 +1739,6 @@ def test_index_expression_round_trip_preserves_low_bit() -> None: assert isinstance(restored_condition, expr.Expr) assert expr.structurally_equivalent(restored_condition, condition) - round_trip_ir = QCProgram.from_qiskit(restored).ir - assert "arith.trunci" in round_trip_ir - assert "arith.cmpi ne" not in round_trip_ir - def test_integer_truncation_exports_as_low_bit_index() -> None: """Preserve the low-bit semantics of a generic integer truncation.""" From 055299e68da4231aa18d2773643c15a4a801d5ae Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Sat, 22 Aug 2026 09:59:53 +0200 Subject: [PATCH 11/38] =?UTF-8?q?=F0=9F=93=9D=20Record=20structured=20Qisk?= =?UTF-8?q?it=20export=20simplification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the public Qiskit fallback concisely and record the shared-symbol, placeholder, all-root-bit, and final validation decisions in the living plan. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- .../plans/qiskit-structured-control-export.md | 117 +++++++++++++----- docs/mlir/python_compiler_collection.md | 10 +- 2 files changed, 85 insertions(+), 42 deletions(-) diff --git a/.agent/plans/qiskit-structured-control-export.md b/.agent/plans/qiskit-structured-control-export.md index 252c4785d9..09e7e54be9 100644 --- a/.agent/plans/qiskit-structured-control-export.md +++ b/.agent/plans/qiskit-structured-control-export.md @@ -21,9 +21,10 @@ 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. +ordinary operations through the C API and emits a zero-operand barrier as a +temporary placeholder for each control-flow operation. After conversion to +Python, it finalizes nested blocks and replaces each placeholder with the +already validated public Qiskit control-flow operation. This plan covers only structured-control export. Relaxing measurement-result store adjacency across quantum-only operations is an independently reviewable @@ -87,6 +88,13 @@ follow-up with its own ExecPlan and branch. - [x] (2026-08-22 07:04Z) Pass the final clean repository lint run and an independent review with no remaining actionable findings; the audit fix is ready to commit and push. +- [x] (2026-08-22 07:50Z) Apply the final complexity pass by sharing exporter + parameter state and native Qiskit symbols, replacing deferred insertion + bookkeeping with in-place placeholders, reducing repeated round trips and + source-preservation checks, and shortening internal user-documentation + detail. Rebuild the binding, pass all 208 minimized translation tests on + Qiskit 2.5.0, 2.5.1, and 2.5.2, regenerate unchanged stubs, and pass the + complete repository lint session. ## Surprises & Discoveries @@ -146,6 +154,23 @@ follow-up with its own ExecPlan and branch. a dead `math.sin` expression caused finalization to report that the loop parameter was absent from its body. +- Observation: Parent and child native writers previously created separate + Qiskit parameter objects and repaired their identity after Python conversion. + Evidence: all generated parameter names are unique, so one symbol table shared + by the writer tree creates the required identity directly and removes the + `assign_parameters` pass. + +- Observation: Every exported control-flow block uses all root qubits and + classical bits in identity order. Evidence: each child writer is created with + the root bit counts and is rebased by constructing a circuit from the parent's + exact bit lists and composing once. + +- Observation: Re-importing most exported test circuits repeated #2175's import + coverage without checking additional exporter behavior. Evidence: direct + assertions already inspect conditions, case labels, captures, parameter + identities, and block contents. One broad round trip and focused semantic + round trips retain the end-to-end contract. + ## Decision Log - Decision: Change only `CircuitWriter`'s output interface and leave all reader @@ -215,6 +240,26 @@ follow-up with its own ExecPlan and branch. a Qiskit loop parameter that its body does not contain. Date/Author: 2026-08-22 / Codex. +- Decision: Append a zero-operand native barrier for each deferred control-flow + operation and replace that exact instruction after converting the circuit to + Python. Rationale: an in-place placeholder preserves instruction order without + merging insertion offsets with controlled-unitary replacements. A focused + zero-qubit, CBit-only regression verifies that a zero-operand placeholder can + become a control-flow instruction with classical operands. Date/Author: + 2026-08-22 / Codex. + +- Decision: Share one native parameter-symbol table among the root writer and + all child writers, and share the exporter's SSA-to-parameter state across + lexical block collection. Rationale: MLIR values remain unique across nested + regions and generated loop names avoid collisions, so copied scopes and + post-construction Python parameter replacement add no semantic protection. + Date/Author: 2026-08-22 / Codex. + +- Decision: Make the all-root-bit invariant explicit and remove per-operation + identity capture maps. Rationale: the exporter never produced sparse or + permuted captures, and retaining vectors implied unsupported generality while + duplicating allocation and validation work. Date/Author: 2026-08-22 / Codex. + ## Outcomes & Retrospective Structured Qiskit control flow now exports recursively through a normalized, @@ -224,13 +269,14 @@ 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 MLIR binding builds successfully after the final merge onto `main`. All 211 -tests in `test/python/test_mlir_qiskit_translation.py` pass against the exact -worktree-built extension. Stub generation and repository lint also pass. The -complete documentation build was explicitly deferred for this handoff. The -semantic diff leaves the refreshed import reader and current name-keyed scalar -parameter normalizer unchanged, while recursively checking that every named -scalar input remains reachable from the emitted top-level or nested Qiskit +After the final complexity pass, the release MLIR binding builds successfully +and all 208 tests in `test/python/test_mlir_qiskit_translation.py` pass against +the exact worktree-built extension with Qiskit 2.5.0, 2.5.1, and 2.5.2. Stub +generation produces no tracked changes, and the complete repository lint session +passes. The documentation build remains explicitly deferred for this handoff. +The semantic diff leaves the refreshed import reader and current name-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. Speculative expression recognition and snapshot validation are bounded before recursion can consume unbounded resources, and dead loop parameter expressions no longer expose invalid Qiskit metadata. The @@ -250,7 +296,7 @@ normalized writer stream. `ExportState` discovers qubit resources, returned 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. Each SCF region becomes a -nested circuit block with captured root qubits and classical bits. +nested circuit block over all 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 @@ -268,11 +314,12 @@ 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 preserves scalar symbols by their validated unique names. -`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. +unchanged. The root writer and its children share scalar symbols by their +validated unique names. `PythonClassicalBuilder` reconstructs normalized +expression trees. The writer emits one temporary native barrier at each +control-flow position, finalizes child writers against the parent's exact bit +objects, creates Python control-flow operations, and replaces the barriers in +place. `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 @@ -283,18 +330,18 @@ support table and its exact restrictions. The implementation adds `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. - -`bindings/mlir/qiskit/Qiskit2_5.cpp` contains a `PythonClassicalBuilder` that -turns constants, captured Clbits, captured ClassicalRegisters, casts, indexing, -unary operations, and binary operations into Qiskit's public expression objects. -`NativeCircuitWriter` records control flow without adding a C placeholder. -During `finish`, it converts native circuits to Python, rebases each nested -block onto the parent's exact Qubit and Clbit objects, preserves canonical -scalar parameter objects across blocks, builds the public control-flow -operations, and inserts them at stable instruction positions. It validates block -shape, captures, loop metadata, switch labels, and bit counts before -construction. +writers. Each block writer has the full root qubit and classical-bit counts. + +The `PythonClassicalBuilder` in `bindings/mlir/qiskit/Qiskit2_5.cpp` turns +constants, captured Clbits, captured ClassicalRegisters, casts, indexing, unary +operations, and binary operations into Qiskit's public expression objects. +`NativeCircuitWriter` appends a zero-operand native barrier as a placeholder for +each control-flow operation. During `finish`, it converts native circuits to +Python, rebases each nested block onto the parent's exact Qubit and Clbit +objects, uses the symbol table shared by the writer tree, builds the public +control-flow operations, and replaces the placeholders in place. It validates +native writer compatibility and bit counts before construction; generic +preflight has already validated block shape, loop metadata, and switch labels. `bindings/mlir/qiskit/QiskitExport.cpp` preserves the existing scalar parameter normalizer and resource discovery while adding recursive circuit and @@ -303,8 +350,8 @@ snapshot validation, loop projection, recursive collection, recursive constructible-gate validation, and recursive writer emission. It uses only CBit operations for classical state and preflights 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. +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 @@ -403,9 +450,7 @@ 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); + std::vector> blocks); `QiskitExport.cpp` owns `ExportedCircuit` and `ExportedControlFlow` records and recursively calls this operation only after complete preflight. `Qiskit2_5.cpp` @@ -423,4 +468,8 @@ contract; only the export-specific delta was replayed. Recorded the final low-bit and constant-index corrections together with the required stubs, lint, deferred documentation build, and 208-test validation after the last parent update. Recorded the squash-merge resolution and the bounded-preflight and dead -loop-parameter fixes found during the post-merge audit. +loop-parameter fixes found during the post-merge audit. Recorded the final +complexity pass that shares parameter state, replaces deferred insertion with +native placeholders, makes the all-root-bit invariant explicit, and reduces +repeated test and documentation work; recorded the successful build, unchanged +stubs, clean lint, and 208-test Qiskit 2.5.0-2.5.2 matrix. diff --git a/docs/mlir/python_compiler_collection.md b/docs/mlir/python_compiler_collection.md index 8246518fbd..1d61c96fcc 100644 --- a/docs/mlir/python_compiler_collection.md +++ b/docs/mlir/python_compiler_collection.md @@ -159,14 +159,8 @@ This compiler route does not construct an intermediate interfaces remain independent and retain their existing version range and behavior. -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. +Qiskit 2.5's C API cannot construct classical expressions or structured control +flow, so export uses Qiskit's public Python classes for these operations. | Circuit feature | Import | Export | | ----------------------------------------------------------------- | -------------------- | -------------- | From 89700c6c539634f4ded22c0d144c07e672fddb0a Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Sat, 22 Aug 2026 10:41:45 +0200 Subject: [PATCH 12/38] =?UTF-8?q?=F0=9F=8E=A8=20Address=20Clang-Tidy=20war?= =?UTF-8?q?ning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return the packed-register constant predicate directly to satisfy readability-simplify-boolean-expr. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/QiskitExport.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 40328f4537..5b767d46e4 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -1382,10 +1382,7 @@ matchPackedRegister(mlir::Value value, ExportState& state, llvm::dyn_cast(operation)) { const auto integer = llvm::dyn_cast(constant.getValue()); - if (!integer || !integer.getValue().isZero()) { - return false; - } - return true; + return integer && integer.getValue().isZero(); } if (operation->getBlock() != &evaluationBlock) { return false; From 1ce3145a10de7d01941d90778a1e9ca84059b6c6 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Sat, 22 Aug 2026 12:41:22 +0200 Subject: [PATCH 13/38] =?UTF-8?q?=F0=9F=90=9B=20Validate=20structured=20Qi?= =?UTF-8?q?skit=20control=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject malformed control-flow block plans before deferred construction and assert normalized Bool/Float expression round trips. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/Qiskit2_5.cpp | 20 ++++++++++++++++++++ test/python/test_mlir_qiskit_translation.py | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index c9f1d6adc2..ef7e78eba1 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -2039,6 +2039,26 @@ class NativeCircuitWriter final : public CircuitWriter { addControlFlow(const ControlFlowKind kind, ClassicalTarget target, Loop loop, std::vector switchCases, std::vector> blocks) override { + const bool validBlockCount = [&]() { + switch (kind) { + case ControlFlowKind::IfElse: + return blocks.size() == 1U || blocks.size() == 2U; + case ControlFlowKind::While: + case ControlFlowKind::For: + return blocks.size() == 1U; + case ControlFlowKind::Switch: + return !blocks.empty() && blocks.size() == switchCases.size(); + case ControlFlowKind::Box: + case ControlFlowKind::Break: + case ControlFlowKind::Continue: + return false; + } + return false; + }(); + if (!validBlockCount) { + throw std::runtime_error( + "Qiskit control flow has an unexpected number of blocks"); + } const auto numQubits = qk_circuit_num_qubits(circuit_); const auto numClbits = qk_circuit_num_clbits(circuit_); for (const auto& block : blocks) { diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index c93b99e282..bc01df14bc 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -1722,6 +1722,18 @@ def test_bool_uint_and_float_expressions(condition: expr.Expr, operation: str) - assert operation in program.ir assert restored.data[0].operation.name == "if_else" + restored_condition = restored.data[0].operation.condition + assert isinstance(restored_condition, expr.Expr) + if operation == "arith.andi": + expected = expr.logic_and( + expr.equal(True, True), # ruff: ignore[boolean-positional-value-in-call] Qiskit expression arguments are positional-only. + expr.equal(False, True), # ruff: ignore[boolean-positional-value-in-call] Qiskit expression arguments are positional-only. + ) + elif operation == "arith.cmpf une": + expected = expr.not_equal(expr.lift(0.5, types.Float()), 0.0) + else: + expected = condition + assert expr.structurally_equivalent(restored_condition, expected) def test_index_expression_export_preserves_low_bit() -> None: From ed552e0d64b12a05b72ccd678c2697b681d86234 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Mon, 24 Aug 2026 10:52:10 +0200 Subject: [PATCH 14/38] =?UTF-8?q?=F0=9F=90=9B=20Initialize=20the=20Qiskit?= =?UTF-8?q?=20C=20API=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish the vendored translation-unit-local function tables through thread-safe static initialization for nanobind 3 free-threaded bindings. Add concurrent import and export coverage. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- .../plans/qiskit-structured-control-export.md | 6 +++++- bindings/mlir/qiskit/Qiskit2_5.cpp | 17 ++++++++++++----- test/python/test_mlir_qiskit_translation.py | 13 +++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.agent/plans/qiskit-structured-control-export.md b/.agent/plans/qiskit-structured-control-export.md index 09e7e54be9..424960116e 100644 --- a/.agent/plans/qiskit-structured-control-export.md +++ b/.agent/plans/qiskit-structured-control-export.md @@ -472,4 +472,8 @@ loop-parameter fixes found during the post-merge audit. Recorded the final complexity pass that shares parameter state, replaces deferred insertion with native placeholders, makes the all-root-bit invariant explicit, and reduces repeated test and documentation work; recorded the successful build, unchanged -stubs, clean lint, and 208-test Qiskit 2.5.0-2.5.2 matrix. +stubs, clean lint, and 208-test Qiskit 2.5.0-2.5.2 matrix. Merged `main` through +`84ace8ef2`, preserved its QCO switch, deterministic finalization, +loop-unrolling, and nanobind 3 changes, and made the vendored Qiskit C API +initialization safe for the new free-threaded binding mode. The split-mode +binding build and all 209 Qiskit 2.5.2 translation tests passed. diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index ef7e78eba1..bbcb5e8ba8 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -2454,16 +2454,23 @@ class NativeTranslation final : public VersionedTranslation { std::make_shared(); }; +[[nodiscard]] uint32_t qiskitApiVersion() { + static const uint32_t VERSION = []() { + if (qk_import() < 0) { + throwPythonError( + "failed to initialize the Qiskit " MQT_QISKIT_VERSION_LABEL " C API"); + } + return qk_api_version(); + }(); + return VERSION; +} + } // namespace std::unique_ptr MQT_QISKIT_VERSION_FACTORY() { // NOLINT(misc-use-internal-linkage): declared in // the version registry. - if (qk_import() < 0) { - throwPythonError("failed to initialize the Qiskit " MQT_QISKIT_VERSION_LABEL - " C API"); - } - const auto version = qk_api_version(); + const auto version = qiskitApiVersion(); const auto major = (version >> 24U) & 0xffU; const auto minor = (version >> 16U) & 0xffU; if (major != MQT_QISKIT_VERSION_EXPECTED_MAJOR || diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index bc01df14bc..66331b34ec 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -14,6 +14,7 @@ import re import subprocess import sys +from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING import numpy as np @@ -105,6 +106,18 @@ ) +def test_native_api_initialization_supports_concurrent_translation() -> None: + """Initialize and reuse the native Qiskit API from concurrent translations.""" + + def _round_trip(_: int) -> str: + circuit = QuantumCircuit(1) + circuit.x(0) + return QCProgram.from_qiskit(circuit).to_qiskit().data[0].operation.name + + with ThreadPoolExecutor(max_workers=8) as executor: + assert list(executor.map(_round_trip, range(32))) == ["x"] * 32 + + @pytest.mark.parametrize("gate", STANDARD_GATES, ids=lambda gate: gate.name) def test_standard_gates_round_trip(gate: Gate) -> None: """Translate each supported gate family in both directions.""" From ea2b436dc28f18106dffd7eade252aeb08316317 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Mon, 24 Aug 2026 12:23:47 +0200 Subject: [PATCH 15/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20structure?= =?UTF-8?q?d=20Qiskit=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/QiskitExport.cpp | 83 ++++++++------------------- 1 file changed, 23 insertions(+), 60 deletions(-) diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 5b767d46e4..98ab7b10ae 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -1204,8 +1204,9 @@ exportExpressionImpl(mlir::Value value, ExportState& state, return std::move(result); }; - if (auto cast = llvm::dyn_cast(operation)) { - return unary(ExpressionKind::Cast, cast.getIn()); + if (llvm::isa(operation)) { + return unary(ExpressionKind::Cast, operation->getOperand(0)); } if (auto cast = llvm::dyn_cast(operation)) { if (!cast.getType().isInteger(1)) { @@ -1234,12 +1235,6 @@ exportExpressionImpl(mlir::Value value, ExportState& state, state.expressionOperations.insert(operation); return 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)) { state.expressionOperations.insert(operation); return exportExpressionImpl(cast.getIn(), state, evaluationBlock, @@ -1318,29 +1313,21 @@ exportExpressionImpl(mlir::Value value, ExportState& state, 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 (llvm::isa(operation)) { + return binary(BinaryOperation::Add, operation->getOperand(0), + operation->getOperand(1)); } - if (auto op = llvm::dyn_cast(operation)) { - return binary(BinaryOperation::Add, op.getLhs(), op.getRhs()); + if (llvm::isa(operation)) { + return binary(BinaryOperation::Subtract, operation->getOperand(0), + operation->getOperand(1)); } - if (auto op = llvm::dyn_cast(operation)) { - return binary(BinaryOperation::Subtract, op.getLhs(), op.getRhs()); + if (llvm::isa(operation)) { + return binary(BinaryOperation::Multiply, operation->getOperand(0), + operation->getOperand(1)); } - 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 (llvm::isa(operation)) { + return binary(BinaryOperation::Divide, operation->getOperand(0), + operation->getOperand(1)); } if (auto op = llvm::dyn_cast(operation)) { result->unaryOperation = UnaryOperation::Negate; @@ -1625,18 +1612,6 @@ void validateClassicalSnapshot(const mlir::Value expression, 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) { @@ -1661,14 +1636,9 @@ void validateClassicalSnapshot(const mlir::Value expression, 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(); + const auto distance = + static_cast(upper) - static_cast(lower); + return ((distance - 1U) / static_cast(step)) + 1U; } struct LoopParameterProjection { @@ -1698,12 +1668,11 @@ matchLoopParameterProjection(mlir::scf::ForOp loop) { if (auto multiply = llvm::dyn_cast(user)) { const auto other = multiply.getLhs() == current ? multiply.getRhs() : multiply.getLhs(); - auto constant = other.getDefiningOp(); + const auto constant = mlir::getConstantIntValue(other); if (!constant) { return std::nullopt; } - projection.multiplier = - signedIntegerConstant(other, "scf.for induction multiplier"); + projection.multiplier = *constant; projection.operations.insert(user); current = multiply.getResult(); } @@ -1711,12 +1680,11 @@ matchLoopParameterProjection(mlir::scf::ForOp loop) { 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(); + const auto constant = mlir::getConstantIntValue(other); if (!constant) { return std::nullopt; } - projection.offset = - signedIntegerConstant(other, "scf.for induction offset"); + projection.offset = *constant; projection.operations.insert(user); current = add.getResult(); } @@ -2137,8 +2105,7 @@ void validateConstructibleGates(const ExportedCircuit& circuit, descriptor.operationSymbol.str() + "' with " + std::to_string(instruction.gate.controls) + " controls"); } - if (instruction.kind != ExportedInstruction::Kind::ControlFlow || - !instruction.controlFlow) { + if (instruction.kind != ExportedInstruction::Kind::ControlFlow) { continue; } for (const auto& block : instruction.controlFlow->blocks) { @@ -2171,10 +2138,6 @@ void emitCircuit(ExportedCircuit& circuit, CircuitWriter& writer, 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()); From 0b88fe521054ed0d390a5868284d3c8a01b3bc1d Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Mon, 24 Aug 2026 12:24:10 +0200 Subject: [PATCH 16/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20Qiskit=20?= =?UTF-8?q?control-flow=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/Qiskit2_5.cpp | 84 +++++++++--------------------- 1 file changed, 26 insertions(+), 58 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index bbcb5e8ba8..a8e932130e 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -30,7 +30,6 @@ #include #include #include -#include #include #include #include @@ -1697,30 +1696,15 @@ class PythonClassicalBuilder final { [[nodiscard]] std::optional registeredClassicalRegister(const Register& reg) const { - std::optional matchingBits; for (const nb::handle candidateHandle : nb::iter(cregs_)) { - 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; + return std::nullopt; } static void validateRegisterValue(const Register& reg, const uint64_t value) { @@ -2188,15 +2172,12 @@ class NativeCircuitWriter final : public CircuitWriter { } [[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)); + if (!loop.isRange) { + throw std::runtime_error( + "Qiskit circuit export supports only range-based for loops"); } - return values; + return nb::module_::import_("builtins") + .attr("range")(loop.start, loop.stop, loop.step); } [[nodiscard]] static nb::object loopParameter(const Loop& loop, @@ -2244,14 +2225,12 @@ class NativeCircuitWriter final : public CircuitWriter { 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)); + if (switchCase.labels.size() != 1U) { + throw std::runtime_error( + "Qiskit circuit export requires one label per switch case"); } - labels = std::move(values); + labels = nb::int_(switchCase.labels.front()); } cases.append(nb::make_tuple(labels, blocks[index])); } @@ -2283,21 +2262,15 @@ class NativeCircuitWriter final : public CircuitWriter { } 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, circuitQubits, circuitClbits)); - } catch (const std::exception& error) { + for (const auto& blockWriter : pending.blockWriters) { + auto* const writer = + dynamic_cast(blockWriter.get()); + if (writer == nullptr) { throw std::runtime_error( - "Qiskit failed to finalize control-flow block " + - std::to_string(index) + ": " + error.what()); + "Qiskit control-flow blocks use an incompatible writer"); } + blocks.emplace_back( + writer->finishImpl(true, circuitQubits, circuitClbits)); } pending.blockWriters.clear(); auto operation = constructControlFlowOperation(pending, blocks, classical, @@ -2454,28 +2427,23 @@ class NativeTranslation final : public VersionedTranslation { std::make_shared(); }; -[[nodiscard]] uint32_t qiskitApiVersion() { - static const uint32_t VERSION = []() { +} // namespace + +std::unique_ptr +MQT_QISKIT_VERSION_FACTORY() { // NOLINT(misc-use-internal-linkage): declared in + // the version registry. + static const auto VERSION = []() { if (qk_import() < 0) { throwPythonError( "failed to initialize the Qiskit " MQT_QISKIT_VERSION_LABEL " C API"); } return qk_api_version(); }(); - return VERSION; -} - -} // namespace - -std::unique_ptr -MQT_QISKIT_VERSION_FACTORY() { // NOLINT(misc-use-internal-linkage): declared in - // the version registry. - const auto version = qiskitApiVersion(); - const auto major = (version >> 24U) & 0xffU; - const auto minor = (version >> 16U) & 0xffU; + const auto major = (VERSION >> 24U) & 0xffU; + const auto minor = (VERSION >> 16U) & 0xffU; if (major != MQT_QISKIT_VERSION_EXPECTED_MAJOR || minor != MQT_QISKIT_VERSION_EXPECTED_MINOR || - (MQT_QISKIT_VERSION_EXACT_API != 0 && version != QISKIT_VERSION_HEX)) { + (MQT_QISKIT_VERSION_EXACT_API != 0 && VERSION != QISKIT_VERSION_HEX)) { throw std::runtime_error("Qiskit C API capsule version does not match the " "selected " MQT_QISKIT_VERSION_LABEL " translation"); From b7fe36e4c8d3378e7747200a93343ccf9d533b86 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Mon, 24 Aug 2026 12:24:36 +0200 Subject: [PATCH 17/38] =?UTF-8?q?=E2=9C=85=20Tighten=20structured-control?= =?UTF-8?q?=20export=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- .../plans/qiskit-structured-control-export.md | 49 ++-- test/python/test_mlir_qiskit_translation.py | 210 +++++++++--------- 2 files changed, 141 insertions(+), 118 deletions(-) diff --git a/.agent/plans/qiskit-structured-control-export.md b/.agent/plans/qiskit-structured-control-export.md index 424960116e..4178f163c4 100644 --- a/.agent/plans/qiskit-structured-control-export.md +++ b/.agent/plans/qiskit-structured-control-export.md @@ -95,6 +95,12 @@ follow-up with its own ExecPlan and branch. detail. Rebuild the binding, pass all 208 minimized translation tests on Qiskit 2.5.0, 2.5.1, and 2.5.2, regenerate unchanged stubs, and pass the complete repository lint session. +- [x] (2026-08-24 10:20Z) Apply the post-review simplification pass: retain the + documented packed-register and general Boolean-select paths, remove + duplicated exporter and adapter code, deduplicate focused test setup, + preserve fused affine overflow handling, rebuild the exact release + binding, pass all 210 translation tests, regenerate unchanged stubs, and + pass focused format and static checks. ## Surprises & Discoveries @@ -171,6 +177,11 @@ follow-up with its own ExecPlan and branch. identities, and block contents. One broad round trip and focused semantic round trips retain the end-to-end contract. +- Observation: A checked signed multiply followed by a checked add is not + equivalent to the existing fused affine calculation. Evidence: multiplying + `-2` by `INT64_MAX` overflows `i64`, but adding `INT64_MAX` produces the valid + final value `-INT64_MAX`; the 128-bit calculation preserves that case. + ## Decision Log - Decision: Change only `CircuitWriter`'s output interface and leave all reader @@ -260,6 +271,14 @@ follow-up with its own ExecPlan and branch. permuted captures, and retaining vectors implied unsupported generality while duplicating allocation and validation work. Date/Author: 2026-08-22 / Codex. +- Decision: Keep packed-register recovery, general pure Boolean `scf.if` + selection, and runtime checks at the versioned writer boundary. Simplify the + operation dispatch, constant extraction, range calculation, internal lookup, + and sole-producer loop and switch construction instead. Rationale: the kept + paths implement documented behavior or protect the Python construction + boundary; the removed code duplicated validated internal state. Date/Author: + 2026-08-24 / Codex. + ## Outcomes & Retrospective Structured Qiskit control flow now exports recursively through a normalized, @@ -270,18 +289,19 @@ snapshots, unsupported expression/result forms, invalid labels, and undefined CBit reads or returns before allocating the Qiskit writer. After the final complexity pass, the release MLIR binding builds successfully -and all 208 tests in `test/python/test_mlir_qiskit_translation.py` pass against -the exact worktree-built extension with Qiskit 2.5.0, 2.5.1, and 2.5.2. Stub -generation produces no tracked changes, and the complete repository lint session -passes. The documentation build remains explicitly deferred for this handoff. -The semantic diff leaves the refreshed import reader and current name-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. Speculative expression recognition and snapshot validation are -bounded before recursion can consume unbounded resources, and dead loop -parameter expressions no longer expose invalid Qiskit metadata. The -measurement-store relaxation remains out of scope for this completed plan and -will receive its own branch and ExecPlan. +and all 210 tests in `test/python/test_mlir_qiskit_translation.py` pass against +the exact worktree-built extension with Qiskit 2.5.2. The earlier 208-test +matrix passed with Qiskit 2.5.0, 2.5.1, and 2.5.2. Stub generation produces no +tracked changes, and the complete repository lint session passes. The +documentation build remains explicitly deferred for this handoff. The semantic +diff leaves the refreshed import reader and current name-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. +Speculative expression recognition and snapshot validation are bounded before +recursion can consume unbounded resources, and dead loop parameter expressions +no longer expose invalid Qiskit metadata. The measurement-store relaxation +remains out of scope for this completed plan and will receive its own branch and +ExecPlan. ## Context and Orientation @@ -476,4 +496,7 @@ stubs, clean lint, and 208-test Qiskit 2.5.0-2.5.2 matrix. Merged `main` through `84ace8ef2`, preserved its QCO switch, deterministic finalization, loop-unrolling, and nanobind 3 changes, and made the vendored Qiskit C API initialization safe for the new free-threaded binding mode. The split-mode -binding build and all 209 Qiskit 2.5.2 translation tests passed. +binding build and all 209 Qiskit 2.5.2 translation tests passed. Applied the +post-review simplification without removing documented register and Boolean +expression behavior, retained fused affine overflow semantics after an +independent audit, and passed all 210 Qiskit 2.5.2 translation tests. diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 66331b34ec..c4969e14a1 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -1219,6 +1219,29 @@ def test_zero_qubit_cbit_only_control_flow_round_trip() -> None: QCProgram.from_qiskit(restored) +def _single_qubit_program(operations: list[str], *, returns_classical: bool = False) -> QCProgram: + """Wrap operations in a one-qubit QC entry function. + + Returns: + The parsed QC program. + """ + result_type = " -> !cbit.reg<1>" if returns_classical else "" + return_value = " %classical : !cbit.reg<1>" if returns_classical else "" + lines = [ + "module {", + f" func.func @main(){result_type} attributes {{mqt.entry_point}} {{", + " %q = qc.alloc : !qc.qubit", + ] + lines.extend(f" {operation}" for operation in operations) + lines.extend([ + " qc.dealloc %q : !qc.qubit", + f" return{return_value}", + " }", + "}", + ]) + return QCProgram.from_mlir_str("\n".join(lines)) + + @pytest.mark.parametrize( ("values", "expected"), [(range(5, -2, -2), [5, 3, 1, -1]), (range(3, 3, -1), [])], @@ -1238,6 +1261,28 @@ def test_for_loop_range_edges_round_trip(values: range, expected: list[int]) -> assert loop.blocks[0].data[0].operation.params[0].uuid == loop.params[1].uuid +def test_for_loop_affine_projection_checks_the_fused_result() -> None: + """Accept a fitting affine value whose intermediate product overflows.""" + program = _single_qubit_program([ + "%lower = arith.constant -2 : index", + "%upper = arith.constant -1 : index", + "%step = arith.constant 1 : index", + "%maximum = arith.constant 9223372036854775807 : i64", + "scf.for %iteration = %lower to %upper step %step {", + " %integer = arith.index_cast %iteration : index to i64", + " %scaled = arith.muli %integer, %maximum : i64", + " %shifted = arith.addi %scaled, %maximum : i64", + " %parameter = arith.sitofp %shifted : i64 to f64", + " qc.rz(%parameter) %q : !qc.qubit", + "}", + ]) + + loop = program.to_qiskit().data[0].operation + + assert list(loop.params[0]) == [-9223372036854775807] + assert loop.blocks[0].data[0].operation.params[0].uuid == loop.params[1].uuid + + def test_nested_for_loop_induction_values_remain_lexically_scoped() -> None: """Keep nested induction variables distinct while retaining outer captures.""" circuit = QuantumCircuit(1) @@ -1372,74 +1417,43 @@ def test_constant_index_switch_exports() -> None: 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 {mqt.entry_point} {", - " %q = qc.alloc : !qc.qubit", - ' %classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1>', - " %zero = arith.constant 0 : index", - " %value0 = cbit.load %classical[%zero] : !cbit.reg<1>", + operations = [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_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)) + operations.extend(f"%value{index} = arith.andi %value{index - 1}, %value{index - 1} : i1" for index in range(1, 14)) + operations.extend(["scf.if %value13 {", " qc.x %q : !qc.qubit", "}"]) + program = _single_qubit_program(operations, returns_classical=True) with pytest.raises(RuntimeError, match="size limit of 4096 nodes"): program.to_qiskit() def test_shared_packed_register_candidate_expansion_is_bounded() -> None: """Bound speculative packed-register matching on a shared SSA DAG.""" - lines = [ - "module {", - " func.func @main() attributes {mqt.entry_point} {", - " %q = qc.alloc : !qc.qubit", - " %value0 = arith.constant 0 : i64", - ] - lines.extend(f" %value{index} = arith.ori %value{index - 1}, %value{index - 1} : i64" for index in range(1, 31)) - lines.extend([ - " %condition = arith.cmpi eq, %value30, %value0 : i64", - " scf.if %condition {", - " qc.x %q : !qc.qubit", - " }", - " qc.dealloc %q : !qc.qubit", - " return", - " }", + operations = ["%value0 = arith.constant 0 : i64"] + operations.extend(f"%value{index} = arith.ori %value{index - 1}, %value{index - 1} : i64" for index in range(1, 31)) + operations.extend([ + "%condition = arith.cmpi eq, %value30, %value0 : i64", + "scf.if %condition {", + " qc.x %q : !qc.qubit", "}", ]) - program = QCProgram.from_mlir_str("\n".join(lines)) + program = _single_qubit_program(operations) with pytest.raises(RuntimeError, match="size limit of 4096 nodes"): program.to_qiskit() def test_classical_snapshot_walk_is_bounded() -> None: """Bound snapshot discovery before recursive expression export.""" - lines = [ - "module {", - " func.func @main() -> !cbit.reg<1> attributes {mqt.entry_point} {", - " %q = qc.alloc : !qc.qubit", - ' %classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1>', - " %zero = arith.constant 0 : index", - " %value0 = cbit.load %classical[%zero] : !cbit.reg<1>", + operations = [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_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}, %value0 : i1" for index in range(1, 4097)) - lines.extend([ - " scf.if %value4096 {", - " qc.x %q : !qc.qubit", - " }", - " qc.dealloc %q : !qc.qubit", - " return %classical : !cbit.reg<1>", - " }", - "}", - ]) - program = QCProgram.from_mlir_str("\n".join(lines)) + operations.extend(f"%value{index} = arith.andi %value{index - 1}, %value0 : i1" for index in range(1, 4097)) + operations.extend(["scf.if %value4096 {", " qc.x %q : !qc.qubit", "}"]) + program = _single_qubit_program(operations, returns_classical=True) with pytest.raises(RuntimeError, match="size limit of 4096 nodes"): program.to_qiskit() @@ -1565,26 +1579,32 @@ def test_multi_result_boolean_select_expressions_export() -> None: assert expr.structurally_equivalent(second, expected_second) +def _undefined_cbit_program(operations: list[str]) -> QCProgram: + """Build a one-qubit program with one undefined public CBit. + + Returns: + The parsed QC program. + """ + return _single_qubit_program( + [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1>', + "%zero = arith.constant 0 : index", + *operations, + ], + returns_classical=True, + ) + + 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 {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %classical = cbit.alloc(#cbit.init) {mqt.register_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> - } -} -""" - ) + program = _undefined_cbit_program([ + "%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", + "}", + ]) restored = program.to_qiskit() @@ -1593,24 +1613,14 @@ def test_undefined_cbits_can_be_read_after_unconditional_measurements() -> None: 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 {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %classical = cbit.alloc(#cbit.init) {mqt.register_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> - } -} -""" - ) + program = _undefined_cbit_program([ + "%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>", + ]) with pytest.raises(RuntimeError, match="loads an undefined classical bit"): program.to_qiskit() @@ -1618,23 +1628,13 @@ def test_undefined_cbit_load_before_measurement_is_rejected() -> None: 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 {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %classical = cbit.alloc(#cbit.init) {mqt.register_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> - } -} -""" - ) + program = _undefined_cbit_program([ + "%condition = arith.constant true", + "scf.if %condition {", + " %measured = qc.measure %q : !qc.qubit -> i1", + " cbit.store %measured, %classical[%zero] : !cbit.reg<1>", + "}", + ]) with pytest.raises(RuntimeError, match="cannot return undefined classical bits"): program.to_qiskit() From d4d8636335edf4b7e58b7b2ba0d92cd00a3b15d8 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Mon, 24 Aug 2026 13:25:26 +0200 Subject: [PATCH 18/38] =?UTF-8?q?=F0=9F=90=9B=20Preserve=20bounded=20struc?= =?UTF-8?q?tured=20Qiskit=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balance classical-register packing, isolate selected-result snapshot and node validation, preflight normalized expressions, and remove redundant adapter work while preserving empty-loop semantics. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/Qiskit2_5.cpp | 27 +--- bindings/mlir/qiskit/QiskitExport.cpp | 211 ++++++++++++-------------- bindings/mlir/qiskit/QiskitImport.cpp | 21 ++- 3 files changed, 117 insertions(+), 142 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index a8e932130e..21d47562b5 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -1696,6 +1696,9 @@ class PythonClassicalBuilder final { [[nodiscard]] std::optional registeredClassicalRegister(const Register& reg) const { + if (reg.name.empty()) { + return std::nullopt; + } for (const nb::handle candidateHandle : nb::iter(cregs_)) { auto candidate = nb::borrow(candidateHandle); if (pythonStringAttribute(candidate, "name", @@ -1894,8 +1897,7 @@ class PythonClassicalBuilder final { nb::object typesModule_; }; -using NativeSymbolTable = - std::unordered_map>; +using NativeSymbolTable = std::unordered_map; class NativeCircuitWriter final : public CircuitWriter { public: @@ -2154,15 +2156,6 @@ class NativeCircuitWriter final : public CircuitWriter { [[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"); - } auto rebased = nb::module_::import_("qiskit.circuit") .attr("QuantumCircuit")(exactQubits, exactClbits); pythonAttribute(rebased, "compose", @@ -2315,16 +2308,8 @@ class NativeCircuitWriter final : public CircuitWriter { throw std::runtime_error( "cannot export a symbolic parameter without a name"); } - const auto found = symbols_->find(symbol->name); - if (found != symbols_->end()) { - return found->second->get(); - } - const auto inserted = - symbols_ - ->try_emplace(symbol->name, - std::make_unique(symbol->name)) - .first; - return inserted->second->get(); + return symbols_->try_emplace(symbol->name, symbol->name) + .first->second.get(); } auto output = std::make_unique(); diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 98ab7b10ae..1baff12e2b 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -822,12 +822,8 @@ void collectResources(mlir::func::FuncOp function, ExportState& state, } 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()) { + const auto sentinel = mlir::getConstantIntValue(result); + if (result.getType().isInteger(64) && sentinel && *sentinel == 0) { return; } } @@ -929,13 +925,26 @@ void setExpressionType(Expression& expression, const mlir::Type type) { return checkedAdd(info->second.base, checked, "classical-bit"); } +[[noreturn]] void throwClassicalExpressionSizeError() { + throw std::runtime_error( + "QC classical expression exceeds the size limit of 4096 nodes"); +} + +[[noreturn]] void throwClassicalExpressionDepthError() { + throw std::runtime_error( + "QC classical expressions exceed the nesting limit of 64"); +} + +void countExpressionNode(size_t& nodeCount) { + if (++nodeCount > MAX_EXPORT_EXPRESSION_NODES) { + throwClassicalExpressionSizeError(); + } +} + [[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"); - } + countExpressionNode(nodeCount); auto result = std::make_unique(); result->kind = ExpressionKind::Unary; result->type = ClassicalType::Bool; @@ -949,10 +958,7 @@ makeBooleanUnary(const UnaryOperation operation, 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"); - } + countExpressionNode(nodeCount); auto result = std::make_unique(); result->kind = ExpressionKind::Binary; result->type = ClassicalType::Bool; @@ -974,10 +980,7 @@ constantBoolean(const std::unique_ptr& expression) { [[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"); - } + countExpressionNode(nodeCount); auto result = std::make_unique(); result->kind = expression.kind; result->type = expression.type; @@ -1060,13 +1063,9 @@ exportExpressionImpl(mlir::Value value, ExportState& state, mlir::Block& evaluationBlock, const size_t depth, size_t& nodeCount) { if (depth >= MAX_EXPORT_EXPRESSION_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"); + throwClassicalExpressionDepthError(); } + countExpressionNode(nodeCount); auto* operation = value.getDefiningOp(); if (operation == nullptr) { throw std::runtime_error( @@ -1123,46 +1122,36 @@ exportExpressionImpl(mlir::Value value, ExportState& state, 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()) { + if (!llvm::all_of(ifOp.getResultTypes(), [](const mlir::Type type) { + return type.isInteger(1); + })) { throw std::runtime_error( "Qiskit classical expressions support only Boolean scf.if " "results with an else branch"); } - const auto opResult = llvm::dyn_cast(value); - if (!opResult || opResult.getOwner() != operation) { - throw std::runtime_error( - "Qiskit classical expression does not refer to an scf.if result"); - } - const size_t resultIndex = opResult.getResultNumber(); + const size_t resultIndex = + llvm::cast(value).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 thenYield = llvm::cast(thenBlock.getTerminator()); + auto elseYield = llvm::cast(elseBlock.getTerminator()); auto condition = exportExpressionImpl( ifOp.getCondition(), state, *ifOp->getBlock(), depth + 1U, nodeCount); - std::unique_ptr thenValue; - std::unique_ptr elseValue; + auto thenValue = + exportExpressionImpl(thenYield.getOperand(resultIndex), state, + thenBlock, depth + 1U, nodeCount); + auto elseValue = + exportExpressionImpl(elseYield.getOperand(resultIndex), state, + elseBlock, depth + 1U, nodeCount); 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 (index != resultIndex) { + size_t siblingNodeCount = 0U; + static_cast(exportExpressionImpl(thenYield.getOperand(index), + state, thenBlock, depth + 1U, + siblingNodeCount)); + static_cast(exportExpressionImpl(elseYield.getOperand(index), + state, elseBlock, depth + 1U, + siblingNodeCount)); } } const auto validateBranch = [&](mlir::Block& branch) { @@ -1176,10 +1165,6 @@ exportExpressionImpl(mlir::Value value, ExportState& state, }; 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); @@ -1222,10 +1207,7 @@ exportExpressionImpl(mlir::Value value, ExportState& state, } else { result->left = exportExpressionImpl(cast.getIn(), state, evaluationBlock, depth + 1U, nodeCount); - if (++nodeCount > MAX_EXPORT_EXPRESSION_NODES) { - throw std::runtime_error( - "QC classical expression exceeds the size limit of 4096 nodes"); - } + countExpressionNode(nodeCount); auto zero = std::make_unique(); setExpressionType(*zero, cast.getIn().getType()); zero->kind = ExpressionKind::Value; @@ -1338,11 +1320,27 @@ exportExpressionImpl(mlir::Value value, ExportState& state, operation->getName().getStringRef().str()); } +void validateExpressionDepth(const Expression& expression, + const size_t depth = 0U) { + if (depth >= MAX_EXPORT_EXPRESSION_DEPTH) { + throwClassicalExpressionDepthError(); + } + if (expression.left) { + validateExpressionDepth(*expression.left, depth + 1U); + } + if (expression.right) { + validateExpressionDepth(*expression.right, depth + 1U); + } +} + [[nodiscard]] std::unique_ptr exportExpression(mlir::Value value, ExportState& state, mlir::Block& evaluationBlock) { size_t nodeCount = 0U; - return exportExpressionImpl(value, state, evaluationBlock, 0U, nodeCount); + auto result = + exportExpressionImpl(value, state, evaluationBlock, 0U, nodeCount); + validateExpressionDepth(*result); + return result; } [[nodiscard]] std::optional @@ -1397,11 +1395,7 @@ matchPackedRegister(mlir::Value value, ExportState& state, if (!load || shift >= bits.size() || bits[shift]) { return false; } - try { - bits[shift] = classicalBitIndex(load, state); - } catch (const std::runtime_error&) { - return false; - } + bits[shift] = classicalBitIndex(load, state); operations.insert(operation); return true; }; @@ -1454,8 +1448,7 @@ void validateClassicalSnapshot(const mlir::Value expression, continue; } if (visited.size() > MAX_EXPORT_EXPRESSION_NODES) { - throw std::runtime_error( - "QC classical expression exceeds the size limit of 4096 nodes"); + throwClassicalExpressionSizeError(); } auto* operation = value.getDefiningOp(); if (operation == nullptr) { @@ -1465,17 +1458,13 @@ void validateClassicalSnapshot(const mlir::Value expression, loads.push_back(load); continue; } - if (auto ifOp = llvm::dyn_cast(operation); - ifOp && ifOp.getNumResults() != 0U) { + if (auto ifOp = llvm::dyn_cast(operation)) { + const auto resultIndex = + llvm::cast(value).getResultNumber(); for (auto& region : ifOp->getRegions()) { - if (region.empty()) { - continue; - } - if (auto yield = llvm::dyn_cast( - region.front().getTerminator())) { - worklist.append(yield.getOperands().begin(), - yield.getOperands().end()); - } + auto yield = + llvm::cast(region.front().getTerminator()); + worklist.push_back(yield.getOperand(resultIndex)); } } worklist.append(operation->operand_begin(), operation->operand_end()); @@ -1629,10 +1618,6 @@ void validateClassicalSnapshot(const mlir::Value expression, [[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; } @@ -1700,10 +1685,14 @@ matchLoopParameterProjection(mlir::scf::ForOp loop) { return projection; } -[[nodiscard]] ExportedCircuit collectBlock(mlir::Block& block, - ExportState& state, - size_t controlFlowDepth, - bool topLevel); +[[nodiscard]] ExportedCircuit +collectBlock(mlir::Block& block, ExportState& state, size_t controlFlowDepth); + +void validateControlFlowDepth(const size_t controlFlowDepth) { + if (controlFlowDepth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { + throw std::runtime_error("QC control flow exceeds the nesting limit of 64"); + } +} [[nodiscard]] bool isFusableMeasurementStore(mlir::qc::MeasureOp measure, mlir::cbit::StoreOp store) { @@ -1737,18 +1726,16 @@ void validateExpressionBlock(mlir::Block& block, const ExportState& state) { [[nodiscard]] std::unique_ptr collectIf(mlir::scf::IfOp ifOp, ExportState& state, const size_t controlFlowDepth) { - if (controlFlowDepth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { - throw std::runtime_error("QC control flow exceeds the nesting limit of 64"); - } + validateControlFlowDepth(controlFlowDepth); 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, - controlFlowDepth + 1U, false)); + result->blocks.push_back( + collectBlock(ifOp.getThenRegion().front(), state, controlFlowDepth + 1U)); if (!ifOp.getElseRegion().empty()) { result->blocks.push_back(collectBlock(ifOp.getElseRegion().front(), state, - controlFlowDepth + 1U, false)); + controlFlowDepth + 1U)); } return result; } @@ -1760,9 +1747,7 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, 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"); - } + validateControlFlowDepth(controlFlowDepth); const auto lower = mlir::getConstantIntValue(loop.getLowerBound()); const auto upper = mlir::getConstantIntValue(loop.getUpperBound()); const auto step = mlir::getConstantIntValue(loop.getStep()); @@ -1798,17 +1783,12 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, state.parameters[projection->value] = *loopParameter; } } - auto body = - collectBlock(*loop.getBody(), state, controlFlowDepth + 1U, false); + auto body = collectBlock(*loop.getBody(), state, controlFlowDepth + 1U); if (projection && loopParameter && circuitUsesParameterName(body, loopParameterName)) { result->loop.parameter = *loopParameter; const auto count = rangeLength(*lower, *upper, *step); - if (count == 0U) { - result->loop.start = 0; - result->loop.stop = 0; - result->loop.step = 1; - } else { + if (count != 0U) { result->loop.start = checkedAffine(projection->multiplier, *lower, projection->offset, "scf.for induction start"); @@ -1835,9 +1815,7 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, [[nodiscard]] std::unique_ptr collectWhile(mlir::scf::WhileOp loop, ExportState& state, const size_t controlFlowDepth) { - if (controlFlowDepth >= MAX_EXPORT_CONTROL_FLOW_DEPTH) { - throw std::runtime_error("QC control flow exceeds the nesting limit of 64"); - } + validateControlFlowDepth(controlFlowDepth); auto& before = loop.getBefore().front(); auto& after = loop.getAfter().front(); auto condition = @@ -1855,8 +1833,7 @@ collectWhile(mlir::scf::WhileOp loop, ExportState& state, result->target = exportCondition(condition.getCondition(), state, before, *condition.getOperation()); validateExpressionBlock(before, state); - result->blocks.push_back( - collectBlock(after, state, controlFlowDepth + 1U, false)); + result->blocks.push_back(collectBlock(after, state, controlFlowDepth + 1U)); return result; } @@ -1867,9 +1844,7 @@ collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportState& state, 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"); - } + validateControlFlowDepth(controlFlowDepth); auto result = std::make_unique(); result->kind = ControlFlowKind::Switch; result->target = @@ -1890,18 +1865,18 @@ collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportState& state, result->switchCases.push_back({.labels = {static_cast(label)}}); result->blocks.push_back( collectBlock(switchOp.getCaseRegions()[index].front(), state, - controlFlowDepth + 1U, false)); + controlFlowDepth + 1U)); } result->switchCases.push_back({.isDefault = true}); result->blocks.push_back(collectBlock(switchOp.getDefaultRegion().front(), - state, controlFlowDepth + 1U, false)); + state, controlFlowDepth + 1U)); return result; } [[nodiscard]] ExportedCircuit collectBlock(mlir::Block& block, ExportState& state, - const size_t controlFlowDepth, - const bool topLevel) { + const size_t controlFlowDepth) { + const bool topLevel = controlFlowDepth == 0U; ExportedCircuit circuit; llvm::SmallVector deferredExpressions; for (auto& operation : block) { @@ -2178,7 +2153,7 @@ nb::object exportCircuit(const mlir::QCProgram& program, "target qubit count"); } collectResources(function, state, target); - auto circuit = collectBlock(function.getBody().front(), state, 0U, true); + auto circuit = collectBlock(function.getBody().front(), state, 0U); for (const auto& [reg, info] : state.classicalRegisterInfo) { if (info.initialization == mlir::cbit::Initialization::Zero) { continue; diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 9a1d395196..8fd1565698 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -611,7 +611,8 @@ packRegister(mlir::qc::QCProgramBuilder& builder, } const auto width = static_cast(reg.bits.size()); const auto type = builder.getIntegerType(width); - auto packed = integerConstant(builder, width, 0U); + llvm::SmallVector terms; + terms.reserve(reg.bits.size()); for (size_t index = 0; index < reg.bits.size(); ++index) { auto bit = castInteger( builder, @@ -622,9 +623,23 @@ packRegister(mlir::qc::QCProgramBuilder& builder, integerConstant(builder, width, index)) .getResult(); } - packed = mlir::arith::OrIOp::create(builder, packed, bit).getResult(); + terms.push_back(bit); } - return packed; + while (terms.size() > 1U) { + const auto reducedSize = (terms.size() + 1U) / 2U; + for (size_t index = 0U; index < reducedSize; ++index) { + const auto left = 2U * index; + if (left + 1U < terms.size()) { + terms[index] = + mlir::arith::OrIOp::create(builder, terms[left], terms[left + 1U]) + .getResult(); + } else { + terms[index] = terms[left]; + } + } + terms.resize(reducedSize); + } + return terms.front(); } [[nodiscard]] mlir::Value From dd1118fe8d60f9a1e314b23938ff4db53899a87a Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Mon, 24 Aug 2026 13:25:40 +0200 Subject: [PATCH 19/38] =?UTF-8?q?=E2=9C=85=20Cover=20structured=20Qiskit?= =?UTF-8?q?=20export=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercise 64-bit register packing, selected-result snapshot and size isolation, normalized depth, nested stores, expression captures, and empty projected loops while deduplicating MLIR test setup. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- test/python/test_mlir_qiskit_translation.py | 410 ++++++++++++-------- 1 file changed, 255 insertions(+), 155 deletions(-) diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index c4969e14a1..6b6c5c6e90 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -1077,9 +1077,10 @@ def test_control_flow_and_controlled_unitary_preserve_instruction_order() -> Non assert isinstance(body_operation.modifiers[0], ControlModifier) -def test_root_register_expression_and_nested_condition_preserve_captures() -> None: +@pytest.mark.parametrize("num_clbits", [3, 64]) +def test_root_register_expression_and_nested_condition_preserve_captures(num_clbits: int) -> None: """Keep a root register leaf and pack its nested block-local condition.""" - circuit = QuantumCircuit(1, 3) + circuit = QuantumCircuit(1, num_clbits) condition = expr.logic_and(expr.equal(circuit.cregs[0], 5), circuit.clbits[0]) with circuit.if_test(condition), circuit.if_test((circuit.cregs[0], 2)): circuit.x(0) @@ -1097,27 +1098,22 @@ def test_root_register_expression_and_nested_condition_preserve_captures() -> No 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 {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %classical = cbit.alloc(#cbit.init) {mqt.register_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> - } -} -""" + program = _single_qubit_program( + [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_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", + "}", + ], + returns_classical=True, ) restored = program.to_qiskit() @@ -1149,8 +1145,8 @@ def test_nested_if_while_switch_preserve_capture_identity() -> None: 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, + circuit.while_loop(expr.logic_not(circuit.clbits[0]), None, None, None, label=None), + circuit.switch(expr.bit_xor(circuit.cregs[0], 1), None, None, None, label=None) as case, ): with case(0): circuit.x(0) @@ -1164,10 +1160,12 @@ def test_nested_if_while_switch_preserve_capture_identity() -> None: 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] + assert isinstance(while_instruction.operation.condition, expr.Expr) 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" + assert isinstance(switch_instruction.operation.target, expr.Expr) def test_empty_if_else_branches_round_trip() -> None: @@ -1283,6 +1281,26 @@ def test_for_loop_affine_projection_checks_the_fused_result() -> None: assert loop.blocks[0].data[0].operation.params[0].uuid == loop.params[1].uuid +def test_empty_for_loop_ignores_unrepresentable_projection() -> None: + """Keep an empty loop even when its unused projection has a zero step.""" + program = _single_qubit_program([ + "%lower = arith.constant 1 : index", + "%upper = arith.constant 0 : index", + "%step = arith.constant 1 : index", + "%zero = arith.constant 0 : i64", + "scf.for %iteration = %lower to %upper step %step {", + " %integer = arith.index_cast %iteration : index to i64", + " %scaled = arith.muli %integer, %zero : i64", + " %parameter = arith.sitofp %scaled : i64 to f64", + " qc.rz(%parameter) %q : !qc.qubit", + "}", + ]) + + loop = program.to_qiskit().data[0].operation + + assert list(loop.params[0]) == [] + + def test_nested_for_loop_induction_values_remain_lexically_scoped() -> None: """Keep nested induction variables distinct while retaining outer captures.""" circuit = QuantumCircuit(1) @@ -1328,25 +1346,17 @@ def test_generated_loop_parameter_name_avoids_free_symbol_collision() -> None: ) def test_dead_for_loop_parameter_projection_is_ignored(dead_use: str) -> None: """Omit a loop symbol whose projection has no emitted parameter use.""" - program = QCProgram.from_mlir_str( - f"""module {{ - func.func @main() attributes {{mqt.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 - %parameter = arith.sitofp %integer : i64 to f64 - {dead_use} - qc.x %q : !qc.qubit - }} - qc.dealloc %q : !qc.qubit - return - }} -}} -""" - ) + program = _single_qubit_program([ + "%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", + " %parameter = arith.sitofp %integer : i64 to f64", + f" {dead_use}", + " qc.x %q : !qc.qubit", + "}", + ]) restored = program.to_qiskit() @@ -1358,27 +1368,22 @@ def test_dead_for_loop_parameter_projection_is_ignored(dead_use: str) -> None: 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 {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %classical = cbit.alloc(#cbit.init) {mqt.register_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> - } -} -""" + program = _single_qubit_program( + [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_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", + "}", + ], + returns_classical=True, ) with pytest.raises(RuntimeError, match="case label 2 does not fit the 1-bit target"): program.to_qiskit() @@ -1386,26 +1391,18 @@ def test_switch_case_label_width_is_preflighted() -> None: def test_constant_index_switch_exports() -> None: """Lift a direct constant index selector into a Qiskit Uint expression.""" - program = QCProgram.from_mlir_str( - """module { - func.func @main() attributes {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %selector = arith.constant 0 : index - scf.index_switch %selector - case 0 { - qc.x %q : !qc.qubit - scf.yield - } - default { - qc.z %q : !qc.qubit - scf.yield - } - qc.dealloc %q : !qc.qubit - return - } -} -""" - ) + program = _single_qubit_program([ + "%selector = arith.constant 0 : index", + "scf.index_switch %selector", + "case 0 {", + " qc.x %q : !qc.qubit", + " scf.yield", + "}", + "default {", + " qc.z %q : !qc.qubit", + " scf.yield", + "}", + ]) restored = program.to_qiskit() switch = restored.data[0].operation @@ -1458,52 +1455,110 @@ def test_classical_snapshot_walk_is_bounded() -> None: program.to_qiskit() -def test_result_bearing_control_flow_is_rejected() -> None: - """Reject unsupported control-flow SSA results.""" - program = QCProgram.from_mlir_str( - """module { - func.func @main() attributes {mqt.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 - } -} -""" - ) +def test_export_expression_depth_is_bounded() -> None: + """Reject a classical expression deeper than 64 levels during export.""" + operations = [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1>', + "%zero = arith.constant 0 : index", + "%true = arith.constant true", + "%value0 = cbit.load %classical[%zero] : !cbit.reg<1>", + ] + operations.extend(f"%value{index} = arith.andi %value{index - 1}, %true : i1" for index in range(1, 65)) + operations.extend(["scf.if %value64 {", " qc.x %q : !qc.qubit", "}"]) + program = _single_qubit_program(operations, returns_classical=True) + + with pytest.raises(RuntimeError, match="classical expressions exceed the nesting limit of 64"): + program.to_qiskit() + + +def test_normalized_boolean_select_depth_is_bounded() -> None: + """Bound the final expression produced for a Boolean scf.if result.""" + operations = [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1>', + "%zero = arith.constant 0 : index", + "%true = arith.constant true", + "%value0 = cbit.load %classical[%zero] : !cbit.reg<1>", + ] + operations.extend(f"%value{index} = arith.andi %value{index - 1}, %true : i1" for index in range(1, 62)) + operations.extend([ + "%selected = scf.if %value61 -> (i1) {", + " %then = cbit.load %classical[%zero] : !cbit.reg<1>", + " scf.yield %then : i1", + "} else {", + " %else = cbit.load %classical[%zero] : !cbit.reg<1>", + " scf.yield %else : i1", + "}", + "scf.if %selected {", + " qc.x %q : !qc.qubit", + "}", + ]) + program = _single_qubit_program(operations, returns_classical=True) + + with pytest.raises(RuntimeError, match=r"^QC classical expressions exceed the nesting limit of 64"): + program.to_qiskit() + + +def test_export_control_flow_depth_is_bounded() -> None: + """Reject structured control flow deeper than 64 levels during export.""" + operations = ["%condition = arith.constant true"] + operations.extend(f"{' ' * depth}scf.if %condition {{" for depth in range(65)) + operations.append(f"{' ' * 65}qc.x %q : !qc.qubit") + operations.extend(f"{' ' * depth}}}" for depth in reversed(range(65))) + program = _single_qubit_program(operations) + + with pytest.raises(RuntimeError, match="control flow exceeds the nesting limit of 64"): + program.to_qiskit() + + +def test_nonboolean_result_bearing_if_is_rejected() -> None: + """Reject a result-bearing scf.if whose result is not Boolean.""" + program = _single_qubit_program([ + "%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", + ]) with pytest.raises(RuntimeError, match="does not support SSA results"): program.to_qiskit() -def test_stale_classical_snapshot_is_rejected() -> 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 {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %classical = cbit.alloc(#cbit.init) {mqt.register_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> - } -} -""" +@pytest.mark.parametrize( + "write_operations", + [ + ( + "%measured = qc.measure %q : !qc.qubit -> i1", + "cbit.store %measured, %classical[%zero] : !cbit.reg<1>", + ), + ( + "%always = arith.constant true", + "scf.if %always {", + " %measured = qc.measure %q : !qc.qubit -> i1", + " cbit.store %measured, %classical[%zero] : !cbit.reg<1>", + "}", + ), + ], + ids=["flat-write", "nested-write"], +) +def test_stale_classical_snapshot_is_rejected(write_operations: tuple[str, ...]) -> None: + """Reject a condition whose classical snapshot crosses a later write.""" + program = _single_qubit_program( + [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1>', + "%zero = arith.constant 0 : index", + "%stale = cbit.load %classical[%zero] : !cbit.reg<1>", + *write_operations, + "scf.if %stale {", + " qc.x %q : !qc.qubit", + "}", + ], + returns_classical=True, ) - with pytest.raises(RuntimeError, match="cannot preserve a stale classical snapshot"): + with pytest.raises(RuntimeError, match=r"cannot preserve a (?:stale )?classical snapshot"): program.to_qiskit() @@ -1579,6 +1634,67 @@ def test_multi_result_boolean_select_expressions_export() -> None: assert expr.structurally_equivalent(second, expected_second) +def test_unused_stale_boolean_select_result_does_not_block_export() -> None: + """Ignore stale snapshots belonging only to an unused sibling result.""" + program = _single_qubit_program( + [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1>', + "%zero = arith.constant 0 : index", + "%true = arith.constant true", + "%clean, %unused = scf.if %true -> (i1, i1) {", + " %false = arith.constant false", + " %stale = cbit.load %classical[%zero] : !cbit.reg<1>", + " scf.yield %false, %stale : i1, i1", + "} else {", + " %false = arith.constant false", + " %stale = cbit.load %classical[%zero] : !cbit.reg<1>", + " scf.yield %false, %stale : i1, i1", + "}", + "%measured = qc.measure %q : !qc.qubit -> i1", + "cbit.store %measured, %classical[%zero] : !cbit.reg<1>", + "scf.if %clean {", + " qc.x %q : !qc.qubit", + "}", + ], + returns_classical=True, + ) + + restored = program.to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == ["measure", "if_else"] + + +def test_unused_boolean_select_result_has_independent_size_budget() -> None: + """Validate an unused sibling result without charging its selected sibling.""" + operations = [ + '%classical = cbit.alloc(#cbit.init) {mqt.register_name = "c"} : !cbit.reg<1>', + "%zero = arith.constant 0 : index", + "%false = arith.constant false", + "%selector = cbit.load %classical[%zero] : !cbit.reg<1>", + "%conditions:2 = scf.if %selector -> (i1, i1) {", + " %then0 = cbit.load %classical[%zero] : !cbit.reg<1>", + ] + operations.extend(f" %then{index} = arith.andi %then{index - 1}, %then{index - 1} : i1" for index in range(1, 11)) + operations.extend([ + " scf.yield %false, %then10 : i1, i1", + "} else {", + " %else0 = cbit.load %classical[%zero] : !cbit.reg<1>", + ]) + operations.extend(f" %else{index} = arith.andi %else{index - 1}, %else{index - 1} : i1" for index in range(1, 11)) + operations.extend([ + " scf.yield %false, %else10 : i1, i1", + "}", + "scf.if %conditions#0 {", + " qc.x %q : !qc.qubit", + "}", + ]) + program = _single_qubit_program(operations, returns_classical=True) + + restored = program.to_qiskit() + + assert [instruction.operation.name for instruction in restored.data] == ["if_else"] + + def _undefined_cbit_program(operations: list[str]) -> QCProgram: """Build a one-qubit program with one undefined public CBit. @@ -1674,20 +1790,12 @@ def test_conditional_measurement_does_not_initialize_returned_cbit() -> None: ) 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 {{mqt.entry_point}} {{ - %q = qc.alloc : !qc.qubit - {expression} - scf.if %condition {{ - qc.x %q : !qc.qubit - }} - qc.dealloc %q : !qc.qubit - return - }} -}} -""" - ) + program = _single_qubit_program([ + *expression.splitlines(), + "scf.if %condition {", + " qc.x %q : !qc.qubit", + "}", + ]) source = program.ir with pytest.raises(RuntimeError, match=error): @@ -1767,21 +1875,13 @@ def test_index_expression_export_preserves_low_bit() -> None: def test_integer_truncation_exports_as_low_bit_index() -> None: """Preserve the low-bit semantics of a generic integer truncation.""" - program = QCProgram.from_mlir_str( - """module { - func.func @main() attributes {mqt.entry_point} { - %q = qc.alloc : !qc.qubit - %two = arith.constant 2 : i3 - %condition = arith.trunci %two : i3 to i1 - scf.if %condition { - qc.x %q : !qc.qubit - } - qc.dealloc %q : !qc.qubit - return - } -} -""" - ) + program = _single_qubit_program([ + "%two = arith.constant 2 : i3", + "%condition = arith.trunci %two : i3 to i1", + "scf.if %condition {", + " qc.x %q : !qc.qubit", + "}", + ]) restored = program.to_qiskit() restored_condition = restored.data[0].operation.condition From db5acce0cbcf29034fb620aa5a0c7d2083d1e08a Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Mon, 24 Aug 2026 13:25:48 +0200 Subject: [PATCH 20/38] =?UTF-8?q?=F0=9F=93=9D=20Condense=20the=20structure?= =?UTF-8?q?d=20export=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove obsolete restack and unrelated API-initialization chronology, then record the completed correctness, minimality, and validation pass. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- .../plans/qiskit-structured-control-export.md | 130 ++++++------------ 1 file changed, 39 insertions(+), 91 deletions(-) diff --git a/.agent/plans/qiskit-structured-control-export.md b/.agent/plans/qiskit-structured-control-export.md index 4178f163c4..4dacb3bc13 100644 --- a/.agent/plans/qiskit-structured-control-export.md +++ b/.agent/plans/qiskit-structured-control-export.md @@ -32,62 +32,20 @@ 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. -- [x] (2026-08-21 15:55Z) Restack the export-only commit onto the updated #2175 - head, port it to the closed name-keyed `Parameter` API and current MQT - metadata, fix the two include-cleanliness findings, rebuild the binding, - and pass all 204 translation tests. -- [x] (2026-08-21 16:08Z) Preserve low-bit semantics when exporting integer - truncation, accept direct constant-index switch selectors, add three - focused round-trip regressions, and pass all 207 translation tests. -- [x] (2026-08-21 16:20Z) Complete the final scope and semantic reviews, pass - stub generation and repository lint, explicitly defer the complete - documentation build for this handoff, and prepare the two focused - implementation and documentation commits. -- [x] (2026-08-21 16:28Z) Restack both commits onto #2175's final - classical-expression node-bound fix, confirm the export patches remain - equivalent, rebuild the binding, and pass all 208 translation tests. -- [x] (2026-08-22 06:46Z) Merge the updated `main` after #2175 landed as a - squash commit, retain its finalized importer and minimized tests, remove - the duplicated pre-squash parent coverage, rebuild the binding, and pass - repository lint. +- [x] (2026-08-19 15:43Z) Inspect the existing CBit, scalar-parameter, and + expression-capture foundations; add the version-neutral writer interface, + deferred Qiskit 2.5 writer, recursive preflight and emission, focused + tests, and public support documentation. +- [x] (2026-08-21 16:28Z) Adapt the exporter to the finalized name-keyed + `Parameter` and expression-capture APIs; preserve low-bit truncation and + constant-index switch semantics; pass the release build, stub generation, + translation tests, and repository lint. +- [x] (2026-08-22 06:46Z) Merge the updated `main`, retain its finalized + importer and minimized tests, and remove duplicated parent coverage. - [x] (2026-08-22 06:59Z) Bound speculative packed-register matching, replace recursive classical-snapshot discovery with a bounded worklist, and omit loop parameter metadata when the projected value reaches no emitted parameter expression; pass the three focused regressions. -- [x] (2026-08-22 07:01Z) Rebuild the release binding, pass all 211 Qiskit - translation tests against that exact build, and pass focused format and - static checks; repository lint reformatted the plan and is ready for its - final clean rerun. -- [x] (2026-08-22 07:04Z) Pass the final clean repository lint run and an - independent review with no remaining actionable findings; the audit fix is - ready to commit and push. - [x] (2026-08-22 07:50Z) Apply the final complexity pass by sharing exporter parameter state and native Qiskit symbols, replacing deferred insertion bookkeeping with in-place placeholders, reducing repeated round trips and @@ -101,6 +59,12 @@ follow-up with its own ExecPlan and branch. preserve fused affine overflow handling, rebuild the exact release binding, pass all 210 translation tests, regenerate unchanged stubs, and pass focused format and static checks. +- [x] (2026-08-24 11:24Z) Complete the critical correctness and minimality pass: + balance packed-register import, isolate per-result validation budgets and + consumed-result snapshots, preflight normalized depth, and simplify + adapter and test code. Rebuild the release binding, pass all 218 + translation tests, regenerate unchanged stubs, and pass repository lint; + keep the complete documentation build explicitly deferred. ## Surprises & Discoveries @@ -143,12 +107,6 @@ follow-up with its own ExecPlan and branch. exporting that truncation as a cast reverses the result for values such as binary `010`. -- Observation: Merging a stacked branch after its parent landed as a squash can - retain both the old and finalized parent tests without a textual conflict. - Evidence: the first merged Python diff contained 1,123 changed lines instead - of the export commit's 803; rebuilding it from `main` plus the export-only - patch restored the expected delta and kept the finalized #2175 cases. - - Observation: The packed-register recognizer and snapshot validator ran before the bounded classical-expression exporter. Evidence: a shared zero-valued `arith.ori` DAG caused exponential speculative matching, while a long SSA @@ -182,6 +140,12 @@ follow-up with its own ExecPlan and branch. `-2` by `INT64_MAX` overflows `i64`, but adding `INT64_MAX` produces the valid final value `-INT64_MAX`; the 128-bit calculation preserves that case. +- Observation: The expression limits were not enforced uniformly at semantic + boundaries. Evidence: linear packing made a 64-bit imported register exceed + MLIR nesting limits, unused sibling results shared node and snapshot state, + and a normalized Boolean select could exceed the depth limit only after writer + allocation. + ## Decision Log - Decision: Change only `CircuitWriter`'s output interface and leave all reader @@ -238,12 +202,6 @@ follow-up with its own ExecPlan and branch. valid structured selectors without treating truncation as truthiness. Date/Author: 2026-08-21 / Codex. -- Decision: Treat the squash-merged `main` tree as authoritative for #2175 and - replay only the two structured-export commits while resolving the merge. - Rationale: this preserves the reviewed importer refactors and streamlined - parent coverage without changing #2176's scope. Date/Author: 2026-08-22 / - Codex. - - Decision: Give speculative packed-register matching the same depth and node budgets as expression export, use an iterative bounded snapshot walk, and add loop metadata only when the generated symbol appears in an emitted body @@ -279,6 +237,13 @@ follow-up with its own ExecPlan and branch. boundary; the removed code duplicated validated internal state. Date/Author: 2026-08-24 / Codex. +- Decision: Balance imported packed-register expressions, validate each result's + nodes independently, follow snapshots only through the consumed result, and + validate the final normalized expression before writer allocation. Rationale: + the advertised 64-level and 4,096-node bounds must accept valid 64-bit + registers and reject only the consumed expression, with the same limits on + both sides of normalization. Date/Author: 2026-08-24 / Codex. + ## Outcomes & Retrospective Structured Qiskit control flow now exports recursively through a normalized, @@ -450,15 +415,10 @@ 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. ## Artifacts and Notes -The starting commit already passes captured-expression import tests and uses -unique symbol names in closed `Parameter` trees. The old combined implementation -is a design reference only. The final commit boundary is: +The change is intentionally limited to this commit boundary: structured export: interface + recursive collector + deferred writer + tests + support documentation + this plan @@ -479,24 +439,12 @@ 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. -Updated it again after #2175 changed the scalar representation and metadata -contract; only the export-specific delta was replayed. Recorded the final -low-bit and constant-index corrections together with the required stubs, lint, -deferred documentation build, and 208-test validation after the last parent -update. Recorded the squash-merge resolution and the bounded-preflight and dead -loop-parameter fixes found during the post-merge audit. Recorded the final -complexity pass that shares parameter state, replaces deferred insertion with -native placeholders, makes the all-root-bit invariant explicit, and reduces -repeated test and documentation work; recorded the successful build, unchanged -stubs, clean lint, and 208-test Qiskit 2.5.0-2.5.2 matrix. Merged `main` through -`84ace8ef2`, preserved its QCO switch, deterministic finalization, -loop-unrolling, and nanobind 3 changes, and made the vendored Qiskit C API -initialization safe for the new free-threaded binding mode. The split-mode -binding build and all 209 Qiskit 2.5.2 translation tests passed. Applied the -post-review simplification without removing documented register and Boolean -expression behavior, retained fused affine overflow semantics after an -independent audit, and passed all 210 Qiskit 2.5.2 translation tests. +Revision note: Created this self-contained plan for structured-control export +and kept it current as the scalar/capture foundation, bounded preflight, native +placeholder writer, and minimized coverage stabilized. On 2026-08-24, condensed +obsolete integration chronology and recorded the current correctness and +minimality pass: balanced register packing, per-result node and consumed-result +snapshot validation, final normalized-depth preflight, and smaller adapter and +test code. The exact release build, all 218 translation tests, unchanged stubs, +and repository lint passed; the complete documentation build remains explicitly +deferred. From 75e6c56f9b95a3464440400f0830ff4bdb58dc23 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Mon, 24 Aug 2026 13:27:27 +0200 Subject: [PATCH 21/38] =?UTF-8?q?=F0=9F=90=9B=20Initialize=20the=20Qiskit?= =?UTF-8?q?=20C=20API=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish the vendored translation-unit-local function tables through thread-safe static initialization for nanobind 3 free-threaded bindings. Add concurrent import and export coverage. Assisted-by: GPT-5.6 Sol via Codex Signed-off-by: Simon Hofmann --- bindings/mlir/qiskit/Qiskit2_5.cpp | 18 ++++++++++-------- test/python/test_mlir_qiskit_translation.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index c45255ee4c..c803ae7c97 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -1903,16 +1903,18 @@ class NativeTranslation final : public VersionedTranslation { std::unique_ptr MQT_QISKIT_VERSION_FACTORY() { // NOLINT(misc-use-internal-linkage): declared in // the version registry. - if (qk_import() < 0) { - throwPythonError("failed to initialize the Qiskit " MQT_QISKIT_VERSION_LABEL - " C API"); - } - const auto version = qk_api_version(); - const auto major = (version >> 24U) & 0xffU; - const auto minor = (version >> 16U) & 0xffU; + static const auto VERSION = []() { + if (qk_import() < 0) { + throwPythonError( + "failed to initialize the Qiskit " MQT_QISKIT_VERSION_LABEL " C API"); + } + return qk_api_version(); + }(); + const auto major = (VERSION >> 24U) & 0xffU; + const auto minor = (VERSION >> 16U) & 0xffU; if (major != MQT_QISKIT_VERSION_EXPECTED_MAJOR || minor != MQT_QISKIT_VERSION_EXPECTED_MINOR || - (MQT_QISKIT_VERSION_EXACT_API != 0 && version != QISKIT_VERSION_HEX)) { + (MQT_QISKIT_VERSION_EXACT_API != 0 && VERSION != QISKIT_VERSION_HEX)) { throw std::runtime_error("Qiskit C API capsule version does not match the " "selected " MQT_QISKIT_VERSION_LABEL " translation"); diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index d9ac7569f8..02cad0652d 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -14,6 +14,7 @@ import re import subprocess import sys +from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING import numpy as np @@ -105,6 +106,18 @@ ) +def test_native_api_initialization_supports_concurrent_translation() -> None: + """Initialize and reuse the native Qiskit API from concurrent translations.""" + + def _round_trip(_: int) -> str: + circuit = QuantumCircuit(1) + circuit.x(0) + return QCProgram.from_qiskit(circuit).to_qiskit().data[0].operation.name + + with ThreadPoolExecutor(max_workers=8) as executor: + assert list(executor.map(_round_trip, range(32))) == ["x"] * 32 + + @pytest.mark.parametrize("gate", STANDARD_GATES, ids=lambda gate: gate.name) def test_standard_gates_round_trip(gate: Gate) -> None: """Translate each supported gate family in both directions.""" From 657ea4e18b0040ea86e1962edeff70e02db57f7b Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 12:57:53 +0200 Subject: [PATCH 22/38] =?UTF-8?q?=F0=9F=90=9B=20Export=20forwarded=20measu?= =?UTF-8?q?rement=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/QiskitExport.cpp | 17 +++++++++++---- test/python/test_mlir_qiskit_translation.py | 24 +++++++++++++++++++++ 2 files changed, 37 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)) { diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 6b6c5c6e90..3a267df300 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -549,6 +549,30 @@ 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} + condition = restored.data[2].operation.condition + assert isinstance(condition, expr.Expr) + assert expr.structurally_equivalent(condition, expr.logic_and(*restored.clbits)) + + def test_openqasm3_measurement_export_uses_undefined_cbit_register() -> None: """Represent OpenQASM 3 output initialization without poison values.""" program = QCProgram.from_qasm_str( From 9c7c7f74f676ea863689ac05be372fdc545e5e94 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 12:58:11 +0200 Subject: [PATCH 23/38] =?UTF-8?q?=F0=9F=93=9D=20Document=20forwarded=20mea?= =?UTF-8?q?surement=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mlir/python_compiler_collection.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 890c1b95d541dc9893becae5e72be46337ece864 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 13:10:33 +0200 Subject: [PATCH 24/38] =?UTF-8?q?=E2=9C=A8=20Preserve=20parameter-vector?= =?UTF-8?q?=20provenance=20in=20MLIR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/QiskitExport.cpp | 63 +++++++++++++++++- bindings/mlir/qiskit/QiskitImport.cpp | 66 +++++++++++++++++-- bindings/mlir/qiskit/QiskitTranslation.h | 17 ++++- .../include/mlir/Dialect/MQT/IR/MQTDialect.td | 3 + mlir/lib/Dialect/MQT/IR/MQTDialect.cpp | 57 ++++++++++++++-- 5 files changed, 193 insertions(+), 13 deletions(-) diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 8b5f6a75f8..970d563b30 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -421,6 +422,8 @@ void validateExportParameters(const ExportedCircuit& circuit, } void collectParameters(mlir::func::FuncOp function, ExportState& state) { + llvm::StringMap groups; + uint64_t totalParameterGroupSize = 0U; for (const auto [index, argument] : llvm::enumerate(function.getArguments())) { const auto name = function.getArgAttrOfType( @@ -438,7 +441,65 @@ void collectParameters(mlir::func::FuncOp function, ExportState& state) { throw std::runtime_error( "Qiskit circuit export requires unique parameter names"); } - auto parameter = Parameter::symbol(name.str()); + + const auto groupAttribute = function.getArgAttr( + index, mlir::mqt::MQTDialect::InputGroupAttrHelper::getNameStr()); + std::optional group; + if (groupAttribute) { + const auto metadata = + llvm::dyn_cast(groupAttribute); + if (!metadata || metadata.size() != 4U) { + throw std::runtime_error( + "Qiskit circuit export requires complete and valid parameter " + "input-group metadata"); + } + const auto groupIdentity = metadata.getAs("identity"); + const auto groupName = metadata.getAs("name"); + const auto groupIndex = metadata.getAs("index"); + const auto groupSize = metadata.getAs("size"); + if (!groupIdentity || !groupName || !groupIndex || !groupSize || + groupIdentity.getValue().empty() || + groupIdentity.getValue().contains('\0') || + groupName.getValue().contains('\0') || + !groupIndex.getType().isInteger(64) || groupIndex.getInt() < 0 || + !groupSize.getType().isInteger(64) || groupSize.getInt() < 0) { + throw std::runtime_error( + "Qiskit circuit export requires complete and valid parameter " + "input-group metadata"); + } + group = ParameterGroup{ + .identity = groupIdentity.str(), + .name = groupName.str(), + .index = static_cast(groupIndex.getInt()), + .size = static_cast(groupSize.getInt()), + }; + if (group->size > MAX_PARAMETER_GROUP_SIZE) { + throw std::runtime_error("Qiskit parameter vectors support at most " + + std::to_string(MAX_PARAMETER_GROUP_SIZE) + + " elements"); + } + if (name.getValue() != + group->name + "[" + std::to_string(group->index) + "]") { + throw std::runtime_error( + "Qiskit parameter input name does not match its group and index"); + } + const auto [known, inserted] = + groups.try_emplace(group->identity, *group); + if (inserted) { + if (group->size > MAX_PARAMETER_GROUP_SIZE - totalParameterGroupSize) { + throw std::runtime_error( + "Qiskit circuit export supports at most " + + std::to_string(MAX_PARAMETER_GROUP_SIZE) + + " elements across all distinct parameter vectors"); + } + totalParameterGroupSize += group->size; + } else if (known->second.name != group->name || + known->second.size != group->size) { + throw std::runtime_error( + "one Qiskit parameter input group has conflicting metadata"); + } + } + auto parameter = Parameter::symbol(name.str(), std::move(group)); state.parameters[argument] = parameter; state.inputParameters.push_back(std::move(parameter)); } diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 8fd1565698..5043876d02 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -137,10 +137,20 @@ void validateParameterImpl(const Parameter& parameter, throw std::runtime_error( "Qiskit returned a parameter with invalid symbol metadata"); } - if (localParameters.contains(symbol->name)) { - return; - } - if (freeParameters.contains(symbol->name)) { + const auto validateKnownSymbol = [&](const ValidationParameters& known) { + const auto found = known.find(symbol->name); + if (found == known.end()) { + return false; + } + const auto* expected = found->second.getSymbol(); + if (expected == nullptr || expected->group != symbol->group) { + throw std::runtime_error("Qiskit parameter symbol '" + symbol->name + + "' has conflicting group metadata"); + } + return true; + }; + if (validateKnownSymbol(localParameters) || + validateKnownSymbol(freeParameters)) { return; } throw std::runtime_error("Qiskit parameter symbol '" + symbol->name + @@ -1882,6 +1892,8 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { const auto freeParameters = view->parameters(); ValidationParameters freeParameterSymbols; llvm::StringSet<> parameterNames; + llvm::StringMap parameterGroups; + uint64_t totalParameterGroupSize = 0U; for (const auto& parameter : freeParameters) { const auto* symbol = parameter.getSymbol(); if (symbol == nullptr || symbol->name.empty()) { @@ -1892,6 +1904,34 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { throw std::runtime_error( "Qiskit circuit contains distinct parameters with the same name"); } + if (symbol->group) { + const auto& group = *symbol->group; + if (group.size > MAX_PARAMETER_GROUP_SIZE) { + throw std::runtime_error("Qiskit parameter vectors support at most " + + std::to_string(MAX_PARAMETER_GROUP_SIZE) + + " elements"); + } + if (group.index > + static_cast(std::numeric_limits::max())) { + throw std::runtime_error( + "Qiskit parameter-vector index cannot be represented by MLIR"); + } + const auto [known, inserted] = + parameterGroups.try_emplace(group.identity, group); + if (inserted) { + if (group.size > MAX_PARAMETER_GROUP_SIZE - totalParameterGroupSize) { + throw std::runtime_error( + "Qiskit circuit import supports at most " + + std::to_string(MAX_PARAMETER_GROUP_SIZE) + + " elements across all distinct parameter vectors"); + } + totalParameterGroupSize += group.size; + } else if (known->second.name != group.name || + known->second.size != group.size) { + throw std::runtime_error( + "one Qiskit parameter input group has conflicting metadata"); + } + } freeParameterSymbols.try_emplace(symbol->name, parameter); } @@ -1938,10 +1978,26 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { throw std::runtime_error( "Qiskit circuit returned an invalid free parameter"); } - const llvm::SmallVector argumentAttributes{ + llvm::SmallVector argumentAttributes{ builder.getNamedAttr( mlir::mqt::MQTDialect::InputNameAttrHelper::getNameStr(), builder.getStringAttr(symbol->name))}; + if (symbol->group) { + argumentAttributes.push_back(builder.getNamedAttr( + mlir::mqt::MQTDialect::InputGroupAttrHelper::getNameStr(), + builder.getDictionaryAttr({ + builder.getNamedAttr( + "identity", builder.getStringAttr(symbol->group->identity)), + builder.getNamedAttr("name", + builder.getStringAttr(symbol->group->name)), + builder.getNamedAttr( + "index", builder.getI64IntegerAttr( + static_cast(symbol->group->index))), + builder.getNamedAttr( + "size", builder.getI64IntegerAttr( + static_cast(symbol->group->size))), + }))); + } const auto index = function.getNumArguments(); // MLIR types are handles. Converting FloatType to Type keeps the same // storage and does not slice object state. diff --git a/bindings/mlir/qiskit/QiskitTranslation.h b/bindings/mlir/qiskit/QiskitTranslation.h index c7bdc5490c..2d1f593296 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -52,6 +52,17 @@ validateRegisterLayout(const std::vector& registers, uint32_t total, inline constexpr size_t MAX_PARAMETER_EXPRESSION_DEPTH = 64U; inline constexpr size_t MAX_PARAMETER_EXPRESSION_NODES = 4096U; +inline constexpr uint64_t MAX_PARAMETER_GROUP_SIZE = 65'536U; + +/** Source-level vector metadata for one scalar parameter. */ +struct ParameterGroup { + std::string identity; + std::string name; + uint64_t index = 0U; + uint64_t size = 0U; + + [[nodiscard]] bool operator==(const ParameterGroup&) const = default; +}; enum class UnaryParameterKind : uint8_t { Negate, @@ -84,6 +95,7 @@ class Parameter { struct Symbol { std::string name; + std::optional group; }; struct Unary { @@ -103,8 +115,9 @@ class Parameter { return Parameter(Number{value}); } - [[nodiscard]] static Parameter symbol(std::string name) { - return Parameter(Symbol{std::move(name)}); + [[nodiscard]] static Parameter + symbol(std::string name, std::optional group = std::nullopt) { + return Parameter(Symbol{std::move(name), std::move(group)}); } [[nodiscard]] static Parameter unary(const UnaryParameterKind operation, diff --git a/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td b/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td index 5471ec3976..fc0466948e 100644 --- a/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td +++ b/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td @@ -21,6 +21,8 @@ def MQTDialect : Dialect { across quantum dialect conversions. It defines no operations or types. `mqt.input_name` records the source-level name of a function input. + `mqt.input_group` optionally preserves its source-level vector identity, + name, element index, and size. `mqt.register_name` records the source-level name of a quantum or classical register allocation. Input and register names share one function-wide namespace. @@ -29,6 +31,7 @@ def MQTDialect : Dialect { }]; let discardableAttrs = (ins "::mlir::StringAttr":$input_name, + "::mlir::DictionaryAttr":$input_group, "::mlir::StringAttr":$register_name, "::mlir::UnitAttr":$entry_point); let hasOperationAttrVerify = 1; diff --git a/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp b/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp index f1da715d93..21d33921b2 100644 --- a/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp +++ b/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp @@ -27,6 +27,8 @@ #include #include +#include + using namespace mlir; using namespace mlir::mqt; @@ -82,6 +84,43 @@ verifyEntryPoint(Operation* operation, const NamedAttribute attribute) { return success(); } +[[nodiscard]] static LogicalResult +verifyInputGroup(FunctionOpInterface function, Operation* operation, + const unsigned argIndex, const Attribute attribute) { + const auto inputName = function.getArgAttrOfType( + argIndex, MQTDialect::InputNameAttrHelper::getNameStr()); + const auto group = dyn_cast(attribute); + const auto identity = group ? group.getAs("identity") : nullptr; + const auto groupName = group ? group.getAs("name") : nullptr; + const auto groupIndex = group ? group.getAs("index") : nullptr; + const auto groupSize = group ? group.getAs("size") : nullptr; + if (!inputName || !group || group.size() != 4U || !identity || !groupName || + !groupIndex || !groupSize) { + return operation->emitError() + << "parameter input-group metadata must contain exactly identity, " + "name, index, and size"; + } + if (identity.getValue().empty() || identity.getValue().contains('\0') || + groupName.getValue().contains('\0')) { + return operation->emitError() + << "parameter input-group string metadata is invalid"; + } + if (!groupIndex.getType().isInteger(64) || + groupIndex.getValue().isNegative() || + !groupSize.getType().isInteger(64) || groupSize.getValue().isNegative()) { + return operation->emitError() + << "parameter input-group index and size must be nonnegative i64 " + "integers"; + } + const auto expectedName = + groupName.str() + "[" + std::to_string(groupIndex.getInt()) + "]"; + if (inputName.getValue() != expectedName) { + return operation->emitError() + << "parameter input name must match its group name and index"; + } + return success(); +} + [[nodiscard]] static bool isRegisterAllocation(Operation* operation) { if (isa(operation)) { return true; @@ -147,7 +186,8 @@ MQTDialect::verifyOperationAttribute(Operation* operation, if (attribute.getName() == RegisterNameAttrHelper::getNameStr()) { return verifyRegisterName(operation, attribute); } - if (attribute.getName() == InputNameAttrHelper::getNameStr()) { + if (attribute.getName() == InputNameAttrHelper::getNameStr() || + attribute.getName() == InputGroupAttrHelper::getNameStr()) { return operation->emitError() << "attribute '" << attribute.getName().getValue() << "' is only valid on a function argument"; @@ -159,14 +199,13 @@ MQTDialect::verifyOperationAttribute(Operation* operation, LogicalResult MQTDialect::verifyRegionArgAttribute( Operation* operation, const unsigned regionIndex, const unsigned argIndex, const NamedAttribute attribute) { - if (attribute.getName() != InputNameAttrHelper::getNameStr()) { + const auto attributeName = attribute.getName(); + if (attributeName != InputNameAttrHelper::getNameStr() && + attributeName != InputGroupAttrHelper::getNameStr()) { return operation->emitError() << "attribute '" << attribute.getName().getValue() << "' is not valid on a region argument"; } - if (failed(verifyName(operation, attribute))) { - return failure(); - } auto function = dyn_cast(operation); if (!function || regionIndex != 0) { @@ -175,6 +214,14 @@ LogicalResult MQTDialect::verifyRegionArgAttribute( << "' requires a function entry-block argument"; } + if (attributeName == InputGroupAttrHelper::getNameStr()) { + return verifyInputGroup(function, operation, argIndex, + attribute.getValue()); + } + if (failed(verifyName(operation, attribute))) { + return failure(); + } + const auto name = cast(attribute.getValue()); for (unsigned index = 0; index < function.getNumArguments(); ++index) { if (index == argIndex) { From 334adcd9eaae486b1b0728605853aef30526208d Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 13:10:51 +0200 Subject: [PATCH 25/38] =?UTF-8?q?=E2=9C=A8=20Restore=20Qiskit=20parameter?= =?UTF-8?q?=20vectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/Qiskit2_5.cpp | 115 +++++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 13 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 21d47562b5..798ad71913 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -226,14 +226,40 @@ normalizePythonParameterLeaf(const nb::handle parameter) { throw std::runtime_error( "Qiskit parameter names cannot contain null characters"); } - auto result = Parameter::symbol(std::move(name)); const auto vectorElement = nb::module_::import_("qiskit.circuit").attr("ParameterVectorElement"); - if (nb::isinstance(parameter, vectorElement)) { + if (!nb::isinstance(parameter, vectorElement)) { + return Parameter::symbol(std::move(name)); + } + + const auto vector = pythonAttribute( + parameter, "vector", "Qiskit parameter-vector element has no vector"); + auto groupName = pythonStringAttribute( + vector, "name", "Qiskit parameter vector has an invalid name"); + auto groupIdentity = + pythonText(pythonAttribute(vector, "uuid", + "Qiskit parameter vector has no identity"), + "Qiskit parameter vector has an invalid identity"); + const auto groupIndex = pythonUnsignedAttribute( + parameter, "index", + "Qiskit parameter-vector element has an invalid index"); + size_t groupSize = 0U; + try { + groupSize = nb::len(vector); + } catch (const nb::python_error& error) { + throwPythonError("Qiskit parameter vector has an invalid size", error); + } + if (groupIdentity.empty() || groupIdentity.find('\0') != std::string::npos || + groupName.find('\0') != std::string::npos || + name != groupName + "[" + std::to_string(groupIndex) + "]") { throw std::runtime_error( - "Qiskit parameter-vector elements are not supported"); + "Qiskit parameter-vector element has invalid group metadata"); } - return result; + return Parameter::symbol(std::move(name), + ParameterGroup{.identity = std::move(groupIdentity), + .name = std::move(groupName), + .index = groupIndex, + .size = groupSize}); } struct ParsedParameter { @@ -1189,13 +1215,13 @@ class NativeControlFlowReader final : public ControlFlowReader { break; case QkLoopParamKind_Parameter: { auto symbol = qk_control_flow_loop_symbol_info(controlFlow_); - if (symbol.ty != QkSymbolType_Standalone) { + if (symbol.ty != QkSymbolType_Standalone && + symbol.ty != QkSymbolType_Element) { if (symbol.name != nullptr) { qk_str_free(symbol.name); } throw std::runtime_error( - "Qiskit indexed parameter-vector loop variables are not " - "supported"); + "Qiskit for-loop parameter has an unknown symbol type"); } if (symbol.name == nullptr) { throwPythonError("Qiskit failed to read a loop-parameter name"); @@ -1215,9 +1241,15 @@ class NativeControlFlowReader final : public ControlFlowReader { if (parameterSymbol == nullptr) { throw std::runtime_error("Qiskit for-loop parameter is not a symbol"); } - if (parameterSymbol->name != nativeName) { + const auto nativeIsElement = symbol.ty == QkSymbolType_Element; + if ((!nativeIsElement && + (parameterSymbol->group || parameterSymbol->name != nativeName)) || + (nativeIsElement && + (!parameterSymbol->group || + parameterSymbol->group->name != nativeName || + parameterSymbol->group->index != symbol.index))) { throw std::runtime_error( - "Qiskit Python and native loop-parameter names do not match"); + "Qiskit Python and native loop-parameter metadata do not match"); } result.parameter = std::move(parameter); } catch (const nb::python_error& error) { @@ -1897,7 +1929,16 @@ class PythonClassicalBuilder final { nb::object typesModule_; }; -using NativeSymbolTable = std::unordered_map; +struct NativeSymbol { + NativeSymbol(const std::string_view name, + std::optional sourceGroup) + : group(std::move(sourceGroup)), parameter(name) {} + + std::optional group; + OwnedParameter parameter; +}; + +using NativeSymbolTable = std::unordered_map; class NativeCircuitWriter final : public CircuitWriter { public: @@ -2073,7 +2114,9 @@ class NativeCircuitWriter final : public CircuitWriter { } [[nodiscard]] nb::object finish() override { - return finishImpl(false, nb::none(), nb::none()); + auto circuit = finishImpl(false, nb::none(), nb::none()); + restoreParameterGroups(circuit, *symbols_); + return circuit; } private: @@ -2118,6 +2161,47 @@ class NativeCircuitWriter final : public CircuitWriter { std::vector> blockWriters; }; + static void restoreParameterGroups(const nb::handle circuit, + const NativeSymbolTable& symbols) { + if (!std::ranges::any_of(symbols, [](const auto& entry) { + return entry.second.group.has_value(); + })) { + return; + } + + try { + const auto circuitModule = nb::module_::import_("qiskit.circuit"); + const auto parameterVector = circuitModule.attr("ParameterVector"); + const auto parameterVectorElement = + circuitModule.attr("ParameterVectorElement"); + std::unordered_map groups; + nb::dict replacements; + const auto getParameter = + pythonAttribute(circuit, "get_parameter", + "Qiskit circuit cannot retrieve an output parameter"); + for (const auto& [name, symbol] : symbols) { + if (!symbol.group) { + continue; + } + const auto [group, inserted] = + groups.try_emplace(symbol.group->identity); + if (inserted) { + group->second = + parameterVector(symbol.group->name, symbol.group->size); + } + replacements[getParameter(name)] = + parameterVectorElement(group->second, symbol.group->index); + } + pythonAttribute(circuit, "assign_parameters", + "Qiskit circuit cannot replace output parameters")( + replacements, nb::arg("inplace") = true, + nb::arg("flat_input") = true); + } catch (const nb::python_error& error) { + throwPythonError("Qiskit failed to restore parameter-vector elements", + error); + } + } + void replacePendingControlledUnitaries(const nb::handle pythonCircuit) const { auto data = pythonAttribute(pythonCircuit, "data", "Qiskit circuit has no instruction data"); @@ -2308,8 +2392,13 @@ class NativeCircuitWriter final : public CircuitWriter { throw std::runtime_error( "cannot export a symbolic parameter without a name"); } - return symbols_->try_emplace(symbol->name, symbol->name) - .first->second.get(); + const auto [known, inserted] = + symbols_->try_emplace(symbol->name, symbol->name, symbol->group); + if (!inserted && known->second.group != symbol->group) { + throw std::runtime_error( + "one Qiskit parameter symbol has conflicting group metadata"); + } + return known->second.parameter.get(); } auto output = std::make_unique(); From 8b0adfc3756c5d2f3bea7d7676dd47129e66c061 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 13:11:07 +0200 Subject: [PATCH 26/38] =?UTF-8?q?=E2=9C=85=20Cover=20parameter-vector=20pr?= =?UTF-8?q?ovenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp | 32 +++- test/python/test_mlir_qiskit_translation.py | 138 ++++++++++++++++-- 2 files changed, 160 insertions(+), 10 deletions(-) diff --git a/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp b/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp index 6099633f98..71c2e0324e 100644 --- a/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp +++ b/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp @@ -56,7 +56,9 @@ class MQTIRTest : public ::testing::Test { TEST_F(MQTIRTest, AcceptsProgramInputAndRegisterNames) { EXPECT_TRUE(parse(R"mlir( module { - func.func @qc(%theta: f64 {mqt.input_name = "theta"}) { + func.func @qc(%theta: f64 {mqt.input_name = "theta[2]", + mqt.input_group = {identity = "group-id", name = "theta", + index = 2 : i64, size = 4 : i64}}) { %reg = memref.alloc() {mqt.register_name = "q"} : memref<2x!qc.qubit> return @@ -76,6 +78,11 @@ TEST_F(MQTIRTest, AcceptsProgramInputAndRegisterNames) { %reg = memref.alloc() {mqt.register_name = "lowered"} : memref<2xi1> return } + func.func @empty_vector_name(%element: f64 {mqt.input_name = "[0]", + mqt.input_group = {identity = "empty-name", name = "", + index = 0 : i64, size = 1 : i64}}) { + return + } } )mlir")); } @@ -161,6 +168,29 @@ TEST_F(MQTIRTest, RejectsDuplicateInputNames) { )mlir")); } +TEST_F(MQTIRTest, RejectsInvalidInputGroups) { + EXPECT_FALSE(parse(R"mlir( + module { + func.func @incomplete(%arg: f64 {mqt.input_name = "theta[0]", + mqt.input_group = {identity = "group"}}) { return } + } + )mlir")); + EXPECT_FALSE(parse(R"mlir( + module { + func.func @wrong_integer_type(%arg: f64 {mqt.input_name = "theta[0]", + mqt.input_group = {identity = "group", name = "theta", + index = 0 : i32, size = 1 : i64}}) { return } + } + )mlir")); + EXPECT_FALSE(parse(R"mlir( + module { + func.func @wrong_name(%arg: f64 {mqt.input_name = "phi[0]", + mqt.input_group = {identity = "group", name = "theta", + index = 0 : i64, size = 1 : i64}}) { return } + } + )mlir")); +} + TEST_F(MQTIRTest, RejectsInputNameOnOperation) { EXPECT_FALSE(parse(R"mlir( module { diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 3a267df300..5cb472ac79 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -37,6 +37,7 @@ ) from qiskit.circuit.classical import expr, types from qiskit.circuit.controlflow import CASE_DEFAULT, IfElseOp +from qiskit.circuit.parametervector import ParameterVectorElement from qiskit.quantum_info import Operator, random_unitary from mqt.core.mlir import CompilerTarget, QCProgram, compile_program @@ -2244,18 +2245,59 @@ def test_direct_symbolic_parameters_round_trip_with_shared_identity() -> None: ) -def test_parameter_vector_elements_fail_import_without_mutation() -> None: - """Reject vector elements until the provenance follow-up is applied.""" +def test_sparse_parameter_vector_round_trip_preserves_order_and_binding() -> None: + """Preserve a sparse vector's grouping, size, and numeric element order.""" + vector = ParameterVector("theta", 12) + circuit = QuantumCircuit(1, global_phase=vector[0]) + circuit.rx(vector[10] + vector[2], 0) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + parameters = list(restored.parameters) + assert all(isinstance(parameter, ParameterVectorElement) for parameter in parameters) + assert [parameter.index for parameter in parameters] == [0, 2, 10] + assert len({parameter.vector.uuid for parameter in parameters}) == 1 + restored_vector = parameters[0].vector + assert len(restored_vector) == len(vector) + values = [0.01 * index for index in range(12)] + assert Operator(restored.assign_parameters({restored_vector: values}, strict=False)).equiv( + Operator(circuit.assign_parameters({vector: values}, strict=False)) + ) + + +def test_parameter_vector_is_shared_across_sibling_blocks() -> None: + """Restore one vector for elements used in sibling control-flow blocks.""" vector = ParameterVector("theta", 2) + circuit = QuantumCircuit(1, 1) + with circuit.if_test((circuit.clbits[0], True)) as else_: + circuit.rx(vector[0], 0) + with else_: + circuit.ry(vector[1], 0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + parameters = [block.data[0].operation.params[0] for block in restored.data[0].operation.blocks] + assert [parameter.index for parameter in parameters] == [0, 1] + assert parameters[0].vector.uuid == parameters[1].vector.uuid + restored.assign_parameters({parameters[0].vector: [0.25, 0.5]}, inplace=True) + assert not restored.parameters + + +def test_distinct_parameter_vectors_with_the_same_name_remain_distinct() -> None: + """Keep opaque group identity when vector display names coincide.""" + first = ParameterVector("theta", 3) + second = ParameterVector("theta", 3) circuit = QuantumCircuit(1) - circuit.rx(vector[0], 0) - source_data = list(circuit.data) + circuit.rx(first[0], 0) + circuit.ry(second[2], 0) - with pytest.raises(RuntimeError, match="parameter-vector elements are not supported"): - QCProgram.from_qiskit(circuit) + restored = QCProgram.from_qiskit(circuit).to_qiskit() - assert list(circuit.data) == source_data - assert circuit.parameters == {vector[0]} + parameters = list(restored.parameters) + assert [parameter.index for parameter in parameters] == [0, 2] + assert parameters[0].vector.uuid != parameters[1].vector.uuid + assert Operator(restored.assign_parameters([0.2, 0.7])).equiv(Operator(circuit.assign_parameters([0.2, 0.7]))) def test_standalone_bracket_parameter_names_remain_standalone() -> None: @@ -2269,12 +2311,90 @@ def test_standalone_bracket_parameter_names_remain_standalone() -> None: program = QCProgram.from_qiskit(circuit) restored = program.to_qiskit() - assert "mqt.input_group" not in program.ir + assert all(not isinstance(parameter, ParameterVectorElement) for parameter in restored.parameters) 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))) +@pytest.mark.parametrize(("size", "index"), [(0, 0), (1, 1)]) +def test_out_of_range_parameter_vector_element_round_trip(size: int, index: int) -> None: + """Keep an element whose index is outside its recorded vector size.""" + vector = ParameterVector("theta", size) + parameter = ParameterVectorElement(vector, index) + circuit = QuantumCircuit(1) + circuit.rx(parameter, 0) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + restored_parameter = next(iter(restored.parameters)) + assert isinstance(restored_parameter, ParameterVectorElement) + assert restored_parameter.index == index + assert len(restored_parameter.vector) == size + + +def test_parameter_vector_element_is_valid_loop_parameter() -> None: + """Keep a vector-element loop symbol lexical rather than a free input.""" + iteration = ParameterVector("iteration", 4)[2] + body = QuantumCircuit(1) + body.rx(iteration, 0) + circuit = QuantumCircuit(1) + circuit.for_loop(range(3), iteration, body, [0], [], label=None) + + program = QCProgram.from_qiskit(circuit) + + assert "scf.for" in program.ir + assert "qc.rx" in program.ir + assert "mqt.input_group" not in program.ir + + +@pytest.mark.parametrize( + ("sizes", "message"), + [([65_537], "parameter vectors support at most"), ([32_769, 32_769], "across all distinct")], +) +def test_parameter_vector_size_limits_on_import(sizes: list[int], message: str) -> None: + """Bound individual and aggregate vector metadata before MLIR creation.""" + circuit = QuantumCircuit(1) + for index, size in enumerate(sizes): + circuit.rx(ParameterVector(f"theta{index}", size)[0], 0) + + with pytest.raises(RuntimeError, match=message): + QCProgram.from_qiskit(circuit) + + +@pytest.mark.parametrize( + ("sizes", "shared_group_id", "message"), + [ + ([65_537], None, "parameter vectors support at most"), + ([32_769, 32_769], None, "across all distinct"), + ([1, 2], 0, "conflicting metadata"), + ], +) +def test_parameter_vector_metadata_is_preflighted(sizes: list[int], shared_group_id: int | None, message: str) -> None: + """Validate vector consistency and resource bounds before allocation.""" + arguments = [] + gates = [] + for index, size in enumerate(sizes): + arguments.append( + f'%theta{index}: f64 {{mqt.input_name = "theta{index}[0]", ' + f'mqt.input_group = {{identity = "group{index if shared_group_id is None else shared_group_id}", ' + f'name = "theta{index}", index = 0 : i64, size = {size} : i64}}}}' + ) + gates.append(f" qc.rx(%theta{index}) %q : !qc.qubit") + program = QCProgram.from_mlir_str( + "module {\n" + f" func.func @main({', '.join(arguments)}) attributes {{mqt.entry_point}} {{\n" + " %q = qc.alloc : !qc.qubit\n" + "\n".join(gates) + "\n qc.dealloc %q : !qc.qubit\n" + " return\n" + " }\n" + "}\n" + ) + + with pytest.raises(RuntimeError, match=message): + program.to_qiskit() + + def _assign_parameter_values(circuit: QuantumCircuit, values: dict[str, float]) -> QuantumCircuit: """Bind a circuit using parameter names after an import/export round trip. From e270c9f094ca3d8a32e3a7021dfe862b2a162762 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 13:11:23 +0200 Subject: [PATCH 27/38] =?UTF-8?q?=F0=9F=93=9D=20Document=20Qiskit=20parame?= =?UTF-8?q?ter=20vectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 ++- docs/mlir/python_compiler_collection.md | 31 ++++++++++++++----------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 090ed4f2cf..d367911443 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], [#2150], [#2175], [#2176]) + collection ([#2031], [#2133], [#2140], [#2150], [#2175], [#2176], [#2178]) ([**@burgholzer**], [**@simon1hofmann**]) - ✨ Add conversions between `jeff` and QCO ([#1479], [#1548], [#1565], [#1637], [#1676], [#1706], [#1776], [#1836], [#1934], [#2000], [#2018], [#2105]) @@ -832,6 +832,7 @@ for previous changelogs._ [#2216]: https://github.com/munich-quantum-toolkit/core/pull/2216 [#2203]: https://github.com/munich-quantum-toolkit/core/pull/2203 [#2214]: https://github.com/munich-quantum-toolkit/core/pull/2214 +[#2178]: https://github.com/munich-quantum-toolkit/core/pull/2178 [#2176]: https://github.com/munich-quantum-toolkit/core/pull/2176 [#2175]: https://github.com/munich-quantum-toolkit/core/pull/2175 [#2169]: https://github.com/munich-quantum-toolkit/core/pull/2169 diff --git a/docs/mlir/python_compiler_collection.md b/docs/mlir/python_compiler_collection.md index e42c7c452a..67775b1374 100644 --- a/docs/mlir/python_compiler_collection.md +++ b/docs/mlir/python_compiler_collection.md @@ -175,7 +175,7 @@ flow, so export uses Qiskit's public Python classes for these operations. | 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 | +| Parameter-vector elements | Supported | Supported | | Dense numeric unitaries up to eight qubits | Supported | Supported | | Register aliases or interleaved membership | Rejected | Rejected | | Transpiler layout metadata | Accepted and ignored | Not emitted | @@ -185,19 +185,22 @@ 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 -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. +Free symbols become named {code}`f64` program inputs. Parameter-vector elements +retain their grouping and index, preserving vector order and positional binding +across a round trip; similarly named standalone parameters remain standalone. +Elements used in different structured-control blocks are restored into one +shared vector for the complete circuit tree. Free parameter vectors and their +combined declared size in one translated circuit are each limited to 65,536 +elements. 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. Structured-control export accepts result-free {code}`scf.if`, constant-range {code}`scf.for` without loop-carried values, expression-based {code}`scf.while` From fa3d2842d3a3148106b0991fffbd53835ec170fb Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 13:29:06 +0200 Subject: [PATCH 28/38] =?UTF-8?q?=F0=9F=8E=A8=20Initialize=20parameter=20s?= =?UTF-8?q?ymbols=20by=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/QiskitTranslation.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bindings/mlir/qiskit/QiskitTranslation.h b/bindings/mlir/qiskit/QiskitTranslation.h index 2d1f593296..d1be278f65 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -117,7 +117,8 @@ class Parameter { [[nodiscard]] static Parameter symbol(std::string name, std::optional group = std::nullopt) { - return Parameter(Symbol{std::move(name), std::move(group)}); + return Parameter( + Symbol{.name = std::move(name), .group = std::move(group)}); } [[nodiscard]] static Parameter unary(const UnaryParameterKind operation, From 36b8b3e85ce369e60ebf6ad15f9213b0da1ec6c5 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 14:18:32 +0200 Subject: [PATCH 29/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20parameter?= =?UTF-8?q?=20vector=20restoration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/Qiskit2_5.cpp | 5 +--- bindings/mlir/qiskit/QiskitExport.cpp | 5 ---- bindings/mlir/qiskit/QiskitImport.cpp | 9 ------- test/python/test_mlir_qiskit_translation.py | 28 +++------------------ 4 files changed, 5 insertions(+), 42 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 798ad71913..dd17aa7158 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -2172,8 +2172,6 @@ class NativeCircuitWriter final : public CircuitWriter { try { const auto circuitModule = nb::module_::import_("qiskit.circuit"); const auto parameterVector = circuitModule.attr("ParameterVector"); - const auto parameterVectorElement = - circuitModule.attr("ParameterVectorElement"); std::unordered_map groups; nb::dict replacements; const auto getParameter = @@ -2189,8 +2187,7 @@ class NativeCircuitWriter final : public CircuitWriter { group->second = parameterVector(symbol.group->name, symbol.group->size); } - replacements[getParameter(name)] = - parameterVectorElement(group->second, symbol.group->index); + replacements[getParameter(name)] = group->second[symbol.group->index]; } pythonAttribute(circuit, "assign_parameters", "Qiskit circuit cannot replace output parameters")( diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 970d563b30..c356579c96 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -473,11 +473,6 @@ void collectParameters(mlir::func::FuncOp function, ExportState& state) { .index = static_cast(groupIndex.getInt()), .size = static_cast(groupSize.getInt()), }; - if (group->size > MAX_PARAMETER_GROUP_SIZE) { - throw std::runtime_error("Qiskit parameter vectors support at most " + - std::to_string(MAX_PARAMETER_GROUP_SIZE) + - " elements"); - } if (name.getValue() != group->name + "[" + std::to_string(group->index) + "]") { throw std::runtime_error( diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 5043876d02..87fb09fc8b 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -1906,11 +1906,6 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { } if (symbol->group) { const auto& group = *symbol->group; - if (group.size > MAX_PARAMETER_GROUP_SIZE) { - throw std::runtime_error("Qiskit parameter vectors support at most " + - std::to_string(MAX_PARAMETER_GROUP_SIZE) + - " elements"); - } if (group.index > static_cast(std::numeric_limits::max())) { throw std::runtime_error( @@ -1974,10 +1969,6 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { GlobalParameters globalParameters; for (const auto& parameter : freeParameters) { const auto* symbol = parameter.getSymbol(); - if (symbol == nullptr) { - throw std::runtime_error( - "Qiskit circuit returned an invalid free parameter"); - } llvm::SmallVector argumentAttributes{ builder.getNamedAttr( mlir::mqt::MQTDialect::InputNameAttrHelper::getNameStr(), diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 5cb472ac79..3a581de602 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -2317,23 +2317,6 @@ def test_standalone_bracket_parameter_names_remain_standalone() -> None: assert Operator(restored.assign_parameters(values)).equiv(Operator(circuit.assign_parameters(values))) -@pytest.mark.parametrize(("size", "index"), [(0, 0), (1, 1)]) -def test_out_of_range_parameter_vector_element_round_trip(size: int, index: int) -> None: - """Keep an element whose index is outside its recorded vector size.""" - vector = ParameterVector("theta", size) - parameter = ParameterVectorElement(vector, index) - circuit = QuantumCircuit(1) - circuit.rx(parameter, 0) - - program = QCProgram.from_qiskit(circuit) - restored = program.to_qiskit() - - restored_parameter = next(iter(restored.parameters)) - assert isinstance(restored_parameter, ParameterVectorElement) - assert restored_parameter.index == index - assert len(restored_parameter.vector) == size - - def test_parameter_vector_element_is_valid_loop_parameter() -> None: """Keep a vector-element loop symbol lexical rather than a free input.""" iteration = ParameterVector("iteration", 4)[2] @@ -2349,24 +2332,21 @@ def test_parameter_vector_element_is_valid_loop_parameter() -> None: assert "mqt.input_group" not in program.ir -@pytest.mark.parametrize( - ("sizes", "message"), - [([65_537], "parameter vectors support at most"), ([32_769, 32_769], "across all distinct")], -) -def test_parameter_vector_size_limits_on_import(sizes: list[int], message: str) -> None: +@pytest.mark.parametrize("sizes", [[65_537], [32_769, 32_769]]) +def test_parameter_vector_size_limits_on_import(sizes: list[int]) -> None: """Bound individual and aggregate vector metadata before MLIR creation.""" circuit = QuantumCircuit(1) for index, size in enumerate(sizes): circuit.rx(ParameterVector(f"theta{index}", size)[0], 0) - with pytest.raises(RuntimeError, match=message): + with pytest.raises(RuntimeError, match="across all distinct"): QCProgram.from_qiskit(circuit) @pytest.mark.parametrize( ("sizes", "shared_group_id", "message"), [ - ([65_537], None, "parameter vectors support at most"), + ([65_537], None, "across all distinct"), ([32_769, 32_769], None, "across all distinct"), ([1, 2], 0, "conflicting metadata"), ], From 83c9724eeb6b32d08b3983fc91b4bd013c89f0be Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 14:19:23 +0200 Subject: [PATCH 30/38] =?UTF-8?q?=E2=9C=85=20Trim=20redundant=20parameter?= =?UTF-8?q?=20vector=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/python/test_mlir_qiskit_translation.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 3a581de602..fa15a09fd2 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -2251,13 +2251,10 @@ def test_sparse_parameter_vector_round_trip_preserves_order_and_binding() -> Non circuit = QuantumCircuit(1, global_phase=vector[0]) circuit.rx(vector[10] + vector[2], 0) - program = QCProgram.from_qiskit(circuit) - restored = program.to_qiskit() + restored = QCProgram.from_qiskit(circuit).to_qiskit() parameters = list(restored.parameters) - assert all(isinstance(parameter, ParameterVectorElement) for parameter in parameters) assert [parameter.index for parameter in parameters] == [0, 2, 10] - assert len({parameter.vector.uuid for parameter in parameters}) == 1 restored_vector = parameters[0].vector assert len(restored_vector) == len(vector) values = [0.01 * index for index in range(12)] @@ -2279,7 +2276,6 @@ def test_parameter_vector_is_shared_across_sibling_blocks() -> None: parameters = [block.data[0].operation.params[0] for block in restored.data[0].operation.blocks] assert [parameter.index for parameter in parameters] == [0, 1] - assert parameters[0].vector.uuid == parameters[1].vector.uuid restored.assign_parameters({parameters[0].vector: [0.25, 0.5]}, inplace=True) assert not restored.parameters @@ -2308,8 +2304,7 @@ def test_standalone_bracket_parameter_names_remain_standalone() -> None: circuit.rx(theta_ten, 0) circuit.ry(theta_two, 0) - program = QCProgram.from_qiskit(circuit) - restored = program.to_qiskit() + restored = QCProgram.from_qiskit(circuit).to_qiskit() assert all(not isinstance(parameter, ParameterVectorElement) for parameter in restored.parameters) assert {parameter.name for parameter in restored.parameters} == {"theta[2]", "theta[10]"} From 966e3c000b1a720b948c0a67c2aa26449e46c707 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 17:23:30 +0200 Subject: [PATCH 31/38] =?UTF-8?q?=F0=9F=90=9B=20Preserve=20parameter-vecto?= =?UTF-8?q?r=20loop=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/Qiskit2_5.cpp | 54 ++++++---- bindings/mlir/qiskit/QiskitExport.cpp | 102 ++++++++++-------- bindings/mlir/qiskit/QiskitImport.cpp | 41 ++++--- .../include/mlir/Dialect/MQT/IR/MQTDialect.td | 3 + mlir/lib/Conversion/QCOToQC/QCOToQC.cpp | 1 + mlir/lib/Conversion/QCToQCO/QCToQCO.cpp | 1 + mlir/lib/Dialect/MQT/IR/CMakeLists.txt | 1 + mlir/lib/Dialect/MQT/IR/MQTDialect.cpp | 42 ++++++-- test/python/test_mlir_qiskit_translation.py | 42 +++++++- 9 files changed, 196 insertions(+), 91 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index dd17aa7158..61a15c8242 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -1939,6 +1939,7 @@ struct NativeSymbol { }; using NativeSymbolTable = std::unordered_map; +using PythonParameterGroups = std::unordered_map; class NativeCircuitWriter final : public CircuitWriter { public: @@ -2114,15 +2115,15 @@ class NativeCircuitWriter final : public CircuitWriter { } [[nodiscard]] nb::object finish() override { - auto circuit = finishImpl(false, nb::none(), nb::none()); - restoreParameterGroups(circuit, *symbols_); - return circuit; + PythonParameterGroups groups; + return finishImpl(false, nb::none(), nb::none(), groups); } private: [[nodiscard]] nb::object finishImpl(const bool rebase, const nb::handle exactQubits, - const nb::handle exactClbits) { + const nb::handle exactClbits, + PythonParameterGroups& groups) { if (circuit_ == nullptr) { throw std::runtime_error( "Qiskit circuit writer has already been finalized"); @@ -2138,7 +2139,8 @@ class NativeCircuitWriter final : public CircuitWriter { pythonCircuit = rebaseCircuit(pythonCircuit, exactQubits, exactClbits); } replacePendingControlledUnitaries(pythonCircuit); - replacePendingControlFlow(pythonCircuit); + restoreParameterGroups(pythonCircuit, *symbols_, groups); + replacePendingControlFlow(pythonCircuit, groups); } catch (const nb::python_error& error) { throwPythonError("Qiskit failed to construct deferred instructions", error); @@ -2162,7 +2164,8 @@ class NativeCircuitWriter final : public CircuitWriter { }; static void restoreParameterGroups(const nb::handle circuit, - const NativeSymbolTable& symbols) { + const NativeSymbolTable& symbols, + PythonParameterGroups& groups) { if (!std::ranges::any_of(symbols, [](const auto& entry) { return entry.second.group.has_value(); })) { @@ -2172,22 +2175,28 @@ class NativeCircuitWriter final : public CircuitWriter { try { const auto circuitModule = nb::module_::import_("qiskit.circuit"); const auto parameterVector = circuitModule.attr("ParameterVector"); - std::unordered_map groups; + const auto parameterVectorElement = + circuitModule.attr("ParameterVectorElement"); nb::dict replacements; - const auto getParameter = - pythonAttribute(circuit, "get_parameter", - "Qiskit circuit cannot retrieve an output parameter"); - for (const auto& [name, symbol] : symbols) { - if (!symbol.group) { + const auto parameters = pythonAttribute( + circuit, "parameters", "Qiskit circuit has no parameter collection"); + for (const nb::handle parameter : nb::iter(parameters)) { + const auto name = pythonStringAttribute( + parameter, "name", "Qiskit circuit parameter has no name"); + const auto symbol = symbols.find(name); + if (symbol == symbols.end() || !symbol->second.group) { continue; } - const auto [group, inserted] = - groups.try_emplace(symbol.group->identity); + const auto& metadata = *symbol->second.group; + const auto [group, inserted] = groups.try_emplace(metadata.identity); if (inserted) { - group->second = - parameterVector(symbol.group->name, symbol.group->size); + group->second = parameterVector(metadata.name, metadata.size); } - replacements[getParameter(name)] = group->second[symbol.group->index]; + replacements[parameter] = + parameterVectorElement(group->second, metadata.index); + } + if (nb::len(replacements) == 0U) { + return; } pythonAttribute(circuit, "assign_parameters", "Qiskit circuit cannot replace output parameters")( @@ -2264,12 +2273,16 @@ class NativeCircuitWriter final : public CircuitWriter { throw std::runtime_error( "Qiskit for-loop parameter has invalid symbol metadata"); } + const auto parameterName = + symbol->group ? symbol->group->name + "[" + + std::to_string(symbol->group->index) + "]" + : symbol->name; const auto parameters = pythonAttribute( body, "parameters", "Qiskit circuit has no parameter collection"); for (const nb::handle parameter : nb::iter(parameters)) { if (pythonStringAttribute(parameter, "name", "Qiskit circuit parameter has no name") == - symbol->name) { + parameterName) { return nb::borrow(parameter); } } @@ -2320,7 +2333,8 @@ class NativeCircuitWriter final : public CircuitWriter { "Qiskit circuit export encountered an unsupported control-flow kind"); } - void replacePendingControlFlow(const nb::handle pythonCircuit) { + void replacePendingControlFlow(const nb::handle pythonCircuit, + PythonParameterGroups& groups) { auto data = pythonAttribute(pythonCircuit, "data", "Qiskit circuit has no instruction data"); const auto circuitQubits = pythonAttribute(pythonCircuit, "qubits", @@ -2344,7 +2358,7 @@ class NativeCircuitWriter final : public CircuitWriter { "Qiskit control-flow blocks use an incompatible writer"); } blocks.emplace_back( - writer->finishImpl(true, circuitQubits, circuitClbits)); + writer->finishImpl(true, circuitQubits, circuitClbits, groups)); } pending.blockWriters.clear(); auto operation = constructControlFlowOperation(pending, blocks, classical, diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index c356579c96..3827247e77 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -338,11 +338,59 @@ struct ExportState { ExportedParameters parameters; std::vector inputParameters; llvm::StringSet<> parameterNames; + llvm::StringMap parameterGroups; + uint64_t totalParameterGroupSize = 0U; size_t nextLoopParameter = 0U; uint32_t numQubits = 0; uint32_t numClbits = 0; }; +[[nodiscard]] ParameterGroup parameterGroup(const mlir::Attribute attribute) { + const auto metadata = llvm::dyn_cast(attribute); + if (!metadata || metadata.size() != 4U) { + throw std::runtime_error( + "Qiskit circuit export requires complete and valid parameter-group " + "metadata"); + } + const auto identity = metadata.getAs("identity"); + const auto name = metadata.getAs("name"); + const auto index = metadata.getAs("index"); + const auto size = metadata.getAs("size"); + if (!identity || !name || !index || !size || identity.getValue().empty() || + identity.getValue().contains('\0') || name.getValue().contains('\0') || + !index.getType().isInteger(64) || index.getInt() < 0 || + !size.getType().isInteger(64) || size.getInt() < 0) { + throw std::runtime_error( + "Qiskit circuit export requires complete and valid parameter-group " + "metadata"); + } + return { + .identity = identity.str(), + .name = name.str(), + .index = static_cast(index.getInt()), + .size = static_cast(size.getInt()), + }; +} + +void registerParameterGroup(ExportState& state, const ParameterGroup& group) { + const auto [known, inserted] = + state.parameterGroups.try_emplace(group.identity, group); + if (inserted) { + if (group.size > MAX_PARAMETER_GROUP_SIZE - state.totalParameterGroupSize) { + throw std::runtime_error( + "Qiskit circuit export supports at most " + + std::to_string(MAX_PARAMETER_GROUP_SIZE) + + " elements across all distinct parameter vectors"); + } + state.totalParameterGroupSize += group.size; + return; + } + if (known->second.name != group.name || known->second.size != group.size) { + throw std::runtime_error( + "one Qiskit parameter group has conflicting metadata"); + } +} + [[nodiscard]] bool parameterUsesName(const Parameter& parameter, const std::string_view name) { if (const auto* symbol = parameter.getSymbol()) { @@ -422,8 +470,6 @@ void validateExportParameters(const ExportedCircuit& circuit, } void collectParameters(mlir::func::FuncOp function, ExportState& state) { - llvm::StringMap groups; - uint64_t totalParameterGroupSize = 0U; for (const auto [index, argument] : llvm::enumerate(function.getArguments())) { const auto name = function.getArgAttrOfType( @@ -446,53 +492,13 @@ void collectParameters(mlir::func::FuncOp function, ExportState& state) { index, mlir::mqt::MQTDialect::InputGroupAttrHelper::getNameStr()); std::optional group; if (groupAttribute) { - const auto metadata = - llvm::dyn_cast(groupAttribute); - if (!metadata || metadata.size() != 4U) { - throw std::runtime_error( - "Qiskit circuit export requires complete and valid parameter " - "input-group metadata"); - } - const auto groupIdentity = metadata.getAs("identity"); - const auto groupName = metadata.getAs("name"); - const auto groupIndex = metadata.getAs("index"); - const auto groupSize = metadata.getAs("size"); - if (!groupIdentity || !groupName || !groupIndex || !groupSize || - groupIdentity.getValue().empty() || - groupIdentity.getValue().contains('\0') || - groupName.getValue().contains('\0') || - !groupIndex.getType().isInteger(64) || groupIndex.getInt() < 0 || - !groupSize.getType().isInteger(64) || groupSize.getInt() < 0) { - throw std::runtime_error( - "Qiskit circuit export requires complete and valid parameter " - "input-group metadata"); - } - group = ParameterGroup{ - .identity = groupIdentity.str(), - .name = groupName.str(), - .index = static_cast(groupIndex.getInt()), - .size = static_cast(groupSize.getInt()), - }; + group = parameterGroup(groupAttribute); if (name.getValue() != group->name + "[" + std::to_string(group->index) + "]") { throw std::runtime_error( "Qiskit parameter input name does not match its group and index"); } - const auto [known, inserted] = - groups.try_emplace(group->identity, *group); - if (inserted) { - if (group->size > MAX_PARAMETER_GROUP_SIZE - totalParameterGroupSize) { - throw std::runtime_error( - "Qiskit circuit export supports at most " + - std::to_string(MAX_PARAMETER_GROUP_SIZE) + - " elements across all distinct parameter vectors"); - } - totalParameterGroupSize += group->size; - } else if (known->second.name != group->name || - known->second.size != group->size) { - throw std::runtime_error( - "one Qiskit parameter input group has conflicting metadata"); - } + registerParameterGroup(state, *group); } auto parameter = Parameter::symbol(name.str(), std::move(group)); state.parameters[argument] = parameter; @@ -1822,6 +1828,12 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, result->kind = ControlFlowKind::For; result->loop = { .isRange = true, .start = *lower, .stop = *upper, .step = *step}; + std::optional sourceGroup; + if (const auto attribute = loop->getAttr( + mlir::mqt::MQTDialect::LoopParameterGroupAttrHelper::getNameStr())) { + sourceGroup = parameterGroup(attribute); + registerParameterGroup(state, *sourceGroup); + } std::optional projection; std::optional loopParameter; std::string loopParameterName; @@ -1841,7 +1853,7 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, loopParameterName = "_mqt_loop_" + std::to_string(identity); } while (state.parameterNames.contains(loopParameterName)); state.parameterNames.insert(loopParameterName); - loopParameter = Parameter::symbol(loopParameterName); + loopParameter = Parameter::symbol(loopParameterName, sourceGroup); state.parameters[projection->value] = *loopParameter; } } diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 87fb09fc8b..029dd4b182 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -88,6 +88,24 @@ constexpr size_t MAX_EXPANDED_OPERATIONS = 10'000'000U; [[nodiscard]] mlir::Value floatConstant(mlir::ImplicitLocOpBuilder& builder, double value); +[[nodiscard]] mlir::DictionaryAttr +parameterGroupAttribute(mlir::Builder& builder, const ParameterGroup& group) { + if (group.index > + static_cast(std::numeric_limits::max()) || + group.size > static_cast(std::numeric_limits::max())) { + throw std::runtime_error( + "Qiskit parameter-vector metadata cannot be represented by MLIR"); + } + return builder.getDictionaryAttr({ + builder.getNamedAttr("identity", builder.getStringAttr(group.identity)), + builder.getNamedAttr("name", builder.getStringAttr(group.name)), + builder.getNamedAttr("index", builder.getI64IntegerAttr( + static_cast(group.index))), + builder.getNamedAttr( + "size", builder.getI64IntegerAttr(static_cast(group.size))), + }); +} + [[noreturn]] void throwImportedParameterExpressionSizeError() { throw std::runtime_error( "Qiskit parameter expression exceeds the supported " + @@ -1129,6 +1147,7 @@ void translateControlFlow(mlir::qc::QCProgramBuilder& builder, requireExactLoopParameter(loop.start); requireExactLoopParameter(loop.step > 0 ? loop.stop - 1 : loop.stop + 1); } + auto* const containingBlock = builder.getInsertionBlock(); builder.scfFor(0, count, 1, [&](const mlir::Value iteration) { auto parameters = localParameters; if (loop.parameter) { @@ -1140,6 +1159,15 @@ void translateControlFlow(mlir::qc::QCProgramBuilder& builder, } translateBlock(*body, parameters); }); + if (loop.parameter) { + const auto* symbol = loop.parameter->getSymbol(); + if (symbol != nullptr && symbol->group) { + mlir::cast(&containingBlock->back()) + ->setAttr(mlir::mqt::MQTDialect::LoopParameterGroupAttrHelper:: + getNameStr(), + parameterGroupAttribute(builder, *symbol->group)); + } + } return; } case ControlFlowKind::Switch: { @@ -1976,18 +2004,7 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { if (symbol->group) { argumentAttributes.push_back(builder.getNamedAttr( mlir::mqt::MQTDialect::InputGroupAttrHelper::getNameStr(), - builder.getDictionaryAttr({ - builder.getNamedAttr( - "identity", builder.getStringAttr(symbol->group->identity)), - builder.getNamedAttr("name", - builder.getStringAttr(symbol->group->name)), - builder.getNamedAttr( - "index", builder.getI64IntegerAttr( - static_cast(symbol->group->index))), - builder.getNamedAttr( - "size", builder.getI64IntegerAttr( - static_cast(symbol->group->size))), - }))); + parameterGroupAttribute(builder, *symbol->group))); } const auto index = function.getNumArguments(); // MLIR types are handles. Converting FloatType to Type keeps the same diff --git a/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td b/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td index fc0466948e..ec34f706e6 100644 --- a/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td +++ b/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td @@ -23,6 +23,8 @@ def MQTDialect : Dialect { `mqt.input_name` records the source-level name of a function input. `mqt.input_group` optionally preserves its source-level vector identity, name, element index, and size. + `mqt.loop_parameter_group` preserves the same metadata for a lexically + bound `scf.for` parameter. `mqt.register_name` records the source-level name of a quantum or classical register allocation. Input and register names share one function-wide namespace. @@ -32,6 +34,7 @@ def MQTDialect : Dialect { let discardableAttrs = (ins "::mlir::StringAttr":$input_name, "::mlir::DictionaryAttr":$input_group, + "::mlir::DictionaryAttr":$loop_parameter_group, "::mlir::StringAttr":$register_name, "::mlir::UnitAttr":$entry_point); let hasOperationAttrVerify = 1; diff --git a/mlir/lib/Conversion/QCOToQC/QCOToQC.cpp b/mlir/lib/Conversion/QCOToQC/QCOToQC.cpp index bbc6ce542b..4b2b6107d9 100644 --- a/mlir/lib/Conversion/QCOToQC/QCOToQC.cpp +++ b/mlir/lib/Conversion/QCOToQC/QCOToQC.cpp @@ -890,6 +890,7 @@ struct ConvertQCOSCFForOp final : OpConversionPattern { auto newFor = scf::ForOp::create( rewriter, op.getLoc(), adaptor.getLowerBound(), adaptor.getUpperBound(), adaptor.getStep(), classicalInits); + newFor->setDiscardableAttrs(op->getDiscardableAttrDictionary()); // Erase default block rewriter.eraseBlock(&newFor.getRegion().front()); diff --git a/mlir/lib/Conversion/QCToQCO/QCToQCO.cpp b/mlir/lib/Conversion/QCToQCO/QCToQCO.cpp index 14a384c24f..a737ae1387 100644 --- a/mlir/lib/Conversion/QCToQCO/QCToQCO.cpp +++ b/mlir/lib/Conversion/QCToQCO/QCToQCO.cpp @@ -1459,6 +1459,7 @@ struct ConvertSCFForOp final : StatefulOpConversionPattern { auto newForOp = scf::ForOp::create(rewriter, op.getLoc(), op.getLowerBound(), op.getUpperBound(), op.getStep(), initArgs); + newForOp->setDiscardableAttrs(op->getDiscardableAttrDictionary()); assignMappedTensors(state, op.getOperation(), registerMap, newForOp.getResults() diff --git a/mlir/lib/Dialect/MQT/IR/CMakeLists.txt b/mlir/lib/Dialect/MQT/IR/CMakeLists.txt index 871f7e2283..aafd4f293b 100644 --- a/mlir/lib/Dialect/MQT/IR/CMakeLists.txt +++ b/mlir/lib/Dialect/MQT/IR/CMakeLists.txt @@ -21,6 +21,7 @@ add_mlir_dialect_library( MLIRMemRefDialect MLIRQCDialect MLIRQCODialect + MLIRSCFDialect MLIRQTensorDialect) mqt_mlir_target_use_project_options(MLIRMQTDialect) diff --git a/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp b/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp index 21d33921b2..3981817e63 100644 --- a/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp +++ b/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -85,33 +86,48 @@ verifyEntryPoint(Operation* operation, const NamedAttribute attribute) { } [[nodiscard]] static LogicalResult -verifyInputGroup(FunctionOpInterface function, Operation* operation, - const unsigned argIndex, const Attribute attribute) { - const auto inputName = function.getArgAttrOfType( - argIndex, MQTDialect::InputNameAttrHelper::getNameStr()); +verifyParameterGroup(Operation* operation, const Attribute attribute) { const auto group = dyn_cast(attribute); const auto identity = group ? group.getAs("identity") : nullptr; const auto groupName = group ? group.getAs("name") : nullptr; const auto groupIndex = group ? group.getAs("index") : nullptr; const auto groupSize = group ? group.getAs("size") : nullptr; - if (!inputName || !group || group.size() != 4U || !identity || !groupName || - !groupIndex || !groupSize) { + if (!group || group.size() != 4U || !identity || !groupName || !groupIndex || + !groupSize) { return operation->emitError() - << "parameter input-group metadata must contain exactly identity, " + << "parameter-group metadata must contain exactly identity, " "name, index, and size"; } if (identity.getValue().empty() || identity.getValue().contains('\0') || groupName.getValue().contains('\0')) { return operation->emitError() - << "parameter input-group string metadata is invalid"; + << "parameter-group string metadata is invalid"; } if (!groupIndex.getType().isInteger(64) || groupIndex.getValue().isNegative() || !groupSize.getType().isInteger(64) || groupSize.getValue().isNegative()) { return operation->emitError() - << "parameter input-group index and size must be nonnegative i64 " + << "parameter-group index and size must be nonnegative i64 " "integers"; } + return success(); +} + +[[nodiscard]] static LogicalResult +verifyInputGroup(FunctionOpInterface function, Operation* operation, + const unsigned argIndex, const Attribute attribute) { + const auto inputName = function.getArgAttrOfType( + argIndex, MQTDialect::InputNameAttrHelper::getNameStr()); + if (!inputName) { + return operation->emitError() + << "parameter input-group metadata requires an input name"; + } + if (failed(verifyParameterGroup(operation, attribute))) { + return failure(); + } + const auto group = cast(attribute); + const auto groupName = group.getAs("name"); + const auto groupIndex = group.getAs("index"); const auto expectedName = groupName.str() + "[" + std::to_string(groupIndex.getInt()) + "]"; if (inputName.getValue() != expectedName) { @@ -186,6 +202,14 @@ MQTDialect::verifyOperationAttribute(Operation* operation, if (attribute.getName() == RegisterNameAttrHelper::getNameStr()) { return verifyRegisterName(operation, attribute); } + if (attribute.getName() == LoopParameterGroupAttrHelper::getNameStr()) { + if (!isa(operation)) { + return operation->emitError() + << "attribute '" << attribute.getName().getValue() + << "' is only valid on scf.for"; + } + return verifyParameterGroup(operation, attribute.getValue()); + } if (attribute.getName() == InputNameAttrHelper::getNameStr() || attribute.getName() == InputGroupAttrHelper::getNameStr()) { return operation->emitError() diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index fa15a09fd2..378ddfdd44 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -2264,9 +2264,10 @@ def test_sparse_parameter_vector_round_trip_preserves_order_and_binding() -> Non def test_parameter_vector_is_shared_across_sibling_blocks() -> None: - """Restore one vector for elements used in sibling control-flow blocks.""" + """Restore one vector across parent and sibling control-flow blocks.""" vector = ParameterVector("theta", 2) circuit = QuantumCircuit(1, 1) + circuit.rz(vector[0], 0) with circuit.if_test((circuit.clbits[0], True)) as else_: circuit.rx(vector[0], 0) with else_: @@ -2274,8 +2275,11 @@ def test_parameter_vector_is_shared_across_sibling_blocks() -> None: restored = QCProgram.from_qiskit(circuit).to_qiskit() - parameters = [block.data[0].operation.params[0] for block in restored.data[0].operation.blocks] - assert [parameter.index for parameter in parameters] == [0, 1] + root_parameter = restored.data[0].operation.params[0] + blocks = restored.data[1].operation.blocks + parameters = [root_parameter, *(block.data[0].operation.params[0] for block in blocks)] + assert [parameter.index for parameter in parameters] == [0, 0, 1] + assert all(parameter.vector.uuid == root_parameter.vector.uuid for parameter in parameters) restored.assign_parameters({parameters[0].vector: [0.25, 0.5]}, inplace=True) assert not restored.parameters @@ -2313,7 +2317,7 @@ def test_standalone_bracket_parameter_names_remain_standalone() -> None: def test_parameter_vector_element_is_valid_loop_parameter() -> None: - """Keep a vector-element loop symbol lexical rather than a free input.""" + """Preserve a vector-element loop symbol as a lexical parameter.""" iteration = ParameterVector("iteration", 4)[2] body = QuantumCircuit(1) body.rx(iteration, 0) @@ -2321,10 +2325,38 @@ def test_parameter_vector_element_is_valid_loop_parameter() -> None: circuit.for_loop(range(3), iteration, body, [0], [], label=None) program = QCProgram.from_qiskit(circuit) - assert "scf.for" in program.ir assert "qc.rx" in program.ir assert "mqt.input_group" not in program.ir + assert "mqt.loop_parameter_group" in program.ir + restored_circuits = ( + program.to_qiskit(), + program.to_qco(copy=True).to_qc().to_qiskit(), + ) + for restored in restored_circuits: + restored_loop = restored.data[0].operation + restored_parameter = restored_loop.params[1] + assert isinstance(restored_parameter, ParameterVectorElement) + assert restored_parameter.vector.name == "iteration" + assert restored_parameter.index == 2 + assert len(restored_parameter.vector) == 4 + assert restored_loop.blocks[0].data[0].operation.params[0] == restored_parameter + assert not restored.parameters + + +@pytest.mark.parametrize(("size", "index"), [(0, 0), (1, 1)]) +def test_parameter_vector_element_outside_current_size_round_trips(size: int, index: int) -> None: + """Preserve a vector element outside its vector's current size.""" + vector = ParameterVector("theta", size) + circuit = QuantumCircuit(1) + circuit.rx(ParameterVectorElement(vector, index), 0) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + + restored_element = next(iter(restored.parameters)) + assert isinstance(restored_element, ParameterVectorElement) + assert restored_element.index == index + assert len(restored_element.vector) == size @pytest.mark.parametrize("sizes", [[65_537], [32_769, 32_769]]) From 219a2f33b709ba04907428b8ba087e610de47844 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 17:23:57 +0200 Subject: [PATCH 32/38] =?UTF-8?q?=E2=9C=85=20Cover=20parameter-group=20ver?= =?UTF-8?q?ifier=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp b/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp index 71c2e0324e..21cf8924aa 100644 --- a/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp +++ b/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp @@ -175,6 +175,20 @@ TEST_F(MQTIRTest, RejectsInvalidInputGroups) { mqt.input_group = {identity = "group"}}) { return } } )mlir")); + EXPECT_FALSE(parse(R"mlir( + module { + func.func @empty_identity(%arg: f64 {mqt.input_name = "theta[0]", + mqt.input_group = {identity = "", name = "theta", + index = 0 : i64, size = 1 : i64}}) { return } + } + )mlir")); + EXPECT_FALSE(parse(R"mlir( + module { + func.func @null_name(%arg: f64 {mqt.input_name = "theta[0]", + mqt.input_group = {identity = "group", name = "theta\00", + index = 0 : i64, size = 1 : i64}}) { return } + } + )mlir")); EXPECT_FALSE(parse(R"mlir( module { func.func @wrong_integer_type(%arg: f64 {mqt.input_name = "theta[0]", @@ -191,7 +205,7 @@ TEST_F(MQTIRTest, RejectsInvalidInputGroups) { )mlir")); } -TEST_F(MQTIRTest, RejectsInputNameOnOperation) { +TEST_F(MQTIRTest, RejectsInputMetadataOnOperations) { EXPECT_FALSE(parse(R"mlir( module { func.func @main() { @@ -201,6 +215,17 @@ TEST_F(MQTIRTest, RejectsInputNameOnOperation) { } } )mlir")); + EXPECT_FALSE(parse(R"mlir( + module { + func.func @main() { + %c0 = "arith.constant"() { + mqt.loop_parameter_group = {identity = "group", name = "theta", + index = 0 : i64, size = 1 : i64}, + value = 0.0 : f64} : () -> f64 + return + } + } + )mlir")); } TEST_F(MQTIRTest, RejectsInvalidRegisterNamesAndOwners) { From 40ef2d858315c9ab18b1d44fa442aaf285009cf1 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 25 Aug 2026 17:32:27 +0200 Subject: [PATCH 33/38] =?UTF-8?q?=F0=9F=9A=A8=20Fix=20MLIR=20include-clean?= =?UTF-8?q?er=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/QiskitExport.cpp | 1 + bindings/mlir/qiskit/QiskitImport.cpp | 1 + 2 files changed, 2 insertions(+) diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 3827247e77..a16dfe2692 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 029dd4b182..22350c8b53 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include From 38a04e3f91eb8d5660656fa008ec4e4eba6e188b Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Wed, 26 Aug 2026 15:47:21 +0200 Subject: [PATCH 34/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20parameter?= =?UTF-8?q?-vector=20provenance=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/Qiskit2_5.cpp | 89 +++++++++------------- bindings/mlir/qiskit/QiskitExport.cpp | 27 +------ bindings/mlir/qiskit/QiskitImport.cpp | 19 +---- bindings/mlir/qiskit/QiskitTranslation.cpp | 18 +++++ bindings/mlir/qiskit/QiskitTranslation.h | 10 +++ 5 files changed, 67 insertions(+), 96 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 61a15c8242..3df8effa15 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -1242,12 +1242,10 @@ class NativeControlFlowReader final : public ControlFlowReader { throw std::runtime_error("Qiskit for-loop parameter is not a symbol"); } const auto nativeIsElement = symbol.ty == QkSymbolType_Element; - if ((!nativeIsElement && - (parameterSymbol->group || parameterSymbol->name != nativeName)) || - (nativeIsElement && - (!parameterSymbol->group || - parameterSymbol->group->name != nativeName || - parameterSymbol->group->index != symbol.index))) { + const auto& group = parameterSymbol->group; + if (nativeIsElement != group.has_value() || + (group ? group->name : parameterSymbol->name) != nativeName || + (group && group->index != symbol.index)) { throw std::runtime_error( "Qiskit Python and native loop-parameter metadata do not match"); } @@ -2172,40 +2170,34 @@ class NativeCircuitWriter final : public CircuitWriter { return; } - try { - const auto circuitModule = nb::module_::import_("qiskit.circuit"); - const auto parameterVector = circuitModule.attr("ParameterVector"); - const auto parameterVectorElement = - circuitModule.attr("ParameterVectorElement"); - nb::dict replacements; - const auto parameters = pythonAttribute( - circuit, "parameters", "Qiskit circuit has no parameter collection"); - for (const nb::handle parameter : nb::iter(parameters)) { - const auto name = pythonStringAttribute( - parameter, "name", "Qiskit circuit parameter has no name"); - const auto symbol = symbols.find(name); - if (symbol == symbols.end() || !symbol->second.group) { - continue; - } - const auto& metadata = *symbol->second.group; - const auto [group, inserted] = groups.try_emplace(metadata.identity); - if (inserted) { - group->second = parameterVector(metadata.name, metadata.size); - } - replacements[parameter] = - parameterVectorElement(group->second, metadata.index); + const auto circuitModule = nb::module_::import_("qiskit.circuit"); + const auto parameterVector = circuitModule.attr("ParameterVector"); + const auto parameterVectorElement = + circuitModule.attr("ParameterVectorElement"); + nb::dict replacements; + const auto parameters = pythonAttribute( + circuit, "parameters", "Qiskit circuit has no parameter collection"); + for (const nb::handle parameter : nb::iter(parameters)) { + const auto name = pythonStringAttribute( + parameter, "name", "Qiskit circuit parameter has no name"); + const auto symbol = symbols.find(name); + if (symbol == symbols.end() || !symbol->second.group) { + continue; } - if (nb::len(replacements) == 0U) { - return; + const auto& metadata = *symbol->second.group; + const auto [group, inserted] = groups.try_emplace(metadata.identity); + if (inserted) { + group->second = parameterVector(metadata.name, metadata.size); } - pythonAttribute(circuit, "assign_parameters", - "Qiskit circuit cannot replace output parameters")( - replacements, nb::arg("inplace") = true, - nb::arg("flat_input") = true); - } catch (const nb::python_error& error) { - throwPythonError("Qiskit failed to restore parameter-vector elements", - error); + replacements[parameter] = + parameterVectorElement(group->second, metadata.index); + } + if (nb::len(replacements) == 0U) { + return; } + pythonAttribute(circuit, "assign_parameters", + "Qiskit circuit cannot replace output parameters")( + replacements, nb::arg("inplace") = true, nb::arg("flat_input") = true); } void replacePendingControlledUnitaries(const nb::handle pythonCircuit) const { @@ -2277,17 +2269,9 @@ class NativeCircuitWriter final : public CircuitWriter { symbol->group ? symbol->group->name + "[" + std::to_string(symbol->group->index) + "]" : symbol->name; - const auto parameters = pythonAttribute( - body, "parameters", "Qiskit circuit has no parameter collection"); - for (const nb::handle parameter : nb::iter(parameters)) { - if (pythonStringAttribute(parameter, "name", - "Qiskit circuit parameter has no name") == - parameterName) { - return nb::borrow(parameter); - } - } - throw std::runtime_error( - "Qiskit for-loop parameter is absent from its body"); + return pythonAttribute(body, "get_parameter", + "Qiskit circuit cannot find its loop parameter")( + parameterName); } [[nodiscard]] static nb::object constructControlFlowOperation( @@ -2403,13 +2387,8 @@ class NativeCircuitWriter final : public CircuitWriter { throw std::runtime_error( "cannot export a symbolic parameter without a name"); } - const auto [known, inserted] = - symbols_->try_emplace(symbol->name, symbol->name, symbol->group); - if (!inserted && known->second.group != symbol->group) { - throw std::runtime_error( - "one Qiskit parameter symbol has conflicting group metadata"); - } - return known->second.parameter.get(); + return symbols_->try_emplace(symbol->name, symbol->name, symbol->group) + .first->second.parameter.get(); } auto output = std::make_unique(); diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 71a924c97e..6f2197f6f2 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -30,7 +30,6 @@ #include #include #include -#include #include #include #include @@ -339,8 +338,7 @@ struct ExportState { ExportedParameters parameters; std::vector inputParameters; llvm::StringSet<> parameterNames; - llvm::StringMap parameterGroups; - uint64_t totalParameterGroupSize = 0U; + ParameterGroupRegistry parameterGroups; size_t nextLoopParameter = 0U; uint32_t numQubits = 0; uint32_t numClbits = 0; @@ -373,25 +371,6 @@ struct ExportState { }; } -void registerParameterGroup(ExportState& state, const ParameterGroup& group) { - const auto [known, inserted] = - state.parameterGroups.try_emplace(group.identity, group); - if (inserted) { - if (group.size > MAX_PARAMETER_GROUP_SIZE - state.totalParameterGroupSize) { - throw std::runtime_error( - "Qiskit circuit export supports at most " + - std::to_string(MAX_PARAMETER_GROUP_SIZE) + - " elements across all distinct parameter vectors"); - } - state.totalParameterGroupSize += group.size; - return; - } - if (known->second.name != group.name || known->second.size != group.size) { - throw std::runtime_error( - "one Qiskit parameter group has conflicting metadata"); - } -} - [[nodiscard]] bool parameterUsesName(const Parameter& parameter, const std::string_view name) { if (const auto* symbol = parameter.getSymbol()) { @@ -499,7 +478,7 @@ void collectParameters(mlir::func::FuncOp function, ExportState& state) { throw std::runtime_error( "Qiskit parameter input name does not match its group and index"); } - registerParameterGroup(state, *group); + state.parameterGroups.add(*group); } auto parameter = Parameter::symbol(name.str(), std::move(group)); state.parameters[argument] = parameter; @@ -1726,7 +1705,7 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, if (const auto attribute = loop->getAttr( mlir::mqt::MQTDialect::LoopParameterGroupAttrHelper::getNameStr())) { sourceGroup = parameterGroup(attribute); - registerParameterGroup(state, *sourceGroup); + state.parameterGroups.add(*sourceGroup); } std::optional projection; std::optional loopParameter; diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 167185ab95..6e86b2fa05 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -1950,8 +1950,7 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { const auto freeParameters = view->parameters(); ValidationParameters freeParameterSymbols; llvm::StringSet<> parameterNames; - llvm::StringMap parameterGroups; - uint64_t totalParameterGroupSize = 0U; + ParameterGroupRegistry parameterGroups; for (const auto& parameter : freeParameters) { const auto* symbol = parameter.getSymbol(); if (symbol == nullptr || symbol->name.empty()) { @@ -1969,21 +1968,7 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { throw std::runtime_error( "Qiskit parameter-vector index cannot be represented by MLIR"); } - const auto [known, inserted] = - parameterGroups.try_emplace(group.identity, group); - if (inserted) { - if (group.size > MAX_PARAMETER_GROUP_SIZE - totalParameterGroupSize) { - throw std::runtime_error( - "Qiskit circuit import supports at most " + - std::to_string(MAX_PARAMETER_GROUP_SIZE) + - " elements across all distinct parameter vectors"); - } - totalParameterGroupSize += group.size; - } else if (known->second.name != group.name || - known->second.size != group.size) { - throw std::runtime_error( - "one Qiskit parameter input group has conflicting metadata"); - } + parameterGroups.add(group); } freeParameterSymbols.try_emplace(symbol->name, parameter); } diff --git a/bindings/mlir/qiskit/QiskitTranslation.cpp b/bindings/mlir/qiskit/QiskitTranslation.cpp index aca897043a..e166301715 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.cpp +++ b/bindings/mlir/qiskit/QiskitTranslation.cpp @@ -21,6 +21,24 @@ namespace mqt::bindings::qiskit { +void ParameterGroupRegistry::add(const ParameterGroup& group) { + const auto [known, inserted] = groups.try_emplace(group.identity, group); + if (inserted) { + if (group.size > MAX_PARAMETER_GROUP_SIZE - totalSize) { + throw std::runtime_error( + "Qiskit circuit translation supports at most " + + std::to_string(MAX_PARAMETER_GROUP_SIZE) + + " elements across all distinct parameter vectors"); + } + totalSize += group.size; + return; + } + if (known->second.name != group.name || known->second.size != group.size) { + throw std::runtime_error( + "one Qiskit parameter group has conflicting metadata"); + } +} + uint32_t validateRegisterLayout(const std::vector& registers, const uint32_t total, const std::string_view kind) { diff --git a/bindings/mlir/qiskit/QiskitTranslation.h b/bindings/mlir/qiskit/QiskitTranslation.h index d1be278f65..a66a20e5d3 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -12,6 +12,7 @@ #include "mlir/Dialect/QC/Translation/StandardGate.h" +#include #include #include @@ -64,6 +65,15 @@ struct ParameterGroup { [[nodiscard]] bool operator==(const ParameterGroup&) const = default; }; +class ParameterGroupRegistry { +public: + void add(const ParameterGroup& group); + +private: + llvm::StringMap groups; + uint64_t totalSize = 0U; +}; + enum class UnaryParameterKind : uint8_t { Negate, Sin, From 6a2be732c743de9ba84fc259c86413ed6e84860a Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Wed, 26 Aug 2026 15:47:39 +0200 Subject: [PATCH 35/38] =?UTF-8?q?=E2=9C=85=20Trim=20redundant=20parameter-?= =?UTF-8?q?vector=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp | 10 ++++------ test/python/test_mlir_qiskit_translation.py | 5 ----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp b/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp index 21cf8924aa..de13a4ece5 100644 --- a/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp +++ b/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp @@ -58,7 +58,10 @@ TEST_F(MQTIRTest, AcceptsProgramInputAndRegisterNames) { module { func.func @qc(%theta: f64 {mqt.input_name = "theta[2]", mqt.input_group = {identity = "group-id", name = "theta", - index = 2 : i64, size = 4 : i64}}) { + index = 2 : i64, size = 4 : i64}}, + %element: f64 {mqt.input_name = "[0]", + mqt.input_group = {identity = "empty-name", name = "", + index = 0 : i64, size = 1 : i64}}) { %reg = memref.alloc() {mqt.register_name = "q"} : memref<2x!qc.qubit> return @@ -78,11 +81,6 @@ TEST_F(MQTIRTest, AcceptsProgramInputAndRegisterNames) { %reg = memref.alloc() {mqt.register_name = "lowered"} : memref<2xi1> return } - func.func @empty_vector_name(%element: f64 {mqt.input_name = "[0]", - mqt.input_group = {identity = "empty-name", name = "", - index = 0 : i64, size = 1 : i64}}) { - return - } } )mlir")); } diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 5ec21b9b56..9d9d83710d 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -2228,7 +2228,6 @@ def test_parameter_vector_is_shared_across_sibling_blocks() -> None: blocks = restored.data[1].operation.blocks parameters = [root_parameter, *(block.data[0].operation.params[0] for block in blocks)] assert [parameter.index for parameter in parameters] == [0, 0, 1] - assert all(parameter.vector.uuid == root_parameter.vector.uuid for parameter in parameters) restored.assign_parameters({parameters[0].vector: [0.25, 0.5]}, inplace=True) assert not restored.parameters @@ -2274,8 +2273,6 @@ def test_parameter_vector_element_is_valid_loop_parameter() -> None: circuit.for_loop(range(3), iteration, body, [0], [], label=None) program = QCProgram.from_qiskit(circuit) - assert "scf.for" in program.ir - assert "qc.rx" in program.ir assert "mqt.input_group" not in program.ir assert "mqt.loop_parameter_group" in program.ir restored_circuits = ( @@ -2285,7 +2282,6 @@ def test_parameter_vector_element_is_valid_loop_parameter() -> None: for restored in restored_circuits: restored_loop = restored.data[0].operation restored_parameter = restored_loop.params[1] - assert isinstance(restored_parameter, ParameterVectorElement) assert restored_parameter.vector.name == "iteration" assert restored_parameter.index == 2 assert len(restored_parameter.vector) == 4 @@ -2303,7 +2299,6 @@ def test_parameter_vector_element_outside_current_size_round_trips(size: int, in restored = QCProgram.from_qiskit(circuit).to_qiskit() restored_element = next(iter(restored.parameters)) - assert isinstance(restored_element, ParameterVectorElement) assert restored_element.index == index assert len(restored_element.vector) == size From 8de31f0c46c0ff2047a6374f0e78ef031ec6f1ef Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Thu, 27 Aug 2026 00:33:37 +0200 Subject: [PATCH 36/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Use=20LLVM=20string?= =?UTF-8?q?=20maps=20in=20Qiskit=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/Qiskit2_5.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 3df8effa15..31ffcab7b7 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -11,6 +11,7 @@ #include "QiskitTranslation.h" #include "mlir/Dialect/QC/Translation/StandardGate.h" +#include #include // Qiskit requires its umbrella header before the extension function table. @@ -37,7 +38,6 @@ #include #include #include -#include #include #include #include @@ -1936,8 +1936,8 @@ struct NativeSymbol { OwnedParameter parameter; }; -using NativeSymbolTable = std::unordered_map; -using PythonParameterGroups = std::unordered_map; +using NativeSymbolTable = llvm::StringMap; +using PythonParameterGroups = llvm::StringMap; class NativeCircuitWriter final : public CircuitWriter { public: From 447d5245cc0c5072835ac7692b87010fadbb9233 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Thu, 27 Aug 2026 10:50:56 +0200 Subject: [PATCH 37/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Consolidate=20parame?= =?UTF-8?q?ter-group=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/mlir/qiskit/QiskitExport.cpp | 4 ++-- bindings/mlir/qiskit/QiskitImport.cpp | 8 ++++---- mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td | 8 +++----- mlir/lib/Dialect/MQT/IR/MQTDialect.cpp | 12 ++++++------ 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 6f2197f6f2..f75494f324 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -469,7 +469,7 @@ void collectParameters(mlir::func::FuncOp function, ExportState& state) { } const auto groupAttribute = function.getArgAttr( - index, mlir::mqt::MQTDialect::InputGroupAttrHelper::getNameStr()); + index, mlir::mqt::MQTDialect::ParameterGroupAttrHelper::getNameStr()); std::optional group; if (groupAttribute) { group = parameterGroup(groupAttribute); @@ -1703,7 +1703,7 @@ collectFor(mlir::scf::ForOp loop, ExportState& state, .isRange = true, .start = *lower, .stop = *upper, .step = *step}; std::optional sourceGroup; if (const auto attribute = loop->getAttr( - mlir::mqt::MQTDialect::LoopParameterGroupAttrHelper::getNameStr())) { + mlir::mqt::MQTDialect::ParameterGroupAttrHelper::getNameStr())) { sourceGroup = parameterGroup(attribute); state.parameterGroups.add(*sourceGroup); } diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index 6e86b2fa05..cfb5bcd61d 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -1193,9 +1193,9 @@ void translateControlFlow(mlir::qc::QCProgramBuilder& builder, const auto* symbol = loop.parameter->getSymbol(); if (symbol != nullptr && symbol->group) { mlir::cast(&containingBlock->back()) - ->setAttr(mlir::mqt::MQTDialect::LoopParameterGroupAttrHelper:: - getNameStr(), - parameterGroupAttribute(builder, *symbol->group)); + ->setAttr( + mlir::mqt::MQTDialect::ParameterGroupAttrHelper::getNameStr(), + parameterGroupAttribute(builder, *symbol->group)); } } return; @@ -2018,7 +2018,7 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { builder.getStringAttr(symbol->name))}; if (symbol->group) { argumentAttributes.push_back(builder.getNamedAttr( - mlir::mqt::MQTDialect::InputGroupAttrHelper::getNameStr(), + mlir::mqt::MQTDialect::ParameterGroupAttrHelper::getNameStr(), parameterGroupAttribute(builder, *symbol->group))); } const auto index = function.getNumArguments(); diff --git a/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td b/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td index ec34f706e6..2d34f55a38 100644 --- a/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td +++ b/mlir/include/mlir/Dialect/MQT/IR/MQTDialect.td @@ -21,9 +21,8 @@ def MQTDialect : Dialect { across quantum dialect conversions. It defines no operations or types. `mqt.input_name` records the source-level name of a function input. - `mqt.input_group` optionally preserves its source-level vector identity, - name, element index, and size. - `mqt.loop_parameter_group` preserves the same metadata for a lexically + `mqt.parameter_group` optionally preserves the source-level vector + identity, name, element index, and size of a function input or a lexically bound `scf.for` parameter. `mqt.register_name` records the source-level name of a quantum or classical register allocation. Input and register names share one function-wide @@ -33,8 +32,7 @@ def MQTDialect : Dialect { }]; let discardableAttrs = (ins "::mlir::StringAttr":$input_name, - "::mlir::DictionaryAttr":$input_group, - "::mlir::DictionaryAttr":$loop_parameter_group, + "::mlir::DictionaryAttr":$parameter_group, "::mlir::StringAttr":$register_name, "::mlir::UnitAttr":$entry_point); let hasOperationAttrVerify = 1; diff --git a/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp b/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp index 3981817e63..1cff286d04 100644 --- a/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp +++ b/mlir/lib/Dialect/MQT/IR/MQTDialect.cpp @@ -120,7 +120,8 @@ verifyInputGroup(FunctionOpInterface function, Operation* operation, argIndex, MQTDialect::InputNameAttrHelper::getNameStr()); if (!inputName) { return operation->emitError() - << "parameter input-group metadata requires an input name"; + << "parameter-group metadata on a function argument requires an " + "input name"; } if (failed(verifyParameterGroup(operation, attribute))) { return failure(); @@ -202,7 +203,7 @@ MQTDialect::verifyOperationAttribute(Operation* operation, if (attribute.getName() == RegisterNameAttrHelper::getNameStr()) { return verifyRegisterName(operation, attribute); } - if (attribute.getName() == LoopParameterGroupAttrHelper::getNameStr()) { + if (attribute.getName() == ParameterGroupAttrHelper::getNameStr()) { if (!isa(operation)) { return operation->emitError() << "attribute '" << attribute.getName().getValue() @@ -210,8 +211,7 @@ MQTDialect::verifyOperationAttribute(Operation* operation, } return verifyParameterGroup(operation, attribute.getValue()); } - if (attribute.getName() == InputNameAttrHelper::getNameStr() || - attribute.getName() == InputGroupAttrHelper::getNameStr()) { + if (attribute.getName() == InputNameAttrHelper::getNameStr()) { return operation->emitError() << "attribute '" << attribute.getName().getValue() << "' is only valid on a function argument"; @@ -225,7 +225,7 @@ LogicalResult MQTDialect::verifyRegionArgAttribute( const NamedAttribute attribute) { const auto attributeName = attribute.getName(); if (attributeName != InputNameAttrHelper::getNameStr() && - attributeName != InputGroupAttrHelper::getNameStr()) { + attributeName != ParameterGroupAttrHelper::getNameStr()) { return operation->emitError() << "attribute '" << attribute.getName().getValue() << "' is not valid on a region argument"; @@ -238,7 +238,7 @@ LogicalResult MQTDialect::verifyRegionArgAttribute( << "' requires a function entry-block argument"; } - if (attributeName == InputGroupAttrHelper::getNameStr()) { + if (attributeName == ParameterGroupAttrHelper::getNameStr()) { return verifyInputGroup(function, operation, argIndex, attribute.getValue()); } From 67d8fa6becf820b53d6eac7aba94d2cd8729e8e8 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Thu, 27 Aug 2026 10:51:12 +0200 Subject: [PATCH 38/38] =?UTF-8?q?=E2=9C=85=20Update=20parameter-group=20me?= =?UTF-8?q?tadata=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp | 30 +++++++++---------- test/python/test_mlir_qiskit_translation.py | 5 ++-- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp b/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp index de13a4ece5..363efcbd48 100644 --- a/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp +++ b/mlir/unittests/Dialect/MQT/IR/test_mqt_ir.cpp @@ -57,11 +57,11 @@ TEST_F(MQTIRTest, AcceptsProgramInputAndRegisterNames) { EXPECT_TRUE(parse(R"mlir( module { func.func @qc(%theta: f64 {mqt.input_name = "theta[2]", - mqt.input_group = {identity = "group-id", name = "theta", - index = 2 : i64, size = 4 : i64}}, + mqt.parameter_group = {identity = "group-id", name = "theta", + index = 2 : i64, size = 4 : i64}}, %element: f64 {mqt.input_name = "[0]", - mqt.input_group = {identity = "empty-name", name = "", - index = 0 : i64, size = 1 : i64}}) { + mqt.parameter_group = {identity = "empty-name", name = "", + index = 0 : i64, size = 1 : i64}}) { %reg = memref.alloc() {mqt.register_name = "q"} : memref<2x!qc.qubit> return @@ -170,35 +170,35 @@ TEST_F(MQTIRTest, RejectsInvalidInputGroups) { EXPECT_FALSE(parse(R"mlir( module { func.func @incomplete(%arg: f64 {mqt.input_name = "theta[0]", - mqt.input_group = {identity = "group"}}) { return } + mqt.parameter_group = {identity = "group"}}) { return } } )mlir")); EXPECT_FALSE(parse(R"mlir( module { func.func @empty_identity(%arg: f64 {mqt.input_name = "theta[0]", - mqt.input_group = {identity = "", name = "theta", - index = 0 : i64, size = 1 : i64}}) { return } + mqt.parameter_group = {identity = "", name = "theta", + index = 0 : i64, size = 1 : i64}}) { return } } )mlir")); EXPECT_FALSE(parse(R"mlir( module { func.func @null_name(%arg: f64 {mqt.input_name = "theta[0]", - mqt.input_group = {identity = "group", name = "theta\00", - index = 0 : i64, size = 1 : i64}}) { return } + mqt.parameter_group = {identity = "group", name = "theta\00", + index = 0 : i64, size = 1 : i64}}) { return } } )mlir")); EXPECT_FALSE(parse(R"mlir( module { func.func @wrong_integer_type(%arg: f64 {mqt.input_name = "theta[0]", - mqt.input_group = {identity = "group", name = "theta", - index = 0 : i32, size = 1 : i64}}) { return } + mqt.parameter_group = {identity = "group", name = "theta", + index = 0 : i32, size = 1 : i64}}) { return } } )mlir")); EXPECT_FALSE(parse(R"mlir( module { func.func @wrong_name(%arg: f64 {mqt.input_name = "phi[0]", - mqt.input_group = {identity = "group", name = "theta", - index = 0 : i64, size = 1 : i64}}) { return } + mqt.parameter_group = {identity = "group", name = "theta", + index = 0 : i64, size = 1 : i64}}) { return } } )mlir")); } @@ -217,8 +217,8 @@ TEST_F(MQTIRTest, RejectsInputMetadataOnOperations) { module { func.func @main() { %c0 = "arith.constant"() { - mqt.loop_parameter_group = {identity = "group", name = "theta", - index = 0 : i64, size = 1 : i64}, + mqt.parameter_group = {identity = "group", name = "theta", + index = 0 : i64, size = 1 : i64}, value = 0.0 : f64} : () -> f64 return } diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 9d9d83710d..7bdee690b1 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -2273,8 +2273,7 @@ def test_parameter_vector_element_is_valid_loop_parameter() -> None: circuit.for_loop(range(3), iteration, body, [0], [], label=None) program = QCProgram.from_qiskit(circuit) - assert "mqt.input_group" not in program.ir - assert "mqt.loop_parameter_group" in program.ir + assert program.ir.count("mqt.parameter_group") == 1 restored_circuits = ( program.to_qiskit(), program.to_qco(copy=True).to_qc().to_qiskit(), @@ -2329,7 +2328,7 @@ def test_parameter_vector_metadata_is_preflighted(sizes: list[int], shared_group for index, size in enumerate(sizes): arguments.append( f'%theta{index}: f64 {{mqt.input_name = "theta{index}[0]", ' - f'mqt.input_group = {{identity = "group{index if shared_group_id is None else shared_group_id}", ' + f'mqt.parameter_group = {{identity = "group{index if shared_group_id is None else shared_group_id}", ' f'name = "theta{index}", index = 0 : i64, size = {size} : i64}}}}' ) gates.append(f" qc.rx(%theta{index}) %q : !qc.qubit")