From 890ee097858777886ede4e77287e0eb3eb27835a Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Wed, 12 Aug 2026 11:10:46 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20Add=20SABRE=20routing=20actio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- src/mqt/predictor/rl/actions/qiskit_actions.py | 15 +++++++++++++++ src/mqt/predictor/rl/actions/registry.py | 1 + 2 files changed, 16 insertions(+) diff --git a/src/mqt/predictor/rl/actions/qiskit_actions.py b/src/mqt/predictor/rl/actions/qiskit_actions.py index 173d168cd..1d290f623 100644 --- a/src/mqt/predictor/rl/actions/qiskit_actions.py +++ b/src/mqt/predictor/rl/actions/qiskit_actions.py @@ -54,6 +54,7 @@ OptimizeCliffords, RemoveDiagonalGatesBeforeMeasure, SabreLayout, + SabreSwap, Size, UnitarySynthesis, VF2Layout, @@ -252,6 +253,20 @@ def qiskit_layout_actions() -> list[Action]: ] +def qiskit_routing_actions() -> list[Action]: + """Return the Qiskit routing actions.""" + return [ + DeferredDeviceAction( + "SabreSwap", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", [SabreSwap(coupling_map=CouplingMap(device.build_coupling_map()), heuristic="decay")] + ), + ) + ] + + def qiskit_mapping_action() -> Action: """Returns the Qiskit mapping action.""" return DeferredDeviceAction( diff --git a/src/mqt/predictor/rl/actions/registry.py b/src/mqt/predictor/rl/actions/registry.py index 5aa6c73ad..7f47b523d 100644 --- a/src/mqt/predictor/rl/actions/registry.py +++ b/src/mqt/predictor/rl/actions/registry.py @@ -51,6 +51,7 @@ def get_actions_by_pass_type() -> dict[PassType, list[Action]]: for _action in ( *qiskit_actions.qiskit_layout_actions(), + *qiskit_actions.qiskit_routing_actions(), qiskit_actions.qiskit_mapping_action(), qiskit_actions.qiskit_synthesis_action(), qiskit_actions.qiskit_o3_action(), From 4cb43a14d948d984db916323a6a0d4e5f76b8c11 Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Fri, 21 Aug 2026 17:51:15 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=93=9D=20Document=20the=20SABRE=20r?= =?UTF-8?q?outing=20action?= 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: flowerthrower --- CHANGELOG.md | 3 +++ UPGRADING.md | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3886aaa4f..7b277ef3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ releases may include breaking changes. ### Added +- ✨ Add Qiskit's `SabreSwap` pass to the RL routing actions ([#759]) + ([**@flowerthrower**]) - ✨ Expand and compact the RL observation with normalized OpenQASM operation frequencies and one-element `float32` qubit-count and depth arrays, and include measurements in the shared ML feature schema ([#758]) @@ -93,6 +95,7 @@ for previous changelogs._ [#773]: https://github.com/munich-quantum-toolkit/predictor/pull/771 [#769]: https://github.com/munich-quantum-toolkit/predictor/pull/769 +[#759]: https://github.com/munich-quantum-toolkit/predictor/pull/759 [#758]: https://github.com/munich-quantum-toolkit/predictor/pull/758 [#755]: https://github.com/munich-quantum-toolkit/predictor/pull/755 [#731]: https://github.com/munich-quantum-toolkit/predictor/pull/731 diff --git a/UPGRADING.md b/UPGRADING.md index fd0caf47f..437a36e4d 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -6,6 +6,13 @@ of changes including minor and patch releases, please refer to the ## [Unreleased] +### Qiskit SABRE routing action + +The RL action space now includes Qiskit's `SabreSwap` routing pass. Existing RL +models must be retrained because the action-space size and the indices of later +actions have changed. Code that persists or selects actions by numeric index +must be updated. + ### RL observation features The RL observation now includes normalized frequencies for supported OpenQASM From 27ad06610d96d7668ec5792020e871b15e37925d Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Wed, 26 Aug 2026 15:24:25 +0200 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20Expand=20Qiskit=20RL=20action?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- CHANGELOG.md | 6 +- UPGRADING.md | 22 +++- .../predictor/rl/actions/qiskit_actions.py | 111 +++++++++++++++++- src/mqt/predictor/rl/predictorenv.py | 2 +- .../test_integration_further_SDKs.py | 76 +++++++++++- 5 files changed, 201 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b277ef3f..6529b3ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ releases may include breaking changes. ### Added -- ✨ Add Qiskit's `SabreSwap` pass to the RL routing actions ([#759]) +- ✨ Add Qiskit's `TrivialLayout`, `ElidePermutations`, `SabreSwap`, + `BasicSwap`, `LookaheadSwap`, `GateDirection`, `RemoveIdentityEquivalent`, and + `Optimize1qGatesSimpleCommutation` passes to the RL actions ([#759]) ([**@flowerthrower**]) - ✨ Expand and compact the RL observation with normalized OpenQASM operation frequencies and one-element `float32` qubit-count and depth arrays, and @@ -25,6 +27,8 @@ releases may include breaking changes. ### Changed +- 🐛 Make the `OptimizeCliffords` RL action collect standard Clifford gates + before optimizing them ([#759]) ([**@flowerthrower**]) - 🔥 Drop support for Python 3.10 ([#773]) ([**@denialhaag**]) - ♻️ Split RL actions package into `base` and `registry` modules ([#769]) ([**@denialhaag**]) diff --git a/UPGRADING.md b/UPGRADING.md index 437a36e4d..8ec563560 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -6,12 +6,24 @@ of changes including minor and patch releases, please refer to the ## [Unreleased] -### Qiskit SABRE routing action +### Expanded Qiskit action set -The RL action space now includes Qiskit's `SabreSwap` routing pass. Existing RL -models must be retrained because the action-space size and the indices of later -actions have changed. Code that persists or selects actions by numeric index -must be updated. +The RL action space now includes the following Qiskit passes: + +- the `TrivialLayout` and `ElidePermutations` layout actions; +- the `SabreSwap`, `BasicSwap`, `LookaheadSwap`, and `GateDirection` routing + actions; and +- the `RemoveIdentityEquivalent` and `Optimize1qGatesSimpleCommutation` + optimization actions. + +`ElidePermutations` establishes a trivial layout in the same action so its +output permutation remains part of the canonical layout. `OptimizeCliffords` now +collects standard Clifford gates before optimizing and decomposes the result for +subsequent passes. + +Existing RL models must be retrained because the action-space size and the +indices of later actions have changed. Code that persists or selects actions by +numeric index must be updated. ### RL observation features diff --git a/src/mqt/predictor/rl/actions/qiskit_actions.py b/src/mqt/predictor/rl/actions/qiskit_actions.py index 1d290f623..5e18dfacf 100644 --- a/src/mqt/predictor/rl/actions/qiskit_actions.py +++ b/src/mqt/predictor/rl/actions/qiskit_actions.py @@ -37,25 +37,33 @@ from qiskit.transpiler import CouplingMap, PassManager, TranspileLayout from qiskit.transpiler.passes import ( ApplyLayout, + BasicSwap, BasisTranslator, Collect2qBlocks, + CollectCliffords, CommutativeCancellation, CommutativeInverseCancellation, ConsolidateBlocks, DenseLayout, Depth, + ElidePermutations, EnlargeWithAncilla, FixedPoint, FullAncillaAllocation, + GateDirection, GatesInBasis, InverseCancellation, + LookaheadSwap, MinimumPoint, Optimize1qGatesDecomposition, + Optimize1qGatesSimpleCommutation, OptimizeCliffords, RemoveDiagonalGatesBeforeMeasure, + RemoveIdentityEquivalent, SabreLayout, SabreSwap, Size, + TrivialLayout, UnitarySynthesis, VF2Layout, VF2PostLayout, @@ -150,7 +158,7 @@ def qiskit_optimization_actions() -> list[Action]: "OptimizeCliffords", CompilationOrigin.QISKIT, PassType.OPT, - [OptimizeCliffords()], + [CollectCliffords(), OptimizeCliffords()], preserves_layout=True, preserves_routing=False, preserves_synthesis=False, @@ -164,6 +172,32 @@ def qiskit_optimization_actions() -> list[Action]: preserves_routing=True, preserves_synthesis=False, ), + DeviceIndependentAction( + "RemoveIdentityEquivalent", + CompilationOrigin.QISKIT, + PassType.OPT, + [RemoveIdentityEquivalent()], + preserves_layout=True, + preserves_routing=True, + preserves_synthesis=True, + ), + DeferredDeviceAction( + "Optimize1qGatesSimpleCommutation", + CompilationOrigin.QISKIT, + PassType.OPT, + transpile_pass=lambda device: cast( + "list[Task]", + [ + Optimize1qGatesSimpleCommutation( + basis=device.operation_names, + run_to_completion=True, + ) + ], + ), + preserves_layout=True, + preserves_routing=True, + preserves_synthesis=True, + ), ] @@ -250,6 +284,35 @@ def qiskit_layout_actions() -> list[Action]: ], ), ), + DeferredDeviceAction( + "TrivialLayout", + CompilationOrigin.QISKIT, + PassType.LAYOUT, + transpile_pass=lambda device: cast( + "list[Task]", + [ + TrivialLayout(coupling_map=CouplingMap(device.build_coupling_map())), + FullAncillaAllocation(coupling_map=CouplingMap(device.build_coupling_map())), + EnlargeWithAncilla(), + ApplyLayout(), + ], + ), + ), + DeferredDeviceAction( + "ElidePermutations", + CompilationOrigin.QISKIT, + PassType.LAYOUT, + transpile_pass=lambda device: cast( + "list[Task]", + [ + ElidePermutations(), + TrivialLayout(coupling_map=CouplingMap(device.build_coupling_map())), + FullAncillaAllocation(coupling_map=CouplingMap(device.build_coupling_map())), + EnlargeWithAncilla(), + ApplyLayout(), + ], + ), + ), ] @@ -263,7 +326,39 @@ def qiskit_routing_actions() -> list[Action]: transpile_pass=lambda device: cast( "list[Task]", [SabreSwap(coupling_map=CouplingMap(device.build_coupling_map()), heuristic="decay")] ), - ) + ), + DeferredDeviceAction( + "BasicSwap", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", [BasicSwap(coupling_map=CouplingMap(device.build_coupling_map()))] + ), + ), + DeferredDeviceAction( + "LookaheadSwap", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", + [ + LookaheadSwap( + coupling_map=CouplingMap(device.build_coupling_map()), + search_depth=1, + search_width=1, + ) + ], + ), + ), + DeferredDeviceAction( + "GateDirection", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", + [GateDirection(coupling_map=CouplingMap(device.build_coupling_map()), target=device)], + ), + ), ] @@ -385,11 +480,21 @@ def run_qiskit_action( if altered_qc.count_ops().get("unitary"): # Custom "unitary" gates can not be processed further by other passes altered_qc = altered_qc.decompose(gates_to_decompose="unitary") + if altered_qc.count_ops().get("clifford"): + altered_qc = altered_qc.decompose(gates_to_decompose="clifford") return altered_qc, layout -def is_qiskit_action_available(action: Action, device: Target) -> bool: +def is_qiskit_action_available(action: Action, circuit: QuantumCircuit, device: Target) -> bool: """Return whether a Qiskit action is available for the current device.""" + if action.name == "GateDirection": + undirected_edges = {frozenset(edge) for edge in device.build_coupling_map().get_edges()} + return all( + frozenset(circuit.find_bit(qubit).index for qubit in instruction.qubits) in undirected_edges + for instruction in circuit.data + if len(instruction.qubits) == 2 + ) + # Only allow VF2PostLayout if "ibm" is in the device name # TODO: Why? return action.name != "VF2PostLayout" or "ibm" in device.description diff --git a/src/mqt/predictor/rl/predictorenv.py b/src/mqt/predictor/rl/predictorenv.py index c433ccaa7..728aec798 100644 --- a/src/mqt/predictor/rl/predictorenv.py +++ b/src/mqt/predictor/rl/predictorenv.py @@ -506,7 +506,7 @@ def action_masks(self) -> list[bool]: action_mask.append(True) continue if action.origin == CompilationOrigin.QISKIT: - action_mask.append(is_qiskit_action_available(action, self.device)) + action_mask.append(is_qiskit_action_available(action, self.state, self.device)) elif action.origin == CompilationOrigin.TKET: action_mask.append(is_tket_action_available(action=action, has_layout=has_layout)) elif action.origin == CompilationOrigin.BQSKIT: diff --git a/tests/compilation/test_integration_further_SDKs.py b/tests/compilation/test_integration_further_SDKs.py index 017794e7b..31adafdee 100644 --- a/tests/compilation/test_integration_further_SDKs.py +++ b/tests/compilation/test_integration_further_SDKs.py @@ -10,13 +10,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING - import pytest from mqt.bench.targets import get_device from qiskit import QuantumCircuit from qiskit.circuit import StandardEquivalenceLibrary -from qiskit.transpiler import PassManager, TranspileLayout +from qiskit.circuit.library import CXGate, HGate +from qiskit.transpiler import PassManager, Target, TranspileLayout from qiskit.transpiler.passes import ( ApplyLayout, BasisTranslator, @@ -29,9 +28,6 @@ from mqt.predictor.rl.actions import CompilationOrigin, PassType from mqt.predictor.rl.predictorenv import PredictorEnv -if TYPE_CHECKING: - from qiskit.transpiler import Target - def _setup_env(env: PredictorEnv, circuit: QuantumCircuit, layout: TranspileLayout | None, n_qubits: int) -> None: """Reset env to the given circuit/layout state without starting a full RL episode.""" @@ -46,6 +42,11 @@ def _is_available(env: PredictorEnv, idx: int) -> bool: return env.action_masks()[idx] +def _action_index(env: PredictorEnv, name: str) -> int: + """Return the index of an action with the given name.""" + return next(idx for idx, action in env.action_set.items() if action.name == name) + + def _lay_out(circuit: QuantumCircuit, target: Target) -> tuple[QuantumCircuit, TranspileLayout]: """Apply a trivial Qiskit layout to the circuit.""" coupling_map = target.build_coupling_map() @@ -126,6 +127,69 @@ def env(target: Target) -> PredictorEnv: return PredictorEnv(device=target, reward_function="expected_fidelity") +def test_requested_qiskit_passes_are_registered(env: PredictorEnv) -> None: + """All requested individual Qiskit passes are exposed as RL actions.""" + action_names = {action.name for action in env.action_set.values()} + assert { + "BasicSwap", + "ElidePermutations", + "GateDirection", + "LookaheadSwap", + "Optimize1qGatesSimpleCommutation", + "RemoveIdentityEquivalent", + "TrivialLayout", + } <= action_names + + +def test_elide_permutations_tracks_output_permutation(env: PredictorEnv) -> None: + """Eliding a SWAP keeps its output permutation in the established layout.""" + circuit = QuantumCircuit(3) + circuit.swap(0, 1) + circuit.x(0) + _setup_env(env, circuit, None, circuit.num_qubits) + + compiled = env.apply_action(_action_index(env, "ElidePermutations")) + + assert "swap" not in compiled.count_ops() + assert [ + (instruction.operation.name, compiled.find_bit(instruction.qubits[0]).index) for instruction in compiled.data + ] == [("x", 1)] + assert env.layout is not None + assert env.layout.final_index_layout() == [1, 0, 2] + + +def test_gate_direction_routes_adjacent_directional_gate() -> None: + """GateDirection is available once only edge direction remains to be fixed.""" + directional_target = Target(num_qubits=2, description="directional test target") + directional_target.add_instruction(HGate(), {(0,): None, (1,): None}) + directional_target.add_instruction(CXGate(), {(0, 1): None}) + with pytest.warns(UserWarning, match="uni-directional"): + directional_env = PredictorEnv(device=directional_target, reward_function="expected_fidelity") + + circuit = QuantumCircuit(2) + circuit.cx(1, 0) + laid_out, layout = _lay_out(circuit, directional_target) + _setup_env(directional_env, laid_out, layout, circuit.num_qubits) + action_index = _action_index(directional_env, "GateDirection") + + assert _is_available(directional_env, action_index) + compiled = directional_env.apply_action(action_index) + + assert directional_env.is_circuit_routed(compiled, directional_target.build_coupling_map()) + + +def test_optimize_cliffords_collects_standard_clifford_gates(env: PredictorEnv) -> None: + """OptimizeCliffords first collects ordinary gates and decomposes its result.""" + circuit = QuantumCircuit(1) + circuit.h(0) + circuit.h(0) + _setup_env(env, circuit, None, circuit.num_qubits) + + compiled = env.apply_action(_action_index(env, "OptimizeCliffords")) + + assert not compiled.data + + def test_synthesis_actions_produce_native_gates( simple_circuit: QuantumCircuit, env: PredictorEnv, From 62ce9c8cdc6ff832a21c897090af3a7007557e2b Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Wed, 26 Aug 2026 15:39:35 +0200 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=A7=AA=20Align=20Qiskit=20action=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- .../test_integration_further_SDKs.py | 93 +++++-------------- tests/compilation/test_predictor_rl.py | 23 ++++- 2 files changed, 42 insertions(+), 74 deletions(-) diff --git a/tests/compilation/test_integration_further_SDKs.py b/tests/compilation/test_integration_further_SDKs.py index 31adafdee..61cb6b1c8 100644 --- a/tests/compilation/test_integration_further_SDKs.py +++ b/tests/compilation/test_integration_further_SDKs.py @@ -10,12 +10,13 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from mqt.bench.targets import get_device from qiskit import QuantumCircuit from qiskit.circuit import StandardEquivalenceLibrary -from qiskit.circuit.library import CXGate, HGate -from qiskit.transpiler import PassManager, Target, TranspileLayout +from qiskit.transpiler import PassManager, TranspileLayout from qiskit.transpiler.passes import ( ApplyLayout, BasisTranslator, @@ -28,6 +29,9 @@ from mqt.predictor.rl.actions import CompilationOrigin, PassType from mqt.predictor.rl.predictorenv import PredictorEnv +if TYPE_CHECKING: + from qiskit.transpiler import Target + def _setup_env(env: PredictorEnv, circuit: QuantumCircuit, layout: TranspileLayout | None, n_qubits: int) -> None: """Reset env to the given circuit/layout state without starting a full RL episode.""" @@ -42,11 +46,6 @@ def _is_available(env: PredictorEnv, idx: int) -> bool: return env.action_masks()[idx] -def _action_index(env: PredictorEnv, name: str) -> int: - """Return the index of an action with the given name.""" - return next(idx for idx, action in env.action_set.items() if action.name == name) - - def _lay_out(circuit: QuantumCircuit, target: Target) -> tuple[QuantumCircuit, TranspileLayout]: """Apply a trivial Qiskit layout to the circuit.""" coupling_map = target.build_coupling_map() @@ -127,69 +126,6 @@ def env(target: Target) -> PredictorEnv: return PredictorEnv(device=target, reward_function="expected_fidelity") -def test_requested_qiskit_passes_are_registered(env: PredictorEnv) -> None: - """All requested individual Qiskit passes are exposed as RL actions.""" - action_names = {action.name for action in env.action_set.values()} - assert { - "BasicSwap", - "ElidePermutations", - "GateDirection", - "LookaheadSwap", - "Optimize1qGatesSimpleCommutation", - "RemoveIdentityEquivalent", - "TrivialLayout", - } <= action_names - - -def test_elide_permutations_tracks_output_permutation(env: PredictorEnv) -> None: - """Eliding a SWAP keeps its output permutation in the established layout.""" - circuit = QuantumCircuit(3) - circuit.swap(0, 1) - circuit.x(0) - _setup_env(env, circuit, None, circuit.num_qubits) - - compiled = env.apply_action(_action_index(env, "ElidePermutations")) - - assert "swap" not in compiled.count_ops() - assert [ - (instruction.operation.name, compiled.find_bit(instruction.qubits[0]).index) for instruction in compiled.data - ] == [("x", 1)] - assert env.layout is not None - assert env.layout.final_index_layout() == [1, 0, 2] - - -def test_gate_direction_routes_adjacent_directional_gate() -> None: - """GateDirection is available once only edge direction remains to be fixed.""" - directional_target = Target(num_qubits=2, description="directional test target") - directional_target.add_instruction(HGate(), {(0,): None, (1,): None}) - directional_target.add_instruction(CXGate(), {(0, 1): None}) - with pytest.warns(UserWarning, match="uni-directional"): - directional_env = PredictorEnv(device=directional_target, reward_function="expected_fidelity") - - circuit = QuantumCircuit(2) - circuit.cx(1, 0) - laid_out, layout = _lay_out(circuit, directional_target) - _setup_env(directional_env, laid_out, layout, circuit.num_qubits) - action_index = _action_index(directional_env, "GateDirection") - - assert _is_available(directional_env, action_index) - compiled = directional_env.apply_action(action_index) - - assert directional_env.is_circuit_routed(compiled, directional_target.build_coupling_map()) - - -def test_optimize_cliffords_collects_standard_clifford_gates(env: PredictorEnv) -> None: - """OptimizeCliffords first collects ordinary gates and decomposes its result.""" - circuit = QuantumCircuit(1) - circuit.h(0) - circuit.h(0) - _setup_env(env, circuit, None, circuit.num_qubits) - - compiled = env.apply_action(_action_index(env, "OptimizeCliffords")) - - assert not compiled.data - - def test_synthesis_actions_produce_native_gates( simple_circuit: QuantumCircuit, env: PredictorEnv, @@ -233,7 +169,12 @@ def test_layout_actions_establish_layout( for idx, action in env.action_set.items(): if action.pass_type != PassType.LAYOUT: continue - _setup_env(env, synthesized, None, synthesized.num_qubits) + circuit = synthesized + if action.name == "ElidePermutations": + circuit = QuantumCircuit(3) + circuit.swap(0, 1) + circuit.x(0) + _setup_env(env, circuit, None, circuit.num_qubits) if not _is_available(env, idx): continue compiled = env.apply_action(idx) @@ -245,6 +186,9 @@ def test_layout_actions_establish_layout( f"{action.name} on {env.device.description} VIOLATED INVARIANT: " f"did not establish valid layout. Layout: {env.layout}" ) + if action.name == "ElidePermutations": + assert "swap" not in compiled.count_ops() + assert env.layout.final_index_layout() == [1, 0, 2] assert applied_actions > 0 @@ -372,3 +316,10 @@ def test_optimization_actions_preserve_invariants( f"Device native gates: {env.device.operation_names}. " f"Circuit gates: {set(compiled.count_ops().keys())}" ) + + if action.name == "OptimizeCliffords": + clifford_circuit = QuantumCircuit(1) + clifford_circuit.h(0) + clifford_circuit.h(0) + _setup_env(env, clifford_circuit, None, clifford_circuit.num_qubits) + assert not env.apply_action(idx).data diff --git a/tests/compilation/test_predictor_rl.py b/tests/compilation/test_predictor_rl.py index c057431d6..72ce008ea 100644 --- a/tests/compilation/test_predictor_rl.py +++ b/tests/compilation/test_predictor_rl.py @@ -149,15 +149,32 @@ def test_qcompile_with_false_input() -> None: rl_compile(qc, device=None, figure_of_merit="expected_fidelity") -def test_warning_for_unidirectional_device() -> None: - """Test the warning for a unidirectional device.""" +def test_unidirectional_device_warning_and_gate_direction() -> None: + """Test warning and gate-direction routing for a unidirectional device.""" target = Target() target.add_instruction(CXGate(), {(0, 1): InstructionProperties()}) target.description = "uni-directional device" msg = "The connectivity of the device 'uni-directional device' is uni-directional and MQT Predictor might return a compiled circuit that assumes bi-directionality." with pytest.warns(UserWarning, match=re.escape(msg)): - Predictor(figure_of_merit="expected_fidelity", device=target) + predictor = Predictor(figure_of_merit="expected_fidelity", device=target) + + env = predictor.env + qc = QuantumCircuit(2) + qc.cx(1, 0) + env.reset(qc) + env.layout = TranspileLayout( + initial_layout=Layout({qubit: index for index, qubit in enumerate(qc.qubits)}), + input_qubit_mapping={qubit: index for index, qubit in enumerate(qc.qubits)}, + final_layout=None, + _output_qubit_list=qc.qubits, + _input_qubit_count=qc.num_qubits, + ) + env.valid_actions = env.determine_valid_actions_for_state() + action_index = next(index for index in env.actions_routing_indices if env.action_set[index].name == "GateDirection") + + assert env.action_masks()[action_index] + assert env.is_circuit_routed(env.apply_action(action_index), target.build_coupling_map()) def test_predictor_env_truncates_at_max_steps() -> None: From f8aaad9ef0bfc5db3b959d8f3f25cb1247933553 Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Thu, 27 Aug 2026 09:17:42 +0200 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=A7=AA=20Cover=20Qiskit=20actions?= =?UTF-8?q?=20through=20pass=20invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- .../test_integration_further_SDKs.py | 89 ++++++++++--------- tests/compilation/test_predictor_rl.py | 23 +---- 2 files changed, 48 insertions(+), 64 deletions(-) diff --git a/tests/compilation/test_integration_further_SDKs.py b/tests/compilation/test_integration_further_SDKs.py index 61cb6b1c8..03284bc21 100644 --- a/tests/compilation/test_integration_further_SDKs.py +++ b/tests/compilation/test_integration_further_SDKs.py @@ -10,13 +10,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING - import pytest from mqt.bench.targets import get_device from qiskit import QuantumCircuit from qiskit.circuit import StandardEquivalenceLibrary -from qiskit.transpiler import PassManager, TranspileLayout +from qiskit.circuit.library import CXGate, HGate +from qiskit.transpiler import PassManager, Target, TranspileLayout from qiskit.transpiler.passes import ( ApplyLayout, BasisTranslator, @@ -29,9 +28,6 @@ from mqt.predictor.rl.actions import CompilationOrigin, PassType from mqt.predictor.rl.predictorenv import PredictorEnv -if TYPE_CHECKING: - from qiskit.transpiler import Target - def _setup_env(env: PredictorEnv, circuit: QuantumCircuit, layout: TranspileLayout | None, n_qubits: int) -> None: """Reset env to the given circuit/layout state without starting a full RL episode.""" @@ -126,6 +122,16 @@ def env(target: Target) -> PredictorEnv: return PredictorEnv(device=target, reward_function="expected_fidelity") +@pytest.fixture +def directional_env() -> PredictorEnv: + """Create an environment for direction-sensitive routing actions.""" + target = Target(num_qubits=2, description="directional test target") + target.add_instruction(HGate(), {(0,): None, (1,): None}) + target.add_instruction(CXGate(), {(0, 1): None}) + with pytest.warns(UserWarning, match="uni-directional"): + return PredictorEnv(device=target, reward_function="expected_fidelity") + + def test_synthesis_actions_produce_native_gates( simple_circuit: QuantumCircuit, env: PredictorEnv, @@ -169,12 +175,7 @@ def test_layout_actions_establish_layout( for idx, action in env.action_set.items(): if action.pass_type != PassType.LAYOUT: continue - circuit = synthesized - if action.name == "ElidePermutations": - circuit = QuantumCircuit(3) - circuit.swap(0, 1) - circuit.x(0) - _setup_env(env, circuit, None, circuit.num_qubits) + _setup_env(env, synthesized, None, synthesized.num_qubits) if not _is_available(env, idx): continue compiled = env.apply_action(idx) @@ -186,9 +187,6 @@ def test_layout_actions_establish_layout( f"{action.name} on {env.device.description} VIOLATED INVARIANT: " f"did not establish valid layout. Layout: {env.layout}" ) - if action.name == "ElidePermutations": - assert "swap" not in compiled.count_ops() - assert env.layout.final_index_layout() == [1, 0, 2] assert applied_actions > 0 @@ -229,38 +227,48 @@ def test_mapping_actions_establish_layout_and_route( def test_routing_actions_route_circuit( simple_circuit: QuantumCircuit, env: PredictorEnv, + directional_env: PredictorEnv, ) -> None: """Invariant: every routing action produces a circuit where all 2-qubit gates respect the coupling map.""" - coupling_map = env.device.build_coupling_map() - applied_actions = 0 - for idx, action in env.action_set.items(): if action.pass_type != PassType.ROUTING: continue + qc_laid_out, layout = _lay_out(simple_circuit, env.device) - n_qubits = qc_laid_out.num_qubits - _setup_env(env, qc_laid_out, layout, n_qubits) - if not _is_available(env, idx): - continue - routed = env.apply_action(idx) - applied_actions += 1 - assert env.is_circuit_routed(routed, coupling_map), ( - f"{action.name} on {env.device.description} VIOLATED INVARIANT: circuit not properly routed after action" + directional_circuit = QuantumCircuit(2) + directional_circuit.cx(1, 0) + directional_laid_out, directional_layout = _lay_out(directional_circuit, directional_env.device) + test_cases = ( + (env, qc_laid_out, layout), + (directional_env, directional_laid_out, directional_layout), + ) + + for action_env, circuit, action_layout in test_cases: + n_qubits = circuit.num_qubits + _setup_env(action_env, circuit, action_layout, n_qubits) + if _is_available(action_env, idx): + routed = action_env.apply_action(idx) + break + else: + pytest.fail(f"{action.name} was unavailable for all routing test cases") + + coupling_map = action_env.device.build_coupling_map() + assert action_env.is_circuit_routed(routed, coupling_map), ( + f"{action.name} on {action_env.device.description} VIOLATED INVARIANT: " + "circuit not properly routed after action" ) # Check BQSKit routing translates its output permutation into Qiskit layout bookkeeping correctly. if action.origin == CompilationOrigin.BQSKIT: - assert env.layout is not None - assert env.layout.final_layout is not None - assert set(env.layout.final_layout.get_virtual_bits()).issubset(routed.qubits) - assert env.layout._output_qubit_list == routed.qubits # ruff: ignore[private-member-access] - - _setup_env(env, routed, env.layout, n_qubits) - rerouted = env.apply_action(idx) - assert env.layout.final_layout is not None - assert set(env.layout.final_layout.get_virtual_bits()).issubset(rerouted.qubits) - assert env.layout._output_qubit_list == rerouted.qubits # ruff: ignore[private-member-access] + assert action_env.layout is not None + assert action_env.layout.final_layout is not None + assert set(action_env.layout.final_layout.get_virtual_bits()).issubset(routed.qubits) + assert action_env.layout._output_qubit_list == routed.qubits # ruff: ignore[private-member-access] - assert applied_actions > 0 + _setup_env(action_env, routed, action_env.layout, n_qubits) + rerouted = action_env.apply_action(idx) + assert action_env.layout.final_layout is not None + assert set(action_env.layout.final_layout.get_virtual_bits()).issubset(rerouted.qubits) + assert action_env.layout._output_qubit_list == rerouted.qubits # ruff: ignore[private-member-access] def test_optimization_actions_preserve_invariants( @@ -316,10 +324,3 @@ def test_optimization_actions_preserve_invariants( f"Device native gates: {env.device.operation_names}. " f"Circuit gates: {set(compiled.count_ops().keys())}" ) - - if action.name == "OptimizeCliffords": - clifford_circuit = QuantumCircuit(1) - clifford_circuit.h(0) - clifford_circuit.h(0) - _setup_env(env, clifford_circuit, None, clifford_circuit.num_qubits) - assert not env.apply_action(idx).data diff --git a/tests/compilation/test_predictor_rl.py b/tests/compilation/test_predictor_rl.py index 72ce008ea..c057431d6 100644 --- a/tests/compilation/test_predictor_rl.py +++ b/tests/compilation/test_predictor_rl.py @@ -149,32 +149,15 @@ def test_qcompile_with_false_input() -> None: rl_compile(qc, device=None, figure_of_merit="expected_fidelity") -def test_unidirectional_device_warning_and_gate_direction() -> None: - """Test warning and gate-direction routing for a unidirectional device.""" +def test_warning_for_unidirectional_device() -> None: + """Test the warning for a unidirectional device.""" target = Target() target.add_instruction(CXGate(), {(0, 1): InstructionProperties()}) target.description = "uni-directional device" msg = "The connectivity of the device 'uni-directional device' is uni-directional and MQT Predictor might return a compiled circuit that assumes bi-directionality." with pytest.warns(UserWarning, match=re.escape(msg)): - predictor = Predictor(figure_of_merit="expected_fidelity", device=target) - - env = predictor.env - qc = QuantumCircuit(2) - qc.cx(1, 0) - env.reset(qc) - env.layout = TranspileLayout( - initial_layout=Layout({qubit: index for index, qubit in enumerate(qc.qubits)}), - input_qubit_mapping={qubit: index for index, qubit in enumerate(qc.qubits)}, - final_layout=None, - _output_qubit_list=qc.qubits, - _input_qubit_count=qc.num_qubits, - ) - env.valid_actions = env.determine_valid_actions_for_state() - action_index = next(index for index in env.actions_routing_indices if env.action_set[index].name == "GateDirection") - - assert env.action_masks()[action_index] - assert env.is_circuit_routed(env.apply_action(action_index), target.build_coupling_map()) + Predictor(figure_of_merit="expected_fidelity", device=target) def test_predictor_env_truncates_at_max_steps() -> None: From bcf2df6f74fc75a7f9700505ff36b0b70ed18852 Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Thu, 27 Aug 2026 09:39:22 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Assume=20bidirection?= =?UTF-8?q?al=20device=20coupling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- CHANGELOG.md | 2 +- UPGRADING.md | 3 +- .../predictor/rl/actions/qiskit_actions.py | 20 +---- src/mqt/predictor/rl/predictorenv.py | 2 +- .../test_integration_further_SDKs.py | 74 ++++++++----------- 5 files changed, 33 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6529b3ceb..723a857db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ releases may include breaking changes. ### Added - ✨ Add Qiskit's `TrivialLayout`, `ElidePermutations`, `SabreSwap`, - `BasicSwap`, `LookaheadSwap`, `GateDirection`, `RemoveIdentityEquivalent`, and + `BasicSwap`, `LookaheadSwap`, `RemoveIdentityEquivalent`, and `Optimize1qGatesSimpleCommutation` passes to the RL actions ([#759]) ([**@flowerthrower**]) - ✨ Expand and compact the RL observation with normalized OpenQASM operation diff --git a/UPGRADING.md b/UPGRADING.md index 8ec563560..85f61948f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -11,8 +11,7 @@ of changes including minor and patch releases, please refer to the The RL action space now includes the following Qiskit passes: - the `TrivialLayout` and `ElidePermutations` layout actions; -- the `SabreSwap`, `BasicSwap`, `LookaheadSwap`, and `GateDirection` routing - actions; and +- the `SabreSwap`, `BasicSwap`, and `LookaheadSwap` routing actions; and - the `RemoveIdentityEquivalent` and `Optimize1qGatesSimpleCommutation` optimization actions. diff --git a/src/mqt/predictor/rl/actions/qiskit_actions.py b/src/mqt/predictor/rl/actions/qiskit_actions.py index 5e18dfacf..a26fad72c 100644 --- a/src/mqt/predictor/rl/actions/qiskit_actions.py +++ b/src/mqt/predictor/rl/actions/qiskit_actions.py @@ -50,7 +50,6 @@ EnlargeWithAncilla, FixedPoint, FullAncillaAllocation, - GateDirection, GatesInBasis, InverseCancellation, LookaheadSwap, @@ -350,15 +349,6 @@ def qiskit_routing_actions() -> list[Action]: ], ), ), - DeferredDeviceAction( - "GateDirection", - CompilationOrigin.QISKIT, - PassType.ROUTING, - transpile_pass=lambda device: cast( - "list[Task]", - [GateDirection(coupling_map=CouplingMap(device.build_coupling_map()), target=device)], - ), - ), ] @@ -486,15 +476,7 @@ def run_qiskit_action( return altered_qc, layout -def is_qiskit_action_available(action: Action, circuit: QuantumCircuit, device: Target) -> bool: +def is_qiskit_action_available(action: Action, device: Target) -> bool: """Return whether a Qiskit action is available for the current device.""" - if action.name == "GateDirection": - undirected_edges = {frozenset(edge) for edge in device.build_coupling_map().get_edges()} - return all( - frozenset(circuit.find_bit(qubit).index for qubit in instruction.qubits) in undirected_edges - for instruction in circuit.data - if len(instruction.qubits) == 2 - ) - # Only allow VF2PostLayout if "ibm" is in the device name # TODO: Why? return action.name != "VF2PostLayout" or "ibm" in device.description diff --git a/src/mqt/predictor/rl/predictorenv.py b/src/mqt/predictor/rl/predictorenv.py index 728aec798..c433ccaa7 100644 --- a/src/mqt/predictor/rl/predictorenv.py +++ b/src/mqt/predictor/rl/predictorenv.py @@ -506,7 +506,7 @@ def action_masks(self) -> list[bool]: action_mask.append(True) continue if action.origin == CompilationOrigin.QISKIT: - action_mask.append(is_qiskit_action_available(action, self.state, self.device)) + action_mask.append(is_qiskit_action_available(action, self.device)) elif action.origin == CompilationOrigin.TKET: action_mask.append(is_tket_action_available(action=action, has_layout=has_layout)) elif action.origin == CompilationOrigin.BQSKIT: diff --git a/tests/compilation/test_integration_further_SDKs.py b/tests/compilation/test_integration_further_SDKs.py index 03284bc21..017794e7b 100644 --- a/tests/compilation/test_integration_further_SDKs.py +++ b/tests/compilation/test_integration_further_SDKs.py @@ -10,12 +10,13 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from mqt.bench.targets import get_device from qiskit import QuantumCircuit from qiskit.circuit import StandardEquivalenceLibrary -from qiskit.circuit.library import CXGate, HGate -from qiskit.transpiler import PassManager, Target, TranspileLayout +from qiskit.transpiler import PassManager, TranspileLayout from qiskit.transpiler.passes import ( ApplyLayout, BasisTranslator, @@ -28,6 +29,9 @@ from mqt.predictor.rl.actions import CompilationOrigin, PassType from mqt.predictor.rl.predictorenv import PredictorEnv +if TYPE_CHECKING: + from qiskit.transpiler import Target + def _setup_env(env: PredictorEnv, circuit: QuantumCircuit, layout: TranspileLayout | None, n_qubits: int) -> None: """Reset env to the given circuit/layout state without starting a full RL episode.""" @@ -122,16 +126,6 @@ def env(target: Target) -> PredictorEnv: return PredictorEnv(device=target, reward_function="expected_fidelity") -@pytest.fixture -def directional_env() -> PredictorEnv: - """Create an environment for direction-sensitive routing actions.""" - target = Target(num_qubits=2, description="directional test target") - target.add_instruction(HGate(), {(0,): None, (1,): None}) - target.add_instruction(CXGate(), {(0, 1): None}) - with pytest.warns(UserWarning, match="uni-directional"): - return PredictorEnv(device=target, reward_function="expected_fidelity") - - def test_synthesis_actions_produce_native_gates( simple_circuit: QuantumCircuit, env: PredictorEnv, @@ -227,48 +221,38 @@ def test_mapping_actions_establish_layout_and_route( def test_routing_actions_route_circuit( simple_circuit: QuantumCircuit, env: PredictorEnv, - directional_env: PredictorEnv, ) -> None: """Invariant: every routing action produces a circuit where all 2-qubit gates respect the coupling map.""" + coupling_map = env.device.build_coupling_map() + applied_actions = 0 + for idx, action in env.action_set.items(): if action.pass_type != PassType.ROUTING: continue - qc_laid_out, layout = _lay_out(simple_circuit, env.device) - directional_circuit = QuantumCircuit(2) - directional_circuit.cx(1, 0) - directional_laid_out, directional_layout = _lay_out(directional_circuit, directional_env.device) - test_cases = ( - (env, qc_laid_out, layout), - (directional_env, directional_laid_out, directional_layout), - ) - - for action_env, circuit, action_layout in test_cases: - n_qubits = circuit.num_qubits - _setup_env(action_env, circuit, action_layout, n_qubits) - if _is_available(action_env, idx): - routed = action_env.apply_action(idx) - break - else: - pytest.fail(f"{action.name} was unavailable for all routing test cases") - - coupling_map = action_env.device.build_coupling_map() - assert action_env.is_circuit_routed(routed, coupling_map), ( - f"{action.name} on {action_env.device.description} VIOLATED INVARIANT: " - "circuit not properly routed after action" + n_qubits = qc_laid_out.num_qubits + _setup_env(env, qc_laid_out, layout, n_qubits) + if not _is_available(env, idx): + continue + routed = env.apply_action(idx) + applied_actions += 1 + assert env.is_circuit_routed(routed, coupling_map), ( + f"{action.name} on {env.device.description} VIOLATED INVARIANT: circuit not properly routed after action" ) # Check BQSKit routing translates its output permutation into Qiskit layout bookkeeping correctly. if action.origin == CompilationOrigin.BQSKIT: - assert action_env.layout is not None - assert action_env.layout.final_layout is not None - assert set(action_env.layout.final_layout.get_virtual_bits()).issubset(routed.qubits) - assert action_env.layout._output_qubit_list == routed.qubits # ruff: ignore[private-member-access] - - _setup_env(action_env, routed, action_env.layout, n_qubits) - rerouted = action_env.apply_action(idx) - assert action_env.layout.final_layout is not None - assert set(action_env.layout.final_layout.get_virtual_bits()).issubset(rerouted.qubits) - assert action_env.layout._output_qubit_list == rerouted.qubits # ruff: ignore[private-member-access] + assert env.layout is not None + assert env.layout.final_layout is not None + assert set(env.layout.final_layout.get_virtual_bits()).issubset(routed.qubits) + assert env.layout._output_qubit_list == routed.qubits # ruff: ignore[private-member-access] + + _setup_env(env, routed, env.layout, n_qubits) + rerouted = env.apply_action(idx) + assert env.layout.final_layout is not None + assert set(env.layout.final_layout.get_virtual_bits()).issubset(rerouted.qubits) + assert env.layout._output_qubit_list == rerouted.qubits # ruff: ignore[private-member-access] + + assert applied_actions > 0 def test_optimization_actions_preserve_invariants( From 8a50c2461939cb24db48e3ea16fc623a826fafb5 Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Thu, 27 Aug 2026 16:33:41 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9D=20Attribute=20Qiskit=20actio?= =?UTF-8?q?n=20changes=20to=20PR=20#794?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 723a857db..e6288b89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ releases may include breaking changes. - ✨ Add Qiskit's `TrivialLayout`, `ElidePermutations`, `SabreSwap`, `BasicSwap`, `LookaheadSwap`, `RemoveIdentityEquivalent`, and - `Optimize1qGatesSimpleCommutation` passes to the RL actions ([#759]) + `Optimize1qGatesSimpleCommutation` passes to the RL actions ([#794]) ([**@flowerthrower**]) - ✨ Expand and compact the RL observation with normalized OpenQASM operation frequencies and one-element `float32` qubit-count and depth arrays, and @@ -28,7 +28,7 @@ releases may include breaking changes. ### Changed - 🐛 Make the `OptimizeCliffords` RL action collect standard Clifford gates - before optimizing them ([#759]) ([**@flowerthrower**]) + before optimizing them ([#794]) ([**@flowerthrower**]) - 🔥 Drop support for Python 3.10 ([#773]) ([**@denialhaag**]) - ♻️ Split RL actions package into `base` and `registry` modules ([#769]) ([**@denialhaag**]) @@ -99,7 +99,7 @@ for previous changelogs._ [#773]: https://github.com/munich-quantum-toolkit/predictor/pull/771 [#769]: https://github.com/munich-quantum-toolkit/predictor/pull/769 -[#759]: https://github.com/munich-quantum-toolkit/predictor/pull/759 +[#794]: https://github.com/munich-quantum-toolkit/predictor/pull/794 [#758]: https://github.com/munich-quantum-toolkit/predictor/pull/758 [#755]: https://github.com/munich-quantum-toolkit/predictor/pull/755 [#731]: https://github.com/munich-quantum-toolkit/predictor/pull/731 From 6fd59d5e323582a34de55d161ebda11c7562ceca Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Fri, 28 Aug 2026 10:21:54 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9C=A8=20Add=20optional=20AI=20routing?= =?UTF-8?q?=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- CHANGELOG.md | 3 +- UPGRADING.md | 13 +++- .../predictor/rl/actions/qiskit_actions.py | 75 ++++++++++++++++++- src/mqt/predictor/rl/actions/registry.py | 2 + tests/compilation/test_helper_rl.py | 57 +++++++++++++- 5 files changed, 145 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6288b89e..ad5762d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ releases may include breaking changes. - ✨ Add Qiskit's `TrivialLayout`, `ElidePermutations`, `SabreSwap`, `BasicSwap`, `LookaheadSwap`, `RemoveIdentityEquivalent`, and - `Optimize1qGatesSimpleCommutation` passes to the RL actions ([#794]) + `Optimize1qGatesSimpleCommutation` passes and the optional IBM-backed + `AIRouting` and `AIRouting_opt` passes to the RL actions ([#794]) ([**@flowerthrower**]) - ✨ Expand and compact the RL observation with normalized OpenQASM operation frequencies and one-element `float32` qubit-count and depth arrays, and diff --git a/UPGRADING.md b/UPGRADING.md index 85f61948f..1965cc466 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -11,7 +11,9 @@ of changes including minor and patch releases, please refer to the The RL action space now includes the following Qiskit passes: - the `TrivialLayout` and `ElidePermutations` layout actions; -- the `SabreSwap`, `BasicSwap`, and `LookaheadSwap` routing actions; and +- the `SabreSwap`, `BasicSwap`, `LookaheadSwap`, and `AIRouting` routing + actions; +- the combined layout-and-routing action `AIRouting_opt`; and - the `RemoveIdentityEquivalent` and `Optimize1qGatesSimpleCommutation` optimization actions. @@ -20,6 +22,15 @@ output permutation remains part of the canonical layout. `OptimizeCliffords` now collects standard Clifford gates before optimizing and decomposes the result for subsequent passes. +`AIRouting` and `AIRouting_opt` are masked when IBM's optional +`qiskit-ibm-transpiler` package cannot be imported. MQT Predictor does not +install that package because its current release pins NetworkX 2.8.5 while MQT +Bench requires NetworkX 2.8.8 or newer, excludes Python 3.14, and imports Qiskit +internals removed in Qiskit 2.5. Consequently, there is currently no supported +MQT Predictor installation that enables these actions. A future compatible IBM +release can be loaded without changing the action schema. Its routing model is +downloaded on first use. + Existing RL models must be retrained because the action-space size and the indices of later actions have changed. Code that persists or selects actions by numeric index must be updated. diff --git a/src/mqt/predictor/rl/actions/qiskit_actions.py b/src/mqt/predictor/rl/actions/qiskit_actions.py index a26fad72c..f42d0de57 100644 --- a/src/mqt/predictor/rl/actions/qiskit_actions.py +++ b/src/mqt/predictor/rl/actions/qiskit_actions.py @@ -11,7 +11,9 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, cast +from functools import cache +from importlib import import_module +from typing import TYPE_CHECKING, Any, cast from qiskit.circuit import StandardEquivalenceLibrary from qiskit.circuit.library import ( @@ -89,6 +91,39 @@ logger = logging.getLogger("mqt-predictor") +_AI_ROUTING_ACTION_NAMES = frozenset({"AIRouting", "AIRouting_opt"}) + + +@cache +def _load_airouting() -> type[Any]: + """Load IBM's optional AI routing pass.""" + try: + module = import_module("qiskit_ibm_transpiler.ai.routing") + except ImportError as exc: + msg = "AIRouting requires a qiskit-ibm-transpiler installation compatible with this environment." + raise RuntimeError(msg) from exc + return cast("type[Any]", vars(module)["AIRouting"]) + + +def _airouting_pass(*, coupling_map: CouplingMap, layout_mode: str) -> Task: + """Construct IBM's local AI routing pass.""" + return _load_airouting()( + coupling_map=coupling_map, + optimization_level=3, + layout_mode=layout_mode, + local_mode=True, + ) + + +@cache +def _is_ai_routing_available() -> bool: + """Return whether IBM's AI routing pass can be imported.""" + try: + _load_airouting() + except RuntimeError: + return False + return True + def qiskit_optimization_actions() -> list[Action]: """Returns the Qiskit optimization actions.""" @@ -352,6 +387,24 @@ def qiskit_routing_actions() -> list[Action]: ] +def qiskit_ai_routing_action() -> Action: + """Return IBM's AI routing action.""" + return DeferredDeviceAction( + "AIRouting", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", + [ + _airouting_pass( + coupling_map=device.build_coupling_map(), + layout_mode="improve", + ) + ], + ), + ) + + def qiskit_mapping_action() -> Action: """Returns the Qiskit mapping action.""" return DeferredDeviceAction( @@ -364,6 +417,24 @@ def qiskit_mapping_action() -> Action: ) +def qiskit_ai_mapping_action() -> Action: + """Return the combined AI layout and routing action.""" + return DeferredDeviceAction( + "AIRouting_opt", + CompilationOrigin.QISKIT, + PassType.MAPPING, + transpile_pass=lambda device: cast( + "list[Task]", + [ + _airouting_pass( + coupling_map=device.build_coupling_map(), + layout_mode="optimize", + ), + ], + ), + ) + + def qiskit_synthesis_action() -> Action: """Returns the Qiskit synthesis action.""" return DeferredDeviceAction( @@ -478,5 +549,7 @@ def run_qiskit_action( def is_qiskit_action_available(action: Action, device: Target) -> bool: """Return whether a Qiskit action is available for the current device.""" + if action.name in _AI_ROUTING_ACTION_NAMES and not _is_ai_routing_available(): + return False # Only allow VF2PostLayout if "ibm" is in the device name # TODO: Why? return action.name != "VF2PostLayout" or "ibm" in device.description diff --git a/src/mqt/predictor/rl/actions/registry.py b/src/mqt/predictor/rl/actions/registry.py index 7f47b523d..95b239839 100644 --- a/src/mqt/predictor/rl/actions/registry.py +++ b/src/mqt/predictor/rl/actions/registry.py @@ -52,7 +52,9 @@ def get_actions_by_pass_type() -> dict[PassType, list[Action]]: for _action in ( *qiskit_actions.qiskit_layout_actions(), *qiskit_actions.qiskit_routing_actions(), + qiskit_actions.qiskit_ai_routing_action(), qiskit_actions.qiskit_mapping_action(), + qiskit_actions.qiskit_ai_mapping_action(), qiskit_actions.qiskit_synthesis_action(), qiskit_actions.qiskit_o3_action(), *qiskit_actions.qiskit_optimization_actions(), diff --git a/tests/compilation/test_helper_rl.py b/tests/compilation/test_helper_rl.py index 5acc1f75e..44e331eb4 100644 --- a/tests/compilation/test_helper_rl.py +++ b/tests/compilation/test_helper_rl.py @@ -19,21 +19,24 @@ from mqt.bench import BenchmarkLevel, get_benchmark from mqt.bench.targets import get_device from qiskit import transpile -from qiskit.transpiler import PassManager +from qiskit.transpiler import CouplingMap, PassManager from qiskit.transpiler.passes.layout.vf2_post_layout import VF2PostLayoutStopReason from mqt.predictor.rl.actions import ( + CompilationOrigin, PassType, get_actions_by_pass_type, + qiskit_actions, ) from mqt.predictor.rl.actions.bqskit_actions import bqskit_to_qiskit, get_bqskit_native_gates -from mqt.predictor.rl.actions.qiskit_actions import postprocess_vf2postlayout +from mqt.predictor.rl.actions.qiskit_actions import is_qiskit_action_available, postprocess_vf2postlayout from mqt.predictor.rl.helper import create_feature_dict, get_path_trained_model, get_path_training_circuits from mqt.predictor.utils import get_openqasm_gates if TYPE_CHECKING: from collections.abc import Callable + import pytest from qiskit.passmanager.base_tasks import Task from qiskit.transpiler import Target @@ -99,6 +102,56 @@ def test_bqskit_to_qiskit_converts_u1q_to_r_gate() -> None: assert qc.data[0].operation.params == [0.1, 0.2] +def test_ai_routing_action_factories(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the IBM AI routing action wiring without loading its model.""" + + class FakeAIRouting: + """Record the arguments used to construct an AI routing pass.""" + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + + monkeypatch.setattr(qiskit_actions, "_load_airouting", lambda: FakeAIRouting) + + device = get_device("ibm_falcon_27") + actions = { + action.name: action + for action in ( + *get_actions_by_pass_type()[PassType.ROUTING], + *get_actions_by_pass_type()[PassType.MAPPING], + ) + } + + routing_action = actions["AIRouting"] + assert routing_action.origin == CompilationOrigin.QISKIT + assert routing_action.pass_type == PassType.ROUTING + routing_factory = cast("Callable[[Target], list[Task]]", routing_action.transpile_pass) + routing_passes = routing_factory(device) + assert len(routing_passes) == 1 + ai_routing = cast("FakeAIRouting", routing_passes[0]) + routing_coupling_map = cast("CouplingMap", ai_routing.kwargs["coupling_map"]) + assert routing_coupling_map.get_edges() == device.build_coupling_map().get_edges() + assert ai_routing.kwargs["optimization_level"] == 3 + assert ai_routing.kwargs["layout_mode"] == "improve" + assert ai_routing.kwargs["local_mode"] is True + + mapping_action = actions["AIRouting_opt"] + assert mapping_action.origin == CompilationOrigin.QISKIT + assert mapping_action.pass_type == PassType.MAPPING + mapping_factory = cast("Callable[[Target], list[Task]]", mapping_action.transpile_pass) + mapping_passes = mapping_factory(device) + assert len(mapping_passes) == 1 + ai_mapping = cast("FakeAIRouting", mapping_passes[0]) + mapping_coupling_map = cast("CouplingMap", ai_mapping.kwargs["coupling_map"]) + assert mapping_coupling_map.get_edges() == device.build_coupling_map().get_edges() + assert ai_mapping.kwargs["optimization_level"] == 3 + assert ai_mapping.kwargs["layout_mode"] == "optimize" + assert ai_mapping.kwargs["local_mode"] is True + + monkeypatch.setattr(qiskit_actions, "_is_ai_routing_available", lambda: False) + assert not is_qiskit_action_available(routing_action, device) + + def test_vf2_layout_and_postlayout() -> None: """Test the VF2Layout and VF2PostLayout passes.""" qc = get_benchmark("ghz", BenchmarkLevel.ALG, 3) From 72d115c6d6e27e35a42ff488d4eb129fd92faea3 Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Fri, 28 Aug 2026 10:40:09 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=90=9B=20Correct=20Qiskit=20action?= =?UTF-8?q?=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- CHANGELOG.md | 3 +- UPGRADING.md | 13 +-- .../predictor/rl/actions/qiskit_actions.py | 92 +++---------------- src/mqt/predictor/rl/actions/registry.py | 2 - tests/compilation/test_helper_rl.py | 57 +----------- tests/compilation/test_predictor_rl.py | 84 +++++++++-------- 6 files changed, 63 insertions(+), 188 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5762d16..e6288b89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,7 @@ releases may include breaking changes. - ✨ Add Qiskit's `TrivialLayout`, `ElidePermutations`, `SabreSwap`, `BasicSwap`, `LookaheadSwap`, `RemoveIdentityEquivalent`, and - `Optimize1qGatesSimpleCommutation` passes and the optional IBM-backed - `AIRouting` and `AIRouting_opt` passes to the RL actions ([#794]) + `Optimize1qGatesSimpleCommutation` passes to the RL actions ([#794]) ([**@flowerthrower**]) - ✨ Expand and compact the RL observation with normalized OpenQASM operation frequencies and one-element `float32` qubit-count and depth arrays, and diff --git a/UPGRADING.md b/UPGRADING.md index 1965cc466..85f61948f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -11,9 +11,7 @@ of changes including minor and patch releases, please refer to the The RL action space now includes the following Qiskit passes: - the `TrivialLayout` and `ElidePermutations` layout actions; -- the `SabreSwap`, `BasicSwap`, `LookaheadSwap`, and `AIRouting` routing - actions; -- the combined layout-and-routing action `AIRouting_opt`; and +- the `SabreSwap`, `BasicSwap`, and `LookaheadSwap` routing actions; and - the `RemoveIdentityEquivalent` and `Optimize1qGatesSimpleCommutation` optimization actions. @@ -22,15 +20,6 @@ output permutation remains part of the canonical layout. `OptimizeCliffords` now collects standard Clifford gates before optimizing and decomposes the result for subsequent passes. -`AIRouting` and `AIRouting_opt` are masked when IBM's optional -`qiskit-ibm-transpiler` package cannot be imported. MQT Predictor does not -install that package because its current release pins NetworkX 2.8.5 while MQT -Bench requires NetworkX 2.8.8 or newer, excludes Python 3.14, and imports Qiskit -internals removed in Qiskit 2.5. Consequently, there is currently no supported -MQT Predictor installation that enables these actions. A future compatible IBM -release can be loaded without changing the action schema. Its routing model is -downloaded on first use. - Existing RL models must be retrained because the action-space size and the indices of later actions have changed. Code that persists or selects actions by numeric index must be updated. diff --git a/src/mqt/predictor/rl/actions/qiskit_actions.py b/src/mqt/predictor/rl/actions/qiskit_actions.py index f42d0de57..9860c36ed 100644 --- a/src/mqt/predictor/rl/actions/qiskit_actions.py +++ b/src/mqt/predictor/rl/actions/qiskit_actions.py @@ -11,9 +11,7 @@ from __future__ import annotations import logging -from functools import cache -from importlib import import_module -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast from qiskit.circuit import StandardEquivalenceLibrary from qiskit.circuit.library import ( @@ -46,6 +44,7 @@ CommutativeCancellation, CommutativeInverseCancellation, ConsolidateBlocks, + Decompose, DenseLayout, Depth, ElidePermutations, @@ -91,39 +90,6 @@ logger = logging.getLogger("mqt-predictor") -_AI_ROUTING_ACTION_NAMES = frozenset({"AIRouting", "AIRouting_opt"}) - - -@cache -def _load_airouting() -> type[Any]: - """Load IBM's optional AI routing pass.""" - try: - module = import_module("qiskit_ibm_transpiler.ai.routing") - except ImportError as exc: - msg = "AIRouting requires a qiskit-ibm-transpiler installation compatible with this environment." - raise RuntimeError(msg) from exc - return cast("type[Any]", vars(module)["AIRouting"]) - - -def _airouting_pass(*, coupling_map: CouplingMap, layout_mode: str) -> Task: - """Construct IBM's local AI routing pass.""" - return _load_airouting()( - coupling_map=coupling_map, - optimization_level=3, - layout_mode=layout_mode, - local_mode=True, - ) - - -@cache -def _is_ai_routing_available() -> bool: - """Return whether IBM's AI routing pass can be imported.""" - try: - _load_airouting() - except RuntimeError: - return False - return True - def qiskit_optimization_actions() -> list[Action]: """Returns the Qiskit optimization actions.""" @@ -192,7 +158,11 @@ def qiskit_optimization_actions() -> list[Action]: "OptimizeCliffords", CompilationOrigin.QISKIT, PassType.OPT, - [CollectCliffords(), OptimizeCliffords()], + [ + CollectCliffords(), + OptimizeCliffords(), + Decompose(gates_to_decompose="clifford", apply_synthesis=True), + ], preserves_layout=True, preserves_routing=False, preserves_synthesis=False, @@ -387,24 +357,6 @@ def qiskit_routing_actions() -> list[Action]: ] -def qiskit_ai_routing_action() -> Action: - """Return IBM's AI routing action.""" - return DeferredDeviceAction( - "AIRouting", - CompilationOrigin.QISKIT, - PassType.ROUTING, - transpile_pass=lambda device: cast( - "list[Task]", - [ - _airouting_pass( - coupling_map=device.build_coupling_map(), - layout_mode="improve", - ) - ], - ), - ) - - def qiskit_mapping_action() -> Action: """Returns the Qiskit mapping action.""" return DeferredDeviceAction( @@ -417,24 +369,6 @@ def qiskit_mapping_action() -> Action: ) -def qiskit_ai_mapping_action() -> Action: - """Return the combined AI layout and routing action.""" - return DeferredDeviceAction( - "AIRouting_opt", - CompilationOrigin.QISKIT, - PassType.MAPPING, - transpile_pass=lambda device: cast( - "list[Task]", - [ - _airouting_pass( - coupling_map=device.build_coupling_map(), - layout_mode="optimize", - ), - ], - ), - ) - - def qiskit_synthesis_action() -> Action: """Returns the Qiskit synthesis action.""" return DeferredDeviceAction( @@ -536,20 +470,20 @@ def run_qiskit_action( if action.pass_type in {PassType.LAYOUT, PassType.MAPPING, PassType.FINAL_OPT}: altered_qc, layout = _postprocess_layout_action(action, pm.property_set, altered_qc, layout, input_qubit_count) elif action.pass_type == PassType.ROUTING and layout and pm.property_set["final_layout"] is not None: - layout.final_layout = pm.property_set["final_layout"] + routing_layout = pm.property_set["final_layout"] + layout.final_layout = ( + layout.final_layout.compose(routing_layout, circuit.qubits) + if layout.final_layout is not None + else routing_layout + ) if altered_qc.count_ops().get("unitary"): # Custom "unitary" gates can not be processed further by other passes altered_qc = altered_qc.decompose(gates_to_decompose="unitary") - if altered_qc.count_ops().get("clifford"): - altered_qc = altered_qc.decompose(gates_to_decompose="clifford") - return altered_qc, layout def is_qiskit_action_available(action: Action, device: Target) -> bool: """Return whether a Qiskit action is available for the current device.""" - if action.name in _AI_ROUTING_ACTION_NAMES and not _is_ai_routing_available(): - return False # Only allow VF2PostLayout if "ibm" is in the device name # TODO: Why? return action.name != "VF2PostLayout" or "ibm" in device.description diff --git a/src/mqt/predictor/rl/actions/registry.py b/src/mqt/predictor/rl/actions/registry.py index 95b239839..7f47b523d 100644 --- a/src/mqt/predictor/rl/actions/registry.py +++ b/src/mqt/predictor/rl/actions/registry.py @@ -52,9 +52,7 @@ def get_actions_by_pass_type() -> dict[PassType, list[Action]]: for _action in ( *qiskit_actions.qiskit_layout_actions(), *qiskit_actions.qiskit_routing_actions(), - qiskit_actions.qiskit_ai_routing_action(), qiskit_actions.qiskit_mapping_action(), - qiskit_actions.qiskit_ai_mapping_action(), qiskit_actions.qiskit_synthesis_action(), qiskit_actions.qiskit_o3_action(), *qiskit_actions.qiskit_optimization_actions(), diff --git a/tests/compilation/test_helper_rl.py b/tests/compilation/test_helper_rl.py index 44e331eb4..5acc1f75e 100644 --- a/tests/compilation/test_helper_rl.py +++ b/tests/compilation/test_helper_rl.py @@ -19,24 +19,21 @@ from mqt.bench import BenchmarkLevel, get_benchmark from mqt.bench.targets import get_device from qiskit import transpile -from qiskit.transpiler import CouplingMap, PassManager +from qiskit.transpiler import PassManager from qiskit.transpiler.passes.layout.vf2_post_layout import VF2PostLayoutStopReason from mqt.predictor.rl.actions import ( - CompilationOrigin, PassType, get_actions_by_pass_type, - qiskit_actions, ) from mqt.predictor.rl.actions.bqskit_actions import bqskit_to_qiskit, get_bqskit_native_gates -from mqt.predictor.rl.actions.qiskit_actions import is_qiskit_action_available, postprocess_vf2postlayout +from mqt.predictor.rl.actions.qiskit_actions import postprocess_vf2postlayout from mqt.predictor.rl.helper import create_feature_dict, get_path_trained_model, get_path_training_circuits from mqt.predictor.utils import get_openqasm_gates if TYPE_CHECKING: from collections.abc import Callable - import pytest from qiskit.passmanager.base_tasks import Task from qiskit.transpiler import Target @@ -102,56 +99,6 @@ def test_bqskit_to_qiskit_converts_u1q_to_r_gate() -> None: assert qc.data[0].operation.params == [0.1, 0.2] -def test_ai_routing_action_factories(monkeypatch: pytest.MonkeyPatch) -> None: - """Test the IBM AI routing action wiring without loading its model.""" - - class FakeAIRouting: - """Record the arguments used to construct an AI routing pass.""" - - def __init__(self, **kwargs: object) -> None: - self.kwargs = kwargs - - monkeypatch.setattr(qiskit_actions, "_load_airouting", lambda: FakeAIRouting) - - device = get_device("ibm_falcon_27") - actions = { - action.name: action - for action in ( - *get_actions_by_pass_type()[PassType.ROUTING], - *get_actions_by_pass_type()[PassType.MAPPING], - ) - } - - routing_action = actions["AIRouting"] - assert routing_action.origin == CompilationOrigin.QISKIT - assert routing_action.pass_type == PassType.ROUTING - routing_factory = cast("Callable[[Target], list[Task]]", routing_action.transpile_pass) - routing_passes = routing_factory(device) - assert len(routing_passes) == 1 - ai_routing = cast("FakeAIRouting", routing_passes[0]) - routing_coupling_map = cast("CouplingMap", ai_routing.kwargs["coupling_map"]) - assert routing_coupling_map.get_edges() == device.build_coupling_map().get_edges() - assert ai_routing.kwargs["optimization_level"] == 3 - assert ai_routing.kwargs["layout_mode"] == "improve" - assert ai_routing.kwargs["local_mode"] is True - - mapping_action = actions["AIRouting_opt"] - assert mapping_action.origin == CompilationOrigin.QISKIT - assert mapping_action.pass_type == PassType.MAPPING - mapping_factory = cast("Callable[[Target], list[Task]]", mapping_action.transpile_pass) - mapping_passes = mapping_factory(device) - assert len(mapping_passes) == 1 - ai_mapping = cast("FakeAIRouting", mapping_passes[0]) - mapping_coupling_map = cast("CouplingMap", ai_mapping.kwargs["coupling_map"]) - assert mapping_coupling_map.get_edges() == device.build_coupling_map().get_edges() - assert ai_mapping.kwargs["optimization_level"] == 3 - assert ai_mapping.kwargs["layout_mode"] == "optimize" - assert ai_mapping.kwargs["local_mode"] is True - - monkeypatch.setattr(qiskit_actions, "_is_ai_routing_available", lambda: False) - assert not is_qiskit_action_available(routing_action, device) - - def test_vf2_layout_and_postlayout() -> None: """Test the VF2Layout and VF2PostLayout passes.""" qc = get_benchmark("ghz", BenchmarkLevel.ALG, 3) diff --git a/tests/compilation/test_predictor_rl.py b/tests/compilation/test_predictor_rl.py index c057431d6..b816369dd 100644 --- a/tests/compilation/test_predictor_rl.py +++ b/tests/compilation/test_predictor_rl.py @@ -20,6 +20,7 @@ from qiskit import QuantumCircuit from qiskit.circuit.library import CXGate from qiskit.qasm2 import dump +from qiskit.quantum_info import Clifford from qiskit.transpiler import InstructionProperties, Layout, Target, TranspileLayout from qiskit.transpiler.passes import GatesInBasis @@ -30,7 +31,6 @@ DeviceIndependentAction, PassType, get_actions_by_pass_type, - qiskit_actions, register_action, ) from mqt.predictor.rl.actions import registry as actions_registry_module @@ -263,46 +263,54 @@ def test_predictor_env_actions_for_mdp_state( assert set(valid_actions) == expected_actions -def test_predictor_env_qiskit_routing_updates_final_layout(monkeypatch: pytest.MonkeyPatch) -> None: - """Test that Qiskit routing actions update the tracked final layout.""" - device = get_device("ibm_falcon_27") - env = predictorenv_module.PredictorEnv(device=device) - qc = QuantumCircuit(2) - qc.cx(0, 1) - env.reset(qc) - - initial_layout = Layout({qubit: index for index, qubit in enumerate(qc.qubits)}) - final_layout = Layout({qc.qubits[0]: 1, qc.qubits[1]: 0}) - env.layout = TranspileLayout( - initial_layout=initial_layout, - input_qubit_mapping={qubit: index for index, qubit in enumerate(qc.qubits)}, - final_layout=None, - _output_qubit_list=qc.qubits, - _input_qubit_count=qc.num_qubits, +def test_predictor_env_qiskit_routing_composes_final_layout() -> None: + """Test that Qiskit routing composes an existing output permutation.""" + target = Target(num_qubits=3, description="bidirectional line") + target.add_instruction( + CXGate(), + { + (0, 1): InstructionProperties(), + (1, 0): InstructionProperties(), + (1, 2): InstructionProperties(), + (2, 1): InstructionProperties(), + }, ) - - class FakePassManager: - """Minimal PassManager replacement that exposes a final layout.""" - - def __init__(self, _passes: object) -> None: - self.property_set = {"final_layout": final_layout} - - def run(self, circuit: QuantumCircuit) -> QuantumCircuit: - return circuit - - monkeypatch.setattr(qiskit_actions, "PassManager", FakePassManager) - action = DeviceIndependentAction( - name="SyntheticQiskitRouting", - pass_type=PassType.ROUTING, - transpile_pass=[], - origin=CompilationOrigin.QISKIT, + env = predictorenv_module.PredictorEnv(device=target) + circuit = QuantumCircuit(3) + circuit.swap(0, 1) + circuit.cx(1, 2) + env.reset(circuit) + + elide_index = next(index for index, action in env.action_set.items() if action.name == "ElidePermutations") + env.state = env.apply_action(elide_index) + assert env.layout is not None + assert env.layout.final_index_layout() == [1, 0, 2] + + basic_swap_index = next(index for index, action in env.action_set.items() if action.name == "BasicSwap") + env.state = env.apply_action(basic_swap_index) + assert env.layout.final_index_layout() == [0, 1, 2] + + +def test_clifford_decomposition_is_scoped_to_optimize_cliffords() -> None: + """Test that only OptimizeCliffords decomposes Clifford operations.""" + env = predictorenv_module.PredictorEnv(device=get_device("ibm_falcon_27")) + definition = QuantumCircuit(1) + definition.h(0) + circuit = QuantumCircuit(1) + circuit.append(Clifford(definition), [0]) + env.reset(circuit) + + remove_identity_index = next( + index for index, action in env.action_set.items() if action.name == "RemoveIdentityEquivalent" ) - routing_action_index = next(iter(env.actions_routing_indices)) - env.action_set[routing_action_index] = action - altered_qc = env.apply_action(action_index=routing_action_index) + unchanged = env.apply_action(remove_identity_index) + assert unchanged.count_ops() == {"clifford": 1} - assert altered_qc is env.state - assert env.layout.final_layout is final_layout + optimize_cliffords_index = next( + index for index, action in env.action_set.items() if action.name == "OptimizeCliffords" + ) + optimized = env.apply_action(optimize_cliffords_index) + assert "clifford" not in optimized.count_ops() def test_register_action(monkeypatch: pytest.MonkeyPatch) -> None: From b78066780a0b8e6c51144858cfcfde7d48d01f25 Mon Sep 17 00:00:00 2001 From: flowerthrower Date: Fri, 28 Aug 2026 10:21:54 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=E2=9C=A8=20Add=20optional=20AI=20routing?= =?UTF-8?q?=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flowerthrower Assisted-by: GPT 5.6 via Codex --- CHANGELOG.md | 3 +- UPGRADING.md | 13 +++- .../predictor/rl/actions/qiskit_actions.py | 75 ++++++++++++++++++- src/mqt/predictor/rl/actions/registry.py | 2 + tests/compilation/test_helper_rl.py | 57 +++++++++++++- 5 files changed, 145 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6288b89e..ad5762d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ releases may include breaking changes. - ✨ Add Qiskit's `TrivialLayout`, `ElidePermutations`, `SabreSwap`, `BasicSwap`, `LookaheadSwap`, `RemoveIdentityEquivalent`, and - `Optimize1qGatesSimpleCommutation` passes to the RL actions ([#794]) + `Optimize1qGatesSimpleCommutation` passes and the optional IBM-backed + `AIRouting` and `AIRouting_opt` passes to the RL actions ([#794]) ([**@flowerthrower**]) - ✨ Expand and compact the RL observation with normalized OpenQASM operation frequencies and one-element `float32` qubit-count and depth arrays, and diff --git a/UPGRADING.md b/UPGRADING.md index 85f61948f..1965cc466 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -11,7 +11,9 @@ of changes including minor and patch releases, please refer to the The RL action space now includes the following Qiskit passes: - the `TrivialLayout` and `ElidePermutations` layout actions; -- the `SabreSwap`, `BasicSwap`, and `LookaheadSwap` routing actions; and +- the `SabreSwap`, `BasicSwap`, `LookaheadSwap`, and `AIRouting` routing + actions; +- the combined layout-and-routing action `AIRouting_opt`; and - the `RemoveIdentityEquivalent` and `Optimize1qGatesSimpleCommutation` optimization actions. @@ -20,6 +22,15 @@ output permutation remains part of the canonical layout. `OptimizeCliffords` now collects standard Clifford gates before optimizing and decomposes the result for subsequent passes. +`AIRouting` and `AIRouting_opt` are masked when IBM's optional +`qiskit-ibm-transpiler` package cannot be imported. MQT Predictor does not +install that package because its current release pins NetworkX 2.8.5 while MQT +Bench requires NetworkX 2.8.8 or newer, excludes Python 3.14, and imports Qiskit +internals removed in Qiskit 2.5. Consequently, there is currently no supported +MQT Predictor installation that enables these actions. A future compatible IBM +release can be loaded without changing the action schema. Its routing model is +downloaded on first use. + Existing RL models must be retrained because the action-space size and the indices of later actions have changed. Code that persists or selects actions by numeric index must be updated. diff --git a/src/mqt/predictor/rl/actions/qiskit_actions.py b/src/mqt/predictor/rl/actions/qiskit_actions.py index 9860c36ed..d8e1b3538 100644 --- a/src/mqt/predictor/rl/actions/qiskit_actions.py +++ b/src/mqt/predictor/rl/actions/qiskit_actions.py @@ -11,7 +11,9 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, cast +from functools import cache +from importlib import import_module +from typing import TYPE_CHECKING, Any, cast from qiskit.circuit import StandardEquivalenceLibrary from qiskit.circuit.library import ( @@ -90,6 +92,39 @@ logger = logging.getLogger("mqt-predictor") +_AI_ROUTING_ACTION_NAMES = frozenset({"AIRouting", "AIRouting_opt"}) + + +@cache +def _load_airouting() -> type[Any]: + """Load IBM's optional AI routing pass.""" + try: + module = import_module("qiskit_ibm_transpiler.ai.routing") + except ImportError as exc: + msg = "AIRouting requires a qiskit-ibm-transpiler installation compatible with this environment." + raise RuntimeError(msg) from exc + return cast("type[Any]", vars(module)["AIRouting"]) + + +def _airouting_pass(*, coupling_map: CouplingMap, layout_mode: str) -> Task: + """Construct IBM's local AI routing pass.""" + return _load_airouting()( + coupling_map=coupling_map, + optimization_level=3, + layout_mode=layout_mode, + local_mode=True, + ) + + +@cache +def _is_ai_routing_available() -> bool: + """Return whether IBM's AI routing pass can be imported.""" + try: + _load_airouting() + except RuntimeError: + return False + return True + def qiskit_optimization_actions() -> list[Action]: """Returns the Qiskit optimization actions.""" @@ -357,6 +392,24 @@ def qiskit_routing_actions() -> list[Action]: ] +def qiskit_ai_routing_action() -> Action: + """Return IBM's AI routing action.""" + return DeferredDeviceAction( + "AIRouting", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", + [ + _airouting_pass( + coupling_map=device.build_coupling_map(), + layout_mode="improve", + ) + ], + ), + ) + + def qiskit_mapping_action() -> Action: """Returns the Qiskit mapping action.""" return DeferredDeviceAction( @@ -369,6 +422,24 @@ def qiskit_mapping_action() -> Action: ) +def qiskit_ai_mapping_action() -> Action: + """Return the combined AI layout and routing action.""" + return DeferredDeviceAction( + "AIRouting_opt", + CompilationOrigin.QISKIT, + PassType.MAPPING, + transpile_pass=lambda device: cast( + "list[Task]", + [ + _airouting_pass( + coupling_map=device.build_coupling_map(), + layout_mode="optimize", + ), + ], + ), + ) + + def qiskit_synthesis_action() -> Action: """Returns the Qiskit synthesis action.""" return DeferredDeviceAction( @@ -485,5 +556,7 @@ def run_qiskit_action( def is_qiskit_action_available(action: Action, device: Target) -> bool: """Return whether a Qiskit action is available for the current device.""" + if action.name in _AI_ROUTING_ACTION_NAMES and not _is_ai_routing_available(): + return False # Only allow VF2PostLayout if "ibm" is in the device name # TODO: Why? return action.name != "VF2PostLayout" or "ibm" in device.description diff --git a/src/mqt/predictor/rl/actions/registry.py b/src/mqt/predictor/rl/actions/registry.py index 7f47b523d..95b239839 100644 --- a/src/mqt/predictor/rl/actions/registry.py +++ b/src/mqt/predictor/rl/actions/registry.py @@ -52,7 +52,9 @@ def get_actions_by_pass_type() -> dict[PassType, list[Action]]: for _action in ( *qiskit_actions.qiskit_layout_actions(), *qiskit_actions.qiskit_routing_actions(), + qiskit_actions.qiskit_ai_routing_action(), qiskit_actions.qiskit_mapping_action(), + qiskit_actions.qiskit_ai_mapping_action(), qiskit_actions.qiskit_synthesis_action(), qiskit_actions.qiskit_o3_action(), *qiskit_actions.qiskit_optimization_actions(), diff --git a/tests/compilation/test_helper_rl.py b/tests/compilation/test_helper_rl.py index 5acc1f75e..44e331eb4 100644 --- a/tests/compilation/test_helper_rl.py +++ b/tests/compilation/test_helper_rl.py @@ -19,21 +19,24 @@ from mqt.bench import BenchmarkLevel, get_benchmark from mqt.bench.targets import get_device from qiskit import transpile -from qiskit.transpiler import PassManager +from qiskit.transpiler import CouplingMap, PassManager from qiskit.transpiler.passes.layout.vf2_post_layout import VF2PostLayoutStopReason from mqt.predictor.rl.actions import ( + CompilationOrigin, PassType, get_actions_by_pass_type, + qiskit_actions, ) from mqt.predictor.rl.actions.bqskit_actions import bqskit_to_qiskit, get_bqskit_native_gates -from mqt.predictor.rl.actions.qiskit_actions import postprocess_vf2postlayout +from mqt.predictor.rl.actions.qiskit_actions import is_qiskit_action_available, postprocess_vf2postlayout from mqt.predictor.rl.helper import create_feature_dict, get_path_trained_model, get_path_training_circuits from mqt.predictor.utils import get_openqasm_gates if TYPE_CHECKING: from collections.abc import Callable + import pytest from qiskit.passmanager.base_tasks import Task from qiskit.transpiler import Target @@ -99,6 +102,56 @@ def test_bqskit_to_qiskit_converts_u1q_to_r_gate() -> None: assert qc.data[0].operation.params == [0.1, 0.2] +def test_ai_routing_action_factories(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the IBM AI routing action wiring without loading its model.""" + + class FakeAIRouting: + """Record the arguments used to construct an AI routing pass.""" + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + + monkeypatch.setattr(qiskit_actions, "_load_airouting", lambda: FakeAIRouting) + + device = get_device("ibm_falcon_27") + actions = { + action.name: action + for action in ( + *get_actions_by_pass_type()[PassType.ROUTING], + *get_actions_by_pass_type()[PassType.MAPPING], + ) + } + + routing_action = actions["AIRouting"] + assert routing_action.origin == CompilationOrigin.QISKIT + assert routing_action.pass_type == PassType.ROUTING + routing_factory = cast("Callable[[Target], list[Task]]", routing_action.transpile_pass) + routing_passes = routing_factory(device) + assert len(routing_passes) == 1 + ai_routing = cast("FakeAIRouting", routing_passes[0]) + routing_coupling_map = cast("CouplingMap", ai_routing.kwargs["coupling_map"]) + assert routing_coupling_map.get_edges() == device.build_coupling_map().get_edges() + assert ai_routing.kwargs["optimization_level"] == 3 + assert ai_routing.kwargs["layout_mode"] == "improve" + assert ai_routing.kwargs["local_mode"] is True + + mapping_action = actions["AIRouting_opt"] + assert mapping_action.origin == CompilationOrigin.QISKIT + assert mapping_action.pass_type == PassType.MAPPING + mapping_factory = cast("Callable[[Target], list[Task]]", mapping_action.transpile_pass) + mapping_passes = mapping_factory(device) + assert len(mapping_passes) == 1 + ai_mapping = cast("FakeAIRouting", mapping_passes[0]) + mapping_coupling_map = cast("CouplingMap", ai_mapping.kwargs["coupling_map"]) + assert mapping_coupling_map.get_edges() == device.build_coupling_map().get_edges() + assert ai_mapping.kwargs["optimization_level"] == 3 + assert ai_mapping.kwargs["layout_mode"] == "optimize" + assert ai_mapping.kwargs["local_mode"] is True + + monkeypatch.setattr(qiskit_actions, "_is_ai_routing_available", lambda: False) + assert not is_qiskit_action_available(routing_action, device) + + def test_vf2_layout_and_postlayout() -> None: """Test the VF2Layout and VF2PostLayout passes.""" qc = get_benchmark("ghz", BenchmarkLevel.ALG, 3)