Skip to content

Conversation

@Shaobo-Zhou
Copy link
Contributor

@Shaobo-Zhou Shaobo-Zhou commented Sep 6, 2025

Description

This PR refactors the underlying MDP used by the RL-based predictor. It expands the action space, refines state transitions and observations, and adds support for stochastic transpiler passes.

🚀 Major Changes
Expanded Action Space

  • Added AIRouting as a routing/mapping option and wrapped in SafeAIRouting for stable integration into the RL pipeline.
  • Added tket layout actions: GraphPlacement and NoiseAwarePlacement (fidelity-aware).
  • Added optimization actions KAKDecomposition and ElidePermutations.

Support for Stochastic Passes

  • Wrapped stochastic actions (e.g., AIRouting, SabreLayout) in a multi-trial evaluation loop, optimizing for the figure of merit instead of the default gate count internally in Qiskit passes.
  • Introduced max_iterations as parameters to control trial counts, enabling improved predictor performance and stability.

Changes in determine_valid_actions_for_state

  • Refined the logic: After a circuit is mapped, only mapping-preserving optimization actions are allowed to prevent reusing outdated layouts when the circuit structure changes.

Expanded state observation vector

Fixes and Enhancements

  • Fixed a bug in OptimizeCliffords by ensuring CollectCliffords runs beforehand.
  • Corrected computation of estimated success probability in reward.py
  • Fixed incorrect usage of GatesInBasis in rl/predictorenv.py
  • Adjusted benchmark level to INDEP in test_predictor_rl.py, since the current action space does not guarantee support for high-level gates.

Dependency Update

  • Added qiskit-ibm-ai-local-transpiler to the dependencies
  • Pinned networkx==2.8.5 to ensure compatibility with qiskit-ibm-ai-local-transpiler
  • Upgraded pytket_qiskit>=0.71.0 for compatibility with the new action space

Checklist:

  • The pull request only contains commits that are focused and relevant to this change.
  • I have added appropriate tests that cover the new/changed functionality.
  • I have updated the documentation to reflect these changes.
  • I have added entries to the changelog for any noteworthy additions, changes, fixes, or removals.
  • I have added migration instructions to the upgrade guide (if needed).
  • The changes follow the project's style guidelines and introduce no new warnings.
  • The changes are fully tested and pass the CI checks.
  • I have reviewed my own code changes.

Shaobo Zhou and others added 30 commits March 29, 2025 19:20
Update action space and feature space

Update actions

Update action space
Fix: resolve pre-commit issues and add missing annotations

Fix: resolve pre-commit issues and add missing annotations

Remove example_test.py

Remove example_test.py
Fix: resolve pre-commit issues and add missing annotations

Fix: resolve pre-commit issues and add missing annotations

Fix: resolve pre-commit issues and add missing annotations
Signed-off-by: Shaobo-Zhou <109073755+Shaobo-Zhou@users.noreply.github.com>
Fix bugs

Fix bugs

Fix bugs
Fix windows runtime warning problem

Fix windows runtime warning issue
@coderabbitai
Copy link

coderabbitai bot commented Nov 20, 2025

📝 Walkthrough

Walkthrough

Adds noise-aware placement and stochastic routing to the RL optimizer, refines feature extraction and reward timing, adds Windows/Python 3.13 parallelism fallback, updates dependencies, and migrates benchmark examples/tests from BenchmarkLevel.ALG to BenchmarkLevel.INDEP.

Changes

Cohort / File(s) Summary
Benchmark level migration
CHANGELOG.md, docs/setup.md, tests/compilation/test_predictor_rl.py, tests/device_selection/test_predictor_ml.py
Replaced BenchmarkLevel.ALG with BenchmarkLevel.INDEP in docs and tests.
Parallelism / Windows & Py3.13 fallback
src/mqt/predictor/ml/predictor.py, src/mqt/predictor/utils.py, tests/hellinger_distance/*
Added NO_PARALLEL constant to force single-threaded execution on Windows+Python3.13; removed Windows-specific RuntimeWarning and simplified compile/generate calls.
Dependency and project metadata
pyproject.toml
Updated dependency pins (pytket_qiskit, qiskit-ibm-runtime), added stable-baselines3, Python-gated transpiler deps, mypy/module overrides, and uv override for networkx.
Reward timing refinement
src/mqt/predictor/reward.py
Use device discrete time unit (dt) in idle-time decay for modern Qiskit scheduling (qiskit ≥ 2.0.0).
RL action model and helpers
src/mqt/predictor/rl/actions.py, tests/...
Added preserve_layout and stochastic flags to actions, new placement/routing actions (GraphPlacement, NoiseAwarePlacement, SabreSwap, SabreMapping, SafeAIRouting), helper functions for classical registers, and updated action signatures/exports.
Feature extraction & noise parsing
src/mqt/predictor/rl/helper.py, src/mqt/predictor/rl/parsing.py, src/mqt/predictor/utils.py
Added dict_to_featurevector for normalized gate features, prepare_noise_data to extract node/edge/readout errors from Target, and get_rl_openqasm_gates() utility.
RL environment & stochastic compilation
src/mqt/predictor/rl/predictorenv.py, tests/compilation/test_predictor_rl.py
Added fom_aware_compile for multi-run stochastic transpile selection, integrated noise-aware TKET placement and TKET→Qiskit layout conversion, propagated pm_property_set for layout postprocessing, added max_iter and noise attributes, and updated reward/layout-related signatures.
Tests and robustness checks
tests/compilation/test_predictor_rl.py, tests/hellinger_distance/test_estimated_hellinger_distance.py, tests/device_selection/*
Added tests for fom_aware_compile fallback and TKET layout failure; removed explicit num_workers in some tests; adjusted assertions and mapping validations.

Sequence Diagram(s)

sequenceDiagram
    participant Agent as Client/Agent
    participant Env as PredictorEnv
    participant Action as Action Handler
    participant FOM as fom_aware_compile
    participant Transpile as Transpile/Pass (stochastic)
    participant Reward as Reward Calculator

    Agent->>Env: apply_action(action, device, qc)
    Env->>Action: classify action (stochastic ? preserve_layout ?)
    alt stochastic
        Action->>FOM: fom_aware_compile(action, device, qc)
        activate FOM
        loop iterate up to max_iter
            FOM->>Transpile: run transpile pass (non-deterministic)
            Transpile-->>FOM: circuit_variant
            FOM->>Reward: calculate_reward(circuit_variant)
            Reward-->>FOM: reward_score
        end
        FOM-->>Action: best_circuit (highest score) or fallback
        deactivate FOM
    else deterministic
        Action->>Transpile: run deterministic transpile
        Transpile-->>Action: circuit
    end
    Action-->>Env: resulting_circuit (+ pm_property_set)
    Env->>Env: postprocess layout, update state, return result to Agent
Loading
sequenceDiagram
    participant Env as PredictorEnv
    participant Parse as prepare_noise_data
    participant TKET as TKET Placement
    participant Qiskit as Qiskit Layout Conversion
    participant Circuit as QuantumCircuit

    Env->>Parse: prepare_noise_data(device)
    Parse-->>Env: node_err, edge_err, readout_err

    Env->>TKET: run noise-aware placement(node_err, edge_err)
    TKET-->>Env: placement_result (mapping, dag)

    Env->>Qiskit: convert placement -> Qiskit layout (ApplyLayout, FullAncillaAllocation)
    Qiskit-->>Circuit: mapped/enlarged quantum circuit

    Env->>Env: set final_layout, update internal maps
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45–75 minutes

  • Review focus:
    • src/mqt/predictor/rl/predictorenv.py — stochastic compilation flow, pm_property_set propagation, TKET↔Qiskit layout conversions.
    • src/mqt/predictor/rl/actions.py — new action types, flags, and transpile_pass typing/guards.
    • src/mqt/predictor/rl/parsing.py & helper.py — correctness of noise extraction and feature normalization.
    • pyproject.toml — dependency gating and uv override consistency.
    • Tests exercising fallback and placement failure paths.

Poem

🐰
I nibble bytes and stitch a plan,
Noise-aware hops for every qubit and span.
Stochastic twirls, a careful compile—
Windows hums along, Python makes me smile.
Hooray for maps that fit just right! 🎉

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Refined MDP for RL' is vague and generic, using non-descriptive terms that don't convey meaningful information about the specific changes in the changeset. Provide a more specific and descriptive title that highlights a key aspect of the changes, such as 'Expand RL action space and support stochastic transpiler passes' or 'Add stochastic routing and placement to RL predictor MDP.'
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The description is comprehensive with detailed sections covering major changes, dependency updates, and a complete checklist, though checklist items remain unchecked.
Docstring Coverage ✅ Passed Docstring coverage is 87.23% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

❤️ Share

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

@denialhaag
Copy link
Member

Note that your CI will likely be failing until #516 is merged later today.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/mqt/predictor/utils.py (1)

50-51: Update docstring to reflect the new logging behavior.

The docstring still mentions raising RuntimeWarning on Windows, but the implementation now logs an info message and proceeds. The "Raises" section should be updated to accurately reflect the current behavior.

Apply this diff to update the docstring:

     Returns:
         The result of the function call if it finishes within the timeout limit, otherwise False.
 
-    Raises:
-        RuntimeWarning: If the timeout is not supported on the current platform (e.g., Windows).
-        TimeoutExceptionError: If the function call exceeds the timeout limit.
+    Raises:
+        TimeoutExceptionError: If the function call exceeds the timeout limit.
+
+    Note:
+        On Windows, timeout is not supported and the function runs without a timeout limit.
♻️ Duplicate comments (1)
pyproject.toml (1)

263-263: Add version constraint to networkx override.

The networkx==2.8.5 override in [tool.uv] lacks version-specific conditions. Based on past review feedback, consider adding python_version < '3.13' to match line 51 constraints, unless this version is universal.

Consider updating:

 [tool.uv]
 override-dependencies = [
-  "networkx==2.8.5", # Required by `qiskit-ibm-transpiler`
+  "networkx==2.8.5; python_version < '3.13'", # Required by `qiskit-ibm-transpiler`
 ]
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 40507e0 and e7b4174.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • CHANGELOG.md (1 hunks)
  • docs/setup.md (1 hunks)
  • noxfile.py (3 hunks)
  • pyproject.toml (5 hunks)
  • src/mqt/predictor/ml/predictor.py (5 hunks)
  • src/mqt/predictor/reward.py (1 hunks)
  • src/mqt/predictor/rl/actions.py (20 hunks)
  • src/mqt/predictor/rl/helper.py (0 hunks)
  • src/mqt/predictor/rl/parsing.py (2 hunks)
  • src/mqt/predictor/rl/predictorenv.py (13 hunks)
  • src/mqt/predictor/utils.py (1 hunks)
  • tests/compilation/test_predictor_rl.py (5 hunks)
  • tests/device_selection/test_predictor_ml.py (3 hunks)
  • tests/hellinger_distance/test_estimated_hellinger_distance.py (1 hunks)
💤 Files with no reviewable changes (1)
  • src/mqt/predictor/rl/helper.py
🧰 Additional context used
🧬 Code graph analysis (5)
tests/hellinger_distance/test_estimated_hellinger_distance.py (2)
src/mqt/predictor/ml/predictor.py (2)
  • compile_training_circuits (206-240)
  • generate_training_data (242-300)
tests/device_selection/test_predictor_ml.py (2)
  • path_compiled_circuits (33-35)
  • path_uncompiled_circuits (27-29)
src/mqt/predictor/rl/parsing.py (1)
tests/compilation/test_reward.py (1)
  • device (38-40)
src/mqt/predictor/rl/predictorenv.py (3)
src/mqt/predictor/rl/actions.py (6)
  • CompilationOrigin (101-107)
  • DeviceDependentAction (148-159)
  • PassType (110-119)
  • get_actions_by_pass_type (584-589)
  • Action (123-139)
  • run (666-694)
src/mqt/predictor/rl/helper.py (1)
  • create_feature_dict (72-92)
src/mqt/predictor/rl/parsing.py (5)
  • final_layout_bqskit_to_qiskit (186-226)
  • final_layout_pytket_to_qiskit (167-183)
  • postprocess_vf2postlayout (229-250)
  • prepare_noise_data (253-278)
  • apply (89-92)
src/mqt/predictor/reward.py (1)
tests/compilation/test_reward.py (1)
  • device (38-40)
tests/compilation/test_predictor_rl.py (2)
src/mqt/predictor/rl/actions.py (3)
  • Action (123-139)
  • CompilationOrigin (101-107)
  • PassType (110-119)
src/mqt/predictor/rl/predictorenv.py (2)
  • fom_aware_compile (357-422)
  • _apply_tket_action (495-552)
🪛 Ruff (0.14.5)
src/mqt/predictor/rl/predictorenv.py

241-241: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)


407-407: Do not catch blind exception: Exception

(BLE001)


408-408: Logging statement uses f-string

(G004)


416-416: Logging statement uses f-string

(G004)


512-512: Do not catch blind exception: Exception

(BLE001)


513-513: Logging statement uses f-string

(G004)

tests/compilation/test_predictor_rl.py

179-179: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)

⏰ Context from checks skipped due to timeout of 900000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: 🐍 Test (ubuntu-24.04) / 🐍 ubuntu-24.04
  • GitHub Check: 🐍 Test (macos-14) / 🐍 macos-14
  • GitHub Check: 🐍 Test (windows-2022) / 🐍 windows-2022
🔇 Additional comments (34)
src/mqt/predictor/utils.py (1)

54-54: LGTM: Appropriate log level for expected platform behavior.

Downgrading from warning to info is correct here, as the Windows timeout limitation is expected behavior rather than a problem requiring user attention.

noxfile.py (2)

21-21: LGTM!

The pathlib import is necessary for the new _cleanup function and is properly placed.


112-113: LGTM with a note!

The conditional cleanup call is appropriately guarded by the CI environment check and placed after tests complete. However, ensure the aggressive ~/.cache cleanup in the _cleanup function is addressed as noted in the previous comment.

pyproject.toml (3)

51-52: Verify Python 3.13 transpiler package exclusion.

The condition python_version < '3.13' excludes qiskit-ibm-transpiler and qiskit-ibm-ai-local-transpiler from Python 3.13+. Confirm that the transpiler support is truly unavailable on Python 3.13, or if these packages should be unconditionally included with updated versions.


173-173: Include underscore variant in mypy overrides for qiskit_ibm_transpiler.

The mypy override includes qiskit_ibm_transpiler.* but the package may also be imported with underscores (e.g., qiskit.ibm.transpiler). Verify whether both variants need to be included.


38-38: The qiskit-ibm-runtime upper bound constraint is necessary, but the TODO comment needs clarification.

pytket_qiskit 0.71.0 does not explicitly require a qiskit-ibm-runtime version, but pytket_qiskit 0.71.1 requires ≥0.41.1 and 0.72.0 requires ≥0.42.0. The upper bound <0.42.0 is justified because pytket_qiskit 0.72.0 and later require pytket ≥2.9.3 and qiskit ≥2.2.0, which would necessitate bumping your pytket and qiskit versions from 1.29.0 and 1.3.3 respectively.

The TODO comment on line 36–37 is incomplete. It states "Remove once pytket_qiskit is updated," but updating pytket_qiskit beyond 0.71.x requires a cascade of version bumps across the stack. Either:

  1. Update the TODO comment to explain the version cascade required to lift this constraint, or
  2. Document this as a deliberate pinning decision if staying on pytket_qiskit 0.71.x is intentional.
docs/setup.md (1)

112-112: LGTM! Documentation example updated consistently.

The example correctly reflects the switch to BenchmarkLevel.INDEP, aligning with the broader PR changes and the updated CHANGELOG entry.

CHANGELOG.md (1)

14-14: LGTM! Changelog appropriately documents the benchmark level change.

The entry clearly describes the switch from ALG to INDEP for RL predictor test circuits.

tests/device_selection/test_predictor_ml.py (1)

46-46: LGTM! Test benchmark level consistently updated.

All three occurrences correctly switch from BenchmarkLevel.ALG to BenchmarkLevel.INDEP, aligning with the documented change in the CHANGELOG.

Also applies to: 66-66, 96-96

src/mqt/predictor/ml/predictor.py (4)

64-65: Reasonable workaround for Windows Python 3.13 multiprocessing issue.

The NO_PARALLEL guard correctly identifies the problematic environment mentioned in the PR description (_posixsubprocess issues). This fallback to single-threaded execution prevents crashes while maintaining functionality.


232-235: LGTM! NO_PARALLEL correctly applied to compilation parallelization.

The implementation follows the pattern from past review suggestions, falling back to single-threaded execution (num_jobs=1) when on Windows with Python 3.13+.


275-276: LGTM! Consistent NO_PARALLEL application in training data generation.

The parallel execution correctly respects the NO_PARALLEL guard, ensuring single-threaded operation on Windows Python 3.13+.


415-418: LGTM! NO_PARALLEL correctly applied to GridSearchCV.

The implementation follows the suggested pattern from past reviews, using num_jobs=1 when NO_PARALLEL is true, otherwise defaulting to 8 workers for cross-validation.

tests/hellinger_distance/test_estimated_hellinger_distance.py (1)

219-224: Updated to use internal parallelism control.

The removal of num_workers parameters aligns with the new NO_PARALLEL implementation in predictor.py that automatically handles Windows Python 3.13 cases internally. The increased timeout (600 → 6000 seconds) accommodates the longer compilation times for Hellinger distance model training.

tests/compilation/test_predictor_rl.py (4)

15-15: LGTM! Type hints and test imports properly structured.

The TYPE_CHECKING block and expanded imports support the new test scenarios and enhanced validation (e.g., CheckMap for mapping verification).

Also applies to: 20-20, 23-24, 28-28, 38-40


47-47: LGTM! Benchmark level consistently updated.

Both test cases correctly switch to BenchmarkLevel.INDEP, aligning with the PR's documented change.

Also applies to: 116-116


101-112: Excellent addition of explicit mapping validation.

The new CheckMap validation ensures the compiled circuit is properly swap-mapped to the device's coupling map, and the enhanced assertions provide stronger test coverage of the compilation pipeline.


134-155: Good defensive test for fom_aware_compile fallback.

This test validates that when reward calculation fails, fom_aware_compile gracefully falls back (as documented in the relevant code snippet showing swap-count fallback), ensuring resilience in the compilation pipeline.

src/mqt/predictor/rl/actions.py (8)

14-14: LGTM! Imports appropriately extended for new functionality.

The new imports support TKET placement actions (GraphPlacement, NoiseAwarePlacement), classical register handling (ClassicalRegister, DAG converters), and version-gated AIRouting integration for Python < 3.13.

Also applies to: 30-32, 50-50, 52-52, 57-57, 73-73, 82-84


138-139: Excellent metadata additions for action classification.

The stochastic flag enables multi-trial optimization for non-deterministic passes (as described in the PR objectives), while preserve_layout helps determine valid action sequences post-mapping. The naming addresses past review feedback.


151-158: Type annotation widened for flexibility.

The addition of list[Any] provides flexibility for dynamically constructed pass lists (e.g., TKET passes with runtime-determined parameters), though it reduces type safety compared to more specific alternatives.


196-210: Good additions of preserve_layout semantics and Clifford handling fix.

The preserve_layout=True flags correctly identify optimization passes that maintain qubit mappings, enabling proper action sequencing. The addition of CollectCliffords before OptimizeCliffords (line 272) addresses the fix mentioned in the PR objectives.

Also applies to: 218-218, 228-228, 238-238, 263-263, 272-272, 287-299, 362-362


387-387: Major expansion of routing and layout options.

The new actions implement features from the PR objectives:

  • TKET placement actions (GraphPlacement, NoiseAwarePlacement) with appropriate timeouts
  • SabreSwap for routing-only scenarios
  • AIRouting / AIRouting_opt with version gating for Python < 3.13 compatibility and SafeAIRouting wrapper
  • Proper stochastic=True flags for non-deterministic actions
  • VF2PostLayout timeout prevents excessive runtime

Also applies to: 425-452, 466-520


592-654: Well-structured classical register management utilities.

These helper functions cleanly separate classical register handling from quantum routing logic, enabling SafeAIRouting to temporarily remove measurements, apply routing, and restore them with correct qubit mappings.


567-567: Configuration change: Fixed max_synthesis_size for BQSKitSynthesis.

The max_synthesis_size is now consistently 3, removing the conditional GITHUB_ACTIONS check. This may improve synthesis quality but could increase compilation time in CI environments.


656-694: I need to verify where AIRouting comes from and whether it populates property_set["final_layout"] as expected by the code.

Let me search for AIRouting's actual implementation to confirm it sets final_layout.

Based on my verification of the Qiskit documentation and code, here is the rewritten review comment:


Verify that AIRouting reliably populates property_set["final_layout"] in all scenarios.

The routing stage must set the final_layout property in the PropertySet if routing has taken place. SafeAIRouting depends on this by asserting that self.property_set["final_layout"] is non-None (line 679-680). While routing passes already return the final layout permutation caused by the swap mapping to the property set, the code should verify that AIRouting, which acts both as a layout stage and a routing stage, consistently populates this property in all execution paths—including edge cases with classical registers or non-routable circuits. Consider replacing the assertion with a more informative error message or defensive code path if AIRouting may legitimately skip setting final_layout in certain scenarios.

src/mqt/predictor/reward.py (1)

206-214: Critical fix: Correct time unit conversion for delay decay.

The addition of dt scaling (line 206, 209) fixes a bug where instruction.duration in Qiskit 2.0+ is in discrete time units, not seconds. Without this conversion, the idle decay calculation would be incorrect, leading to inaccurate estimated success probability. This addresses the ESP fix mentioned in the PR objectives.

src/mqt/predictor/rl/parsing.py (1)

253-278: LGTM! Clean noise data extraction implementation.

The function properly handles edge cases with None checks and attribute verification. The separation of node, edge, and readout errors is well-structured and the implementation is straightforward.

src/mqt/predictor/rl/predictorenv.py (6)

495-552: Excellent refactoring of TKET action handling.

The new logic for NoiseAwarePlacement and layout mapping is well-structured:

  • Caches noise data to avoid redundant extraction
  • Properly converts TKET placements to Qiskit layouts
  • Includes graceful fallback when placement fails
  • Maintains layout consistency across the compilation pipeline

The broad exception handling on line 512 is appropriate given that placement operations can fail in various ways.


590-623: Excellent refactoring - much improved readability!

The restructured logic clearly handles all circuit states with well-defined decision paths:

  • Separates native vs. non-native gate checks
  • Distinguishes mapped vs. unmapped circuits
  • Properly handles layout assignment states
  • Returns appropriate action subsets for each combination

This is a significant improvement over the previous implementation and makes the decision logic easy to follow and verify.


235-239: LGTM! Proper handling of special gate types.

The decomposition logic correctly handles both unitary and Clifford gates before basis translation, preventing potential errors in the BasisTranslator pass.


245-256: LGTM! Clean refactoring with improved flexibility.

The addition of the optional qc parameter and simplified conditional logic improves readability. The assert_never at the end serves as a type-checking guard to ensure exhaustive handling of all reward function cases.


424-460: Well-integrated stochastic action support.

The changes properly route stochastic actions through the new fom_aware_compile method and maintain property set tracking for layout management. The conditional logic handles both device-dependent and device-independent actions appropriately.

Note: This depends on fixing the FOM comparison logic in fom_aware_compile (see previous comment).


462-493: LGTM! Improved parameter passing and error handling.

The refactored signature with explicit pm_property_set parameter is clearer than the previous approach. The early return guard (lines 468-469) properly handles cases where the property set is unavailable, preventing potential AttributeError exceptions.

noxfile.py Outdated
Comment on lines 64 to 77
def _cleanup(session: nox.Session) -> None:
"""Remove this session's virtualenv to save disk space in CI."""
version = session.python
if version != "3.13": # keep cache for last run
venv_dir = session.virtualenv.location
if venv_dir and pathlib.Path(venv_dir).exists():
shutil.rmtree(venv_dir, ignore_errors=True)
session.log(f"Cleaned up {venv_dir}")
shutil.rmtree(pathlib.Path("~/.cache").expanduser(), ignore_errors=True)
gha_temp = pathlib.Path("/home/runner/work/_temp/setup-uv-cache")
if gha_temp.exists():
shutil.rmtree(gha_temp, ignore_errors=True)
session.log(f"Cleaned GitHub Actions uv temp cache at {gha_temp}")

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The ~/.cache cleanup is too aggressive and may cause unintended side effects.

Line 72 removes the entire ~/.cache directory, which affects all tools and sessions system-wide, not just the current nox session. This could:

  • Interfere with parallel nox sessions or other CI jobs sharing the same runner
  • Remove caches from unrelated tools
  • Cause unexpected behavior in subsequent steps

Consider a more targeted approach:

 def _cleanup(session: nox.Session) -> None:
     """Remove this session's virtualenv to save disk space in CI."""
     version = session.python
-    if version != "3.13":  # keep cache for last run
+    # Keep cache for last Python version
+    all_versions = ["3.10", "3.11", "3.12", "3.13"]
+    if version != all_versions[-1]:
         venv_dir = session.virtualenv.location
         if venv_dir and pathlib.Path(venv_dir).exists():
             shutil.rmtree(venv_dir, ignore_errors=True)
             session.log(f"Cleaned up {venv_dir}")
-        shutil.rmtree(pathlib.Path("~/.cache").expanduser(), ignore_errors=True)
+        # Only remove session-specific caches
+        cache_dirs = [
+            pathlib.Path("~/.cache/uv").expanduser(),
+            pathlib.Path("~/.cache/pip").expanduser(),
+        ]
+        for cache_dir in cache_dirs:
+            if cache_dir.exists():
+                shutil.rmtree(cache_dir, ignore_errors=True)
+                session.log(f"Cleaned up {cache_dir}")
         gha_temp = pathlib.Path("/home/runner/work/_temp/setup-uv-cache")
         if gha_temp.exists():
             shutil.rmtree(gha_temp, ignore_errors=True)
             session.log(f"Cleaned GitHub Actions uv temp cache at {gha_temp}")

Additional minor issues:

  1. Line 72: No logging for the ~/.cache cleanup unlike other cleanup operations.
  2. Line 67: The hardcoded "3.13" check is fragile—if Python versions are reordered or a newer version is added, the logic breaks.

Comment on lines +407 to +408
except Exception as e:
logger.warning(f"[Fallback to SWAP counts] Synthesis or fidelity computation failed: {e}")
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider logging best practices (optional).

The broad exception handling is appropriate here since arbitrary transpiler passes may fail in unpredictable ways, and the fallback logic handles failures gracefully.

For the logging statements, consider using lazy formatting instead of f-strings for consistency with Python logging best practices:

logger.warning("[Fallback to SWAP counts] Synthesis or fidelity computation failed: %s", e)
logger.exception("[Error] Pass failed at iteration %d", i + 1)

However, the current implementation is acceptable, and f-strings in logging are widely used in modern Python.

Also applies to: 415-417

🧰 Tools
🪛 Ruff (0.14.5)

407-407: Do not catch blind exception: Exception

(BLE001)


408-408: Logging statement uses f-string

(G004)

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 407-408 and 415-417, the
logging uses f-strings which defeats Python logging's lazy formatting and makes
it harder to include exception tracebacks; update the calls to use logging's
format-style parameters and logger.exception where a traceback is useful (e.g.,
logger.warning("[Fallback to SWAP counts] Synthesis or fidelity computation
failed: %s", e) and replace error logs that should include stack traces with
logger.exception("[Error] Pass failed at iteration %d", i + 1)), ensuring the
exception object is passed as an argument rather than interpolated.

Comment on lines +157 to +184
def test_tket_action_layout_failure() -> None:
"""Test fallback in case of TKET layout placement failure."""
qc = QuantumCircuit(1)

class FakePass:
def get_placement_map(self, _: object) -> None:
msg = "fake placement failure"
raise RuntimeError(msg)

def apply(self, _: object) -> None:
pass

dummy_action = Action(
name="DummyLayout",
origin=CompilationOrigin.TKET,
pass_type=PassType.LAYOUT,
transpile_pass=lambda _device: [FakePass()],
)

predictor = Predictor(figure_of_merit="estimated_success_probability", device=get_device("ibm_eagle_127"))
predictor.env.actions_layout_indices.append(0)
predictor.env.state = qc
apply_tket = predictor.env._apply_tket_action # noqa: SLF001
result_qc = apply_tket(dummy_action, 0)

assert isinstance(result_qc, QuantumCircuit)
assert result_qc.num_qubits == 1

Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Good test for TKET layout failure handling, but remove unused noqa.

The test correctly validates the fallback behavior when TKET placement fails (as shown in _apply_tket_action snippet). However, the # noqa: SLF001 on line 179 is unnecessary since SLF001 is not in the enabled rule set.

Apply this diff to remove the unused directive:

-    apply_tket = predictor.env._apply_tket_action  # noqa: SLF001
+    apply_tket = predictor.env._apply_tket_action
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_tket_action_layout_failure() -> None:
"""Test fallback in case of TKET layout placement failure."""
qc = QuantumCircuit(1)
class FakePass:
def get_placement_map(self, _: object) -> None:
msg = "fake placement failure"
raise RuntimeError(msg)
def apply(self, _: object) -> None:
pass
dummy_action = Action(
name="DummyLayout",
origin=CompilationOrigin.TKET,
pass_type=PassType.LAYOUT,
transpile_pass=lambda _device: [FakePass()],
)
predictor = Predictor(figure_of_merit="estimated_success_probability", device=get_device("ibm_eagle_127"))
predictor.env.actions_layout_indices.append(0)
predictor.env.state = qc
apply_tket = predictor.env._apply_tket_action # noqa: SLF001
result_qc = apply_tket(dummy_action, 0)
assert isinstance(result_qc, QuantumCircuit)
assert result_qc.num_qubits == 1
def test_tket_action_layout_failure() -> None:
"""Test fallback in case of TKET layout placement failure."""
qc = QuantumCircuit(1)
class FakePass:
def get_placement_map(self, _: object) -> None:
msg = "fake placement failure"
raise RuntimeError(msg)
def apply(self, _: object) -> None:
pass
dummy_action = Action(
name="DummyLayout",
origin=CompilationOrigin.TKET,
pass_type=PassType.LAYOUT,
transpile_pass=lambda _device: [FakePass()],
)
predictor = Predictor(figure_of_merit="estimated_success_probability", device=get_device("ibm_eagle_127"))
predictor.env.actions_layout_indices.append(0)
predictor.env.state = qc
apply_tket = predictor.env._apply_tket_action
result_qc = apply_tket(dummy_action, 0)
assert isinstance(result_qc, QuantumCircuit)
assert result_qc.num_qubits == 1
🧰 Tools
🪛 Ruff (0.14.5)

179-179: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)

🤖 Prompt for AI Agents
In tests/compilation/test_predictor_rl.py around lines 157 to 184, the inline
comment "# noqa: SLF001" on line 179 is unnecessary; remove that noqa directive
so the line becomes just "apply_tket = predictor.env._apply_tket_action" (no
trailing noqa) and ensure the file still imports and runs the test unchanged.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/mqt/predictor/utils.py (1)

50-51: Update the docstring to reflect the implementation change.

The docstring claims that a RuntimeWarning is raised on Windows, but the implementation at line 54 now logs an info message instead. The function no longer raises any warning.

Apply this diff to fix the docstring:

-    Raises:
-        RuntimeWarning: If the timeout is not supported on the current platform (e.g., Windows).
-        TimeoutExceptionError: If the function call exceeds the timeout limit.
+    Raises:
+        TimeoutExceptionError: If the function call exceeds the timeout limit.
+
+    Note:
+        On Windows, the timeout is not supported and the function runs without a timeout limit.
src/mqt/predictor/rl/actions.py (2)

122-160: Align Action metadata and remove_action docstring with current behavior

The stochastic and preserve_layout flags on Action are a nice way to expose metadata to the environment. Two small cleanups:

  • Since these are used as simple booleans, the bool | None typing is unnecessary; plain bool = False would be clearer.
  • remove_action’s docstring says it raises ValueError, but it actually raises KeyError for unknown names; the docstring should be updated to match.

For example:

@dataclass
class Action:
@@
-    stochastic: bool | None = False
-    preserve_layout: bool | None = False
+    stochastic: bool = False
+    preserve_layout: bool = False
@@
 def remove_action(name: str) -> None:
-    """Removes an action from the global actions registry by name.
-
-    Raises:
-        ValueError: If no action with the given name is registered.
-    """
+    """Removes an action from the global actions registry by name.
+
+    Raises:
+        KeyError: If no action with the given name is registered.
+    """

Also applies to: 179-188


584-694: SafeAIRouting and classical helpers are sound; consider explicit exceptions and future classical ops

The extract_cregs_and_measurements / remove_cregs / add_cregs_and_measurements helpers plus SafeAIRouting.run form a reasonable workaround for AIRouting’s classical-bit sensitivity:

  • Classical registers and measurement operations are cleanly separated, AIRouting runs on a purely quantum DAG, and measurements are restored using final_layout to map original virtual qubits to the routed circuit.
  • This approach is compatible with Qiskit’s bit equality semantics (bits compare equal if they’re from registers of the same name/size and index), so reintroducing measurements via cloned registers is valid.

Two things you might tighten:

  1. Use explicit exceptions instead of assert in SafeAIRouting

    The assert checks on final_layout and qubit membership will vanish under python -O, and they currently surface as bare AssertionErrors. For clearer error reporting and robustness, consider converting them to explicit exceptions, e.g.:

  •        final_layout = getattr(self, "property_set", {}).get("final_layout", None)
    
  •        assert final_layout is not None, "final_layout is None — cannot map virtual qubits"
    
  •        final_layout = getattr(self, "property_set", {}).get("final_layout", None)
    
  •        if final_layout is None:
    
  •            raise RuntimeError("SafeAIRouting: 'final_layout' missing from property_set, cannot map virtual qubits")
    

@@

  •            assert virt in final_layout, f"Virtual qubit {virt} not found in final layout!"
    
  •            if virt not in final_layout:
    
  •                raise RuntimeError(f"SafeAIRouting: virtual qubit {virt} not found in final layout")
    

@@

  •                assert 0 <= phys < len(qc_routed.qubits), f"Physical index {phys} out of range in routed circuit!"
    
  •                if not (0 <= phys < len(qc_routed.qubits)):
    
  •                    raise RuntimeError(f"SafeAIRouting: physical index {phys} out of range in routed circuit")
    

@@

  •                assert phys in qc_routed.qubits, f"Physical qubit {phys} not found in output circuit!"
    
  •                if phys not in qc_routed.qubits:
    
  •                    raise RuntimeError(f"SafeAIRouting: physical qubit {phys} not found in output circuit")
    
    
    
  1. Assumptions about classical operations

    Right now remove_cregs only strips measure and barrier operations, and extract_cregs_and_measurements only tracks measurements. That matches the current workloads (quantum-only circuits plus terminal measurements). If you ever start feeding in circuits with richer classical control or mid-circuit classical ops, it’d be good to either (a) extend these helpers to handle them explicitly or (b) fail fast with a clear error.

Given the reliance on Qiskit’s layout and bit APIs, it’s worth adding a focused regression test that exercises AIRouting on a circuit with interspersed measurements and classical registers to confirm the end-to-end behavior remains correct on your pinned Qiskit/qiskit-ibm-transpiler versions.

src/mqt/predictor/rl/predictorenv.py (1)

119-142: Mapping-preserving indices likely never set due to attribute name mismatch, affecting valid-action logic.

In __init__ you populate actions_mapping_preserving_indices with:

for elem in action_dict[PassType.OPT]:
    self.action_set[index] = elem
    self.actions_opt_indices.append(index)
    if getattr(elem, "preserve", False):
        self.actions_mapping_preserving_indices.append(index)
    index += 1

While determine_valid_actions_for_state later relies heavily on actions_mapping_preserving_indices to distinguish mapping-preserving optimizations from layout/mapping passes (Lines 602–618).

Given the Action definition in src/mqt/predictor/rl/actions.py exposes preserve_layout: bool | None = False (not preserve), this list will remain empty unless some actions define a separate preserve attribute. That means:

  • Branches like “Non-native + mapped → synthesis or mapping-preserving” will collapse to synthesis only.
  • The “Native + fully mapped & layout assigned” case will effectively only allow termination and final optimization passes, not mapping-preserving optimization passes you presumably wanted to keep.

Consider using the existing attribute name (and optionally supporting both for backward compatibility), e.g.:

-            if getattr(elem, "preserve", False):
-                self.actions_mapping_preserving_indices.append(index)
+            if getattr(elem, "preserve_layout", getattr(elem, "preserve", False)):
+                self.actions_mapping_preserving_indices.append(index)

so that the decision logic in determine_valid_actions_for_state can actually see the mapping-preserving actions.

Also applies to: 590-623

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 40507e0 and e7b4174.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • CHANGELOG.md (1 hunks)
  • docs/setup.md (1 hunks)
  • noxfile.py (3 hunks)
  • pyproject.toml (5 hunks)
  • src/mqt/predictor/ml/predictor.py (5 hunks)
  • src/mqt/predictor/reward.py (1 hunks)
  • src/mqt/predictor/rl/actions.py (20 hunks)
  • src/mqt/predictor/rl/helper.py (0 hunks)
  • src/mqt/predictor/rl/parsing.py (2 hunks)
  • src/mqt/predictor/rl/predictorenv.py (13 hunks)
  • src/mqt/predictor/utils.py (1 hunks)
  • tests/compilation/test_predictor_rl.py (5 hunks)
  • tests/device_selection/test_predictor_ml.py (3 hunks)
  • tests/hellinger_distance/test_estimated_hellinger_distance.py (1 hunks)
💤 Files with no reviewable changes (1)
  • src/mqt/predictor/rl/helper.py
🧰 Additional context used
🧬 Code graph analysis (4)
tests/hellinger_distance/test_estimated_hellinger_distance.py (2)
src/mqt/predictor/ml/predictor.py (2)
  • compile_training_circuits (206-240)
  • generate_training_data (242-300)
tests/device_selection/test_predictor_ml.py (2)
  • path_compiled_circuits (33-35)
  • path_uncompiled_circuits (27-29)
tests/compilation/test_predictor_rl.py (3)
src/mqt/predictor/rl/predictor.py (2)
  • Predictor (32-127)
  • rl_compile (151-182)
src/mqt/predictor/rl/actions.py (3)
  • Action (123-139)
  • CompilationOrigin (101-107)
  • PassType (110-119)
src/mqt/predictor/rl/predictorenv.py (2)
  • fom_aware_compile (357-422)
  • _apply_tket_action (495-552)
src/mqt/predictor/rl/predictorenv.py (5)
src/mqt/predictor/rl/actions.py (6)
  • CompilationOrigin (101-107)
  • DeviceDependentAction (148-159)
  • PassType (110-119)
  • get_actions_by_pass_type (584-589)
  • Action (123-139)
  • run (666-694)
src/mqt/predictor/rl/helper.py (2)
  • create_feature_dict (72-92)
  • get_state_sample (32-69)
src/mqt/predictor/rl/parsing.py (4)
  • final_layout_pytket_to_qiskit (167-183)
  • postprocess_vf2postlayout (229-250)
  • prepare_noise_data (253-278)
  • apply (89-92)
src/mqt/predictor/reward.py (4)
  • expected_fidelity (46-74)
  • estimated_success_probability (102-226)
  • estimated_hellinger_distance (327-349)
  • crit_depth (40-43)
tests/compilation/test_predictor_rl.py (2)
  • get_placement_map (162-164)
  • apply (166-167)
src/mqt/predictor/rl/parsing.py (1)
tests/compilation/test_reward.py (1)
  • device (38-40)
🪛 Ruff (0.14.5)
tests/compilation/test_predictor_rl.py

179-179: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)

src/mqt/predictor/rl/predictorenv.py

241-241: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)


407-407: Do not catch blind exception: Exception

(BLE001)


408-408: Logging statement uses f-string

(G004)


416-416: Logging statement uses f-string

(G004)


512-512: Do not catch blind exception: Exception

(BLE001)


513-513: Logging statement uses f-string

(G004)

⏰ Context from checks skipped due to timeout of 900000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: 🐍 Test (macos-14) / 🐍 macos-14
  • GitHub Check: 🐍 Test (windows-2022) / 🐍 windows-2022
🔇 Additional comments (8)
src/mqt/predictor/ml/predictor.py (1)

232-235: LGTM! Implementation addresses Windows Python 3.13 compatibility.

The conditional parallelization correctly implements the workaround for the _posixsubprocess issue, with clear explanatory comments. This addresses the past review feedback.

src/mqt/predictor/reward.py (1)

206-212: Let me search for more context on how qubit_execution_time is calculated to confirm the unit mismatch:

The unit consistency concern does not apply—no fix needed.

InstructionProperties.duration in Qiskit 2.0 is always in seconds, not discrete time units. This means device[gate_type][...].duration returns seconds, so exec_time_per_qubit accumulates in seconds. Since estimate_duration returns seconds by default, line 224 (idle_time = overall_estimated_duration - qubit_execution_time) correctly subtracts seconds from seconds—no unit mismatch.

The delay gate fix (lines 206–212) correctly converts instruction.duration (which is in dt units) to seconds by multiplying by device.dt, making the decay calculation dimensionally sound.

docs/setup.md (1)

112-112: qcompile example now matches INDEP test setup

Using BenchmarkLevel.INDEP here is consistent with the updated tests and changelog; the example remains correct and aligned with the new default level.

tests/device_selection/test_predictor_ml.py (1)

45-47: Benchmark level switch in ML tests is correct and verified

All three GHZ benchmarks (lines 46, 66, 96) have been consistently updated to use BenchmarkLevel.INDEP, which is a valid enum member in mqt.bench for target-independent, hardware-agnostic benchmark circuits. The test logic remains unchanged and continues to exercise the full device-selection pipeline as intended.

pyproject.toml (1)

35-53: Dependencies are properly configured; no changes needed.

The dependency and tooling configuration is correct as-is. Verification shows:

  1. networkx==2.8.5 override is necessary — qiskit-ibm-transpiler==0.13.1 explicitly requires networkx==2.8.5, so the uv override is a direct requirement, not a temporary workaround. No follow-up action is needed when the transpiler updates.

  2. qiskit_ibm_ai_local_transpiler is not imported — Although listed as a dependency, the codebase contains zero imports of this package. The mypy ignore suggestion is unnecessary since the package is not directly referenced in type-checked code.

  3. qiskit_ibm_transpiler mypy override is already present — The configuration already includes qiskit_ibm_transpiler.* in the ignore_missing_imports block, matching the actual import usage in the codebase.

tests/hellinger_distance/test_estimated_hellinger_distance.py (1)

219-221: Timeout value is indeed conservative; confirm if adjustment is necessary

The verification confirms your original concern: the default timeout parameter is 600 seconds per circuit, and the test uses timeout=6000—a 10x multiplier. With 3 circuits being compiled (from range(2, 5) generating qc2, qc3, qc4), this compounds to substantial wall-clock time per device during compilation.

Your suggestion to lower or make the timeout configurable remains valid. However, the actual necessity depends on typical RL compilation times for these circuits, which cannot be measured in the sandbox environment. The code itself is not incorrect—it's a discretionary choice about headroom vs. CI time.

The simplified call structure is sound; the timeout magnitude is a trade-off for your team to evaluate based on observed RL compilation performance.

src/mqt/predictor/rl/actions.py (1)

191-299: Verification script requires manual execution due to missing dependencies

The verification script cannot run in the sandbox environment due to missing mqt module dependencies. Since I cannot execute dynamic tests, the sanity-check for action instantiation will need to be performed manually in a full development environment where the mqt package is installed.

However, static analysis of the registrations confirms that:

  • The code structure correctly uses DeviceIndependentAction and DeviceDependentAction constructors with valid signatures
  • Lambda functions for transpile_pass align with expected device parameter patterns (e.g., lambda device, lambda native_gate, coupling_map)
  • The preserve_layout and stochastic flags are consistently applied across related passes

To complete the verification, run the provided script in your local development environment where all dependencies are installed.

src/mqt/predictor/rl/parsing.py (1)

253-278: Clarify error aggregation and measurement handling in prepare_noise_data.

Two subtle points here:

  • For devices where "measure" is in device.operation_names, measurement errors will be written both to node_err in the main loop and to readout_err in the dedicated section. If you intend readout errors to be separate from gate errors, consider skipping "measure" in the first loop or documenting that duplication is deliberate.
  • When multiple instruction types exist on the same qubit or edge, the last one in device.operation_names wins (previous entries are overwritten). If you want max/mean or a preferred gate type instead of “last one wins”, you may want to aggregate explicitly.

Please confirm this matches how your TKET noise-aware placement consumes node_err/edge_err/readout_err; if not, I can propose a small refactor.

Comment on lines +275 to +276
num_jobs = 1 if NO_PARALLEL else num_workers
results = Parallel(n_jobs=num_jobs, verbose=100)(
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Add explanatory comment for consistency.

The parallelization logic is correct and matches the pattern in compile_training_circuits. However, this location lacks the explanatory comment present at lines 232-233, creating an inconsistency. Adding the same comment here would improve code maintainability.

Apply this diff:

+        # On Windows + Python 3.13, joblib's default "loky" process backend is broken
+        # (missing `_posixsubprocess`). Fall back to no multiprocessing.
         num_jobs = 1 if NO_PARALLEL else num_workers
         results = Parallel(n_jobs=num_jobs, verbose=100)(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
num_jobs = 1 if NO_PARALLEL else num_workers
results = Parallel(n_jobs=num_jobs, verbose=100)(
# On Windows + Python 3.13, joblib's default "loky" process backend is broken
# (missing `_posixsubprocess`). Fall back to no multiprocessing.
num_jobs = 1 if NO_PARALLEL else num_workers
results = Parallel(n_jobs=num_jobs, verbose=100)(
🤖 Prompt for AI Agents
In src/mqt/predictor/ml/predictor.py around lines 275 to 276, the Parallel call
lacks the explanatory comment that appears at lines 232-233; add the same
comment immediately above the lines setting num_jobs and calling Parallel to
explain why num_jobs is set to 1 when NO_PARALLEL is true and to document the
parallelization behaviour for consistency and maintainability.

Comment on lines +415 to +418
num_jobs = 1 if NO_PARALLEL else 8
mdl = GridSearchCV(mdl, tree_param, cv=num_cv, n_jobs=num_jobs).fit(
training_data.X_train, training_data.y_train
)
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Correct implementation, but consider adding explanatory comment.

The conditional parallelization logic is correct and implements the past review feedback. However, like generate_training_data, this lacks the explanatory comment present in compile_training_circuits (lines 232-233).

Consider adding the same explanatory comment for consistency:

+        # On Windows + Python 3.13, joblib's default "loky" process backend is broken
+        # (missing `_posixsubprocess`). Fall back to no multiprocessing.
         num_jobs = 1 if NO_PARALLEL else 8
         mdl = GridSearchCV(mdl, tree_param, cv=num_cv, n_jobs=num_jobs).fit(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
num_jobs = 1 if NO_PARALLEL else 8
mdl = GridSearchCV(mdl, tree_param, cv=num_cv, n_jobs=num_jobs).fit(
training_data.X_train, training_data.y_train
)
# On Windows + Python 3.13, joblib's default "loky" process backend is broken
# (missing `_posixsubprocess`). Fall back to no multiprocessing.
num_jobs = 1 if NO_PARALLEL else 8
mdl = GridSearchCV(mdl, tree_param, cv=num_cv, n_jobs=num_jobs).fit(
training_data.X_train, training_data.y_train
)
🤖 Prompt for AI Agents
In src/mqt/predictor/ml/predictor.py around lines 415 to 418, the conditional
parallelization (num_jobs = 1 if NO_PARALLEL else 8) is correct but missing an
explanatory comment for consistency; add a short comment above the num_jobs line
mirroring the style of the comment used in compile_training_circuits (lines
232-233) explaining that NO_PARALLEL forces single-threaded execution for
debugging/CI/resource reasons and otherwise uses 8 parallel jobs for
GridSearchCV, so future readers understand the rationale.

Comment on lines +235 to +244
# in case the Qiskit.QuantumCircuit has unitary or u gates or clifford in it, decompose them (because otherwise qiskit will throw an error when applying the BasisTranslator
if self.state.count_ops().get("unitary"):
self.state = self.state.decompose(gates_to_decompose="unitary")
elif self.state.count_ops().get("clifford"):
self.state = self.state.decompose(gates_to_decompose="clifford")

self.state._layout = self.layout # noqa: SLF001
obs = create_feature_dict(self.state)
return obs, reward_val, done, False, {}

def calculate_reward(self) -> float:
return create_feature_dict(self.state), reward_val, done, False, {}

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Decomposition guard is fine; remove unused noqa and consider handling both unitary and clifford.

The new decomposition logic avoids BasisTranslator errors for unitary/clifford gates, which is good. Two minor points:

  • Line 241 uses self.state._layout = self.layout # noqa: SLF001, but Ruff reports RUF100 because SLF001 isn’t enabled. Either remove the # noqa or enable that rule in your config; otherwise it’s just noise.
  • You currently decompose clifford only in an elif branch. If a circuit contains both unitary and clifford, only unitary will be decomposed. If the intent is “decompose both when present”, split this into two independent if statements.
🧰 Tools
🪛 Ruff (0.14.5)

241-241: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 235 to 244, remove the
unnecessary "# noqa: SLF001" on the self.state._layout assignment (either drop
the noqa comment or enable SLF001 in lint config) and change the decomposition
branch so both unitary and clifford are handled independently (replace the
current if/elif with two separate if checks) so that if a circuit contains both
types both are decomposed; keep the self.state._layout assignment as-is after
decomposition.

Comment on lines 371 to 538
def _apply_tket_action(self, action: Action, action_index: int) -> QuantumCircuit:
tket_qc = qiskit_to_tk(self.state, preserve_param_uuid=True)
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
if action.name == "NoiseAwarePlacement":
# Handle NoiseAwarePlacement separately (requires error data)
if self.node_err is None or self.edge_err is None or self.readout_err is None:
self.node_err, self.edge_err, self.readout_err = prepare_noise_data(self.device)
assert callable(action.transpile_pass)
transpile_pass = action.transpile_pass(self.device, self.node_err, self.edge_err, self.readout_err)
else:
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
assert isinstance(transpile_pass, list)
for p in transpile_pass:
p.apply(tket_qc)
# Map TKET placement into a Qiskit layout
if action_index in self.actions_layout_indices:
try:
placement = transpile_pass[0].get_placement_map(tket_qc)
except Exception as e:
logger.warning(f"Placement failed ({action.name}): {e}. Falling back to original circuit.")
return tk_to_qiskit(tket_qc, replace_implicit_swaps=True)
else:
qc_tmp = tk_to_qiskit(tket_qc, replace_implicit_swaps=True)

qiskit_mapping = {
qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
}
layout = Layout(qiskit_mapping)

pm = PassManager([
SetLayout(layout),
FullAncillaAllocation(coupling_map=CouplingMap(self.device.build_coupling_map())),
EnlargeWithAncilla(),
ApplyLayout(),
])
altered_qc = pm.run(qc_tmp)

self.layout = TranspileLayout(
initial_layout=pm.property_set.get("layout"),
input_qubit_mapping=pm.property_set["original_qubit_indices"],
final_layout=pm.property_set["final_layout"],
_output_qubit_list=altered_qc.qubits,
_input_qubit_count=self.num_qubits_uncompiled_circuit,
)
return altered_qc
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

TKET noise-aware placement and layout fallback look reasonable; narrow the exception and tweak logging.

The TKET branch:

  • Correctly handles the special NoiseAwarePlacement case by lazily caching node_err, edge_err, and readout_err via prepare_noise_data(self.device).
  • Maps the TKET placement back into a Qiskit Layout via SetLayout + FullAncillaAllocation + EnlargeWithAncilla + ApplyLayout, and falls back cleanly to the original circuit if get_placement_map fails (as tested).

Two style/robustness points aligning with the Ruff hints:

  • Line 512 catches a bare Exception:

    except Exception as e:
        logger.warning(f"Placement failed ({action.name}): {e}. Falling back to original circuit.")

    If you can, catch the specific TKET error type raised by get_placement_map instead of all exceptions, to avoid hiding unrelated programming errors.

  • Similarly to fom_aware_compile, prefer parameterized logging over f-strings:

    logger.warning(
        "Placement failed (%s): %s. Falling back to original circuit.",
        action.name,
        e,
    )

These are non-blocking but will quiet BLE001/G004 and make failures easier to reason about.

🧰 Tools
🪛 Ruff (0.14.5)

512-512: Do not catch blind exception: Exception

(BLE001)


513-513: Logging statement uses f-string

(G004)

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 495–538, the code currently
catches a bare Exception from transpile_pass[0].get_placement_map and logs with
an f-string; change this to catch the specific exception type that
get_placement_map raises (inspect pytket docs or run a quick repro to determine
the concrete exception) — e.g., replace "except Exception as e:" with "except
<SpecificTKETException> as e:" — and switch the logger call to use parameterized
logging like logger.warning("Placement failed (%s): %s. Falling back to original
circuit.", action.name, e) so you don't hide unrelated errors and avoid
f-strings in logs.

"""
if sys.platform == "win32":
warn("Timeout is not supported on Windows.", category=RuntimeWarning, stacklevel=2)
logger.info("Timeout is not supported on Windows; running without timeout.")
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using warning level instead of info.

The inability to enforce timeouts on Windows is a significant platform limitation that affects the function's behavior. An info log level may be too low for users to notice this constraint.

Consider using a warning level:

-        logger.info("Timeout is not supported on Windows; running without timeout.")
+        logger.warning("Timeout is not supported on Windows; running without timeout.")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.info("Timeout is not supported on Windows; running without timeout.")
logger.warning("Timeout is not supported on Windows; running without timeout.")
🤖 Prompt for AI Agents
In src/mqt/predictor/utils.py around line 54, the log about timeouts not being
supported on Windows is at info level but should be more noticeable; change
logger.info("Timeout is not supported on Windows; running without timeout.") to
use logger.warning(...) (or logger.warn if project style prefers) so the
platform limitation is emitted as a warning; ensure any surrounding tests or
log-level docs are updated if they assert on exact message level.

Comment on lines +134 to +184
def test_fom_aware_compile_fallback(monkeypatch: MonkeyPatch) -> None:
"""Test fallback of the fom_aware_compile function in case of a compilation failure."""
qc = QuantumCircuit(2)
qc.swap(0, 1)

dummy_action = Action(
name="DummyAction",
origin=CompilationOrigin.QISKIT,
pass_type=PassType.MAPPING,
transpile_pass=lambda _device: [], # no passes applied
)

predictor = Predictor(figure_of_merit="critical_depth", device=get_device("ibm_eagle_127"))
monkeypatch.setattr(
predictor.env, "calculate_reward", lambda _circ: (_ for _ in ()).throw(RuntimeError("fake error"))
)
compiled_qc, prop_set = predictor.env.fom_aware_compile(dummy_action, None, qc, max_iteration=1)

assert isinstance(compiled_qc, QuantumCircuit)
assert isinstance(prop_set, dict)
assert "swap" in compiled_qc.count_ops()


def test_tket_action_layout_failure() -> None:
"""Test fallback in case of TKET layout placement failure."""
qc = QuantumCircuit(1)

class FakePass:
def get_placement_map(self, _: object) -> None:
msg = "fake placement failure"
raise RuntimeError(msg)

def apply(self, _: object) -> None:
pass

dummy_action = Action(
name="DummyLayout",
origin=CompilationOrigin.TKET,
pass_type=PassType.LAYOUT,
transpile_pass=lambda _device: [FakePass()],
)

predictor = Predictor(figure_of_merit="estimated_success_probability", device=get_device("ibm_eagle_127"))
predictor.env.actions_layout_indices.append(0)
predictor.env.state = qc
apply_tket = predictor.env._apply_tket_action # noqa: SLF001
result_qc = apply_tket(dummy_action, 0)

assert isinstance(result_qc, QuantumCircuit)
assert result_qc.num_qubits == 1

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

New fallback tests look good; clean up unused noqa on private attribute access.

The two new tests nicely exercise:

  • The SWAP-count fallback path in fom_aware_compile when calculate_reward fails.
  • The TKET layout failure fallback path in _apply_tket_action.

One minor issue: on Line 179 you use

apply_tket = predictor.env._apply_tket_action  # noqa: SLF001

Ruff reports RUF100 here because SLF001 isn’t an enabled rule, so the noqa is effectively unused. You can either:

  • Drop the # noqa: SLF001 entirely, or
  • Align the noqa with whatever rule your linter actually uses for private attribute access, if any.
🧰 Tools
🪛 Ruff (0.14.5)

179-179: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)

🤖 Prompt for AI Agents
In tests/compilation/test_predictor_rl.py around lines 134-184, the assignment
of the private method uses an unnecessary/incorrect noqa comment on line 179;
remove the trailing " # noqa: SLF001" (or replace it with the project's correct
noqa token if you prefer) so the linter warning is resolved, then run the
linter/tests to confirm no new warnings were introduced.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/mqt/predictor/rl/actions.py (1)

604-673: Classical‑bit restoration in SafeAIRouting is likely incorrect and will break measurements.

The current helper pair:

  • extract_cregs_and_measurements stores item.clbits (clbits from the original circuit) in measurements.
  • add_cregs_and_measurements creates new ClassicalRegisters and adds them to qc, but then reuses the stored clbits as cargs when appending measurements.

Qiskit expects all bits used in append() to belong to the target circuit. Reusing clbits from the original circuit after constructing a fresh one will typically raise a CircuitError or silently attach measurements to foreign bits. In addition, the type annotation list[ClassicalRegister] for the measurement cargs is misleading; these are Clbits, not registers.

You should store measurement cargs in a circuit‑agnostic form (e.g., (creg_name, index) tuples) and reconstruct new Clbits from the fresh registers before appending.

A possible local fix:

-def extract_cregs_and_measurements(
-    qc: QuantumCircuit,
-) -> tuple[list[ClassicalRegister], list[tuple[Instruction, list[Qubit], list[ClassicalRegister]]]]:
+def extract_cregs_and_measurements(
+    qc: QuantumCircuit,
+) -> tuple[
+    list[ClassicalRegister],
+    list[tuple[Instruction, list[Qubit], list[tuple[str, int]]]],
+]:
@@
-    cregs = [ClassicalRegister(cr.size, name=cr.name) for cr in qc.cregs]
-    measurements = [(item.operation, item.qubits, item.clbits) for item in qc.data if item.operation.name == "measure"]
-    return cregs, measurements
+    cregs = [ClassicalRegister(cr.size, name=cr.name) for cr in qc.cregs]
+    measurements: list[tuple[Instruction, list[Qubit], list[tuple[str, int]]]] = []
+    for item in qc.data:
+        if item.operation.name != "measure":
+            continue
+        # Record classical bits by (register name, index) so they can be recreated on a new circuit
+        cbit_specs: list[tuple[str, int]] = []
+        for cbit in item.clbits:
+            reg = cbit.register
+            cbit_specs.append((reg.name, cbit.index))
+        measurements.append((item.operation, list(item.qubits), cbit_specs))
+    return cregs, measurements
@@
-def add_cregs_and_measurements(
-    qc: QuantumCircuit,
-    cregs: list[ClassicalRegister],
-    measurements: list[tuple[Instruction, list[Qubit], list[ClassicalRegister]]],
-    qubit_map: dict[Qubit, Qubit] | None = None,
-) -> QuantumCircuit:
+def add_cregs_and_measurements(
+    qc: QuantumCircuit,
+    cregs: list[ClassicalRegister],
+    measurements: list[tuple[Instruction, list[Qubit], list[tuple[str, int]]]],
+    qubit_map: dict[Qubit, Qubit] | None = None,
+) -> QuantumCircuit:
@@
-    for cr in cregs:
-        qc.add_register(cr)
-    for instr, qargs, cargs in measurements:
-        new_qargs = [qubit_map[q] for q in qargs] if qubit_map else qargs
-        qc.append(instr, new_qargs, cargs)
+    # Add new classical registers and index them by name
+    name_to_creg: dict[str, ClassicalRegister] = {}
+    for cr in cregs:
+        qc.add_register(cr)
+        name_to_creg[cr.name] = cr
+
+    for instr, qargs, cbit_specs in measurements:
+        new_qargs = [qubit_map[q] for q in qargs] if qubit_map else qargs
+        # Recreate cargs as clbits from the new registers
+        new_cargs = [name_to_creg[name][idx] for name, idx in cbit_specs]
+        qc.append(instr, new_qargs, new_cargs)
     return qc

This keeps the SafeAIRouting logic but ensures measurements and classical wires are correctly recreated on the routed circuit.

Also applies to: 676-714

♻️ Duplicate comments (3)
noxfile.py (1)

64-77: Cache cleanup remains too aggressive and may cause CI instability.

Despite previous reviews flagging this, Line 72 still removes the entire ~/.cache directory, which affects all tools system-wide (npm, cargo, pip, pre-commit, etc.), not just nox/uv. This can:

  • Interfere with parallel CI jobs or other processes on the same runner
  • Slow down subsequent steps that depend on caches
  • Affect non-GitHub-Actions CI systems that set CI=true (GitLab CI, CircleCI, etc.)

Additionally, Line 67's hardcoded "3.13" check is fragile—if PYTHON_ALL_VERSIONS is reordered or extended, the logic breaks.

Apply this diff to target specific caches and make the version check dynamic:

 def _cleanup(session: nox.Session) -> None:
     """Remove this session's virtualenv to save disk space in CI."""
     version = session.python
-    if version != "3.13":  # keep cache for last run
+    # Keep cache for last Python version
+    if version != PYTHON_ALL_VERSIONS[-1]:
         venv_dir = session.virtualenv.location
         if venv_dir and pathlib.Path(venv_dir).exists():
             shutil.rmtree(venv_dir, ignore_errors=True)
             session.log(f"Cleaned up {venv_dir}")
-        shutil.rmtree(pathlib.Path("~/.cache").expanduser(), ignore_errors=True)
+        # Only remove session-specific caches
+        cache_dirs = [
+            pathlib.Path("~/.cache/uv").expanduser(),
+            pathlib.Path("~/.cache/pip").expanduser(),
+        ]
+        for cache_dir in cache_dirs:
+            if cache_dir.exists():
+                shutil.rmtree(cache_dir, ignore_errors=True)
+                session.log(f"Cleaned up {cache_dir}")
         gha_temp = pathlib.Path("/home/runner/work/_temp/setup-uv-cache")
         if gha_temp.exists():
             shutil.rmtree(gha_temp, ignore_errors=True)
             session.log(f"Cleaned GitHub Actions uv temp cache at {gha_temp}")

Alternatively, if this cleanup is GitHub Actions-specific, gate it on GITHUB_ACTIONS instead of just CI at Line 112.

src/mqt/predictor/utils.py (1)

34-52: Align timeout_watcher docs and log level with actual behavior.

The function no longer raises a RuntimeWarning on Windows; it just logs and continues. Please:

  • Drop RuntimeWarning from the “Raises” section, and
  • Consider using logger.warning instead of logger.info so the lack of timeout support is more visible.

Also applies to: 53-55

src/mqt/predictor/rl/predictorenv.py (1)

240-247: Decomposition branch and unused noqa could be tightened.

  • With the current if ... elif ... structure, circuits containing both "unitary" and "clifford" ops only decompose "unitary". If the intent is “decompose both when present”, consider using two independent if statements.
  • Ruff reports the # noqa: SLF001 on self.state._layout as an unused directive; either drop the comment or enable that rule in your lint config.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e7b4174 and cb4d0fb.

📒 Files selected for processing (5)
  • noxfile.py (3 hunks)
  • src/mqt/predictor/rl/actions.py (21 hunks)
  • src/mqt/predictor/rl/helper.py (3 hunks)
  • src/mqt/predictor/rl/predictorenv.py (12 hunks)
  • src/mqt/predictor/utils.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
src/mqt/predictor/rl/helper.py (1)
src/mqt/predictor/utils.py (2)
  • calc_supermarq_features (93-145)
  • get_openqasm_gates (148-187)
src/mqt/predictor/rl/predictorenv.py (4)
src/mqt/predictor/rl/actions.py (5)
  • CompilationOrigin (103-109)
  • DeviceDependentAction (150-161)
  • PassType (112-121)
  • Action (125-141)
  • run (686-714)
src/mqt/predictor/rl/helper.py (1)
  • create_feature_dict (84-110)
src/mqt/predictor/rl/parsing.py (5)
  • final_layout_bqskit_to_qiskit (186-226)
  • final_layout_pytket_to_qiskit (167-183)
  • postprocess_vf2postlayout (229-250)
  • prepare_noise_data (253-278)
  • apply (89-92)
src/mqt/predictor/utils.py (1)
  • get_openqasm_gates (148-187)
src/mqt/predictor/utils.py (1)
src/mqt/predictor/ml/helper.py (1)
  • get_openqasm_gates (53-99)
🪛 Ruff (0.14.5)
src/mqt/predictor/rl/predictorenv.py

246-246: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)


414-414: Do not catch blind exception: Exception

(BLE001)


415-415: Logging statement uses f-string

(G004)


423-423: Logging statement uses f-string

(G004)


519-519: Do not catch blind exception: Exception

(BLE001)


520-520: Logging statement uses f-string

(G004)

🔇 Additional comments (9)
noxfile.py (2)

21-21: LGTM!

The pathlib import is necessary for the Path operations in the _cleanup function.


112-113: Integration of cleanup is correct.

The conditional invocation of _cleanup only in CI environments is appropriate, and the placement after test execution is correct. However, please address the concerns raised about the _cleanup implementation itself.

src/mqt/predictor/rl/actions.py (3)

124-142: Action / DeviceDependentAction type-signature extensions look consistent with usage.

Allowing transpile_pass to be either a concrete pass list or a factory (Callable[..., list[Any] | list[qiskit_BasePass | tket_BasePass] | Callable[[Circuit], ...]]) aligns with how you construct Qiskit/TKET/BQSKit actions, and the new stochastic / preserve_layout flags match how PredictorEnv consumes them. No issues spotted here.

Also applies to: 149-160


193-203: Layout‑preserving and stochastic action registrations are coherent with the new env logic.

Marking passes like CommutativeCancellation, CommutativeInverseCancellation, RemoveDiagonalGatesBeforeMeasure, InverseCancellation, QiskitO3, Optimize1qGatesDecomposition_preserve, and Opt2qBlocks_preserve as preserve_layout=True matches their semantics and integrates cleanly with actions_mapping_preserving_indices. Similarly, flagging SabreSwap, SabreMapping, and AIRouting variants as stochastic=True is consistent with fom_aware_compile. The TKET KAKDecomposition/GraphPlacement/NoiseAwarePlacement registrations and the updated VF2PostLayout time_limit=100 also look correct.

Also applies to: 204-212, 214-276, 278-287, 287-310, 330-337, 357-384, 386-399, 402-409


445-472: Device‑dependent placement/mapping actions align with predictorenv usage.

The new GraphPlacement and NoiseAwarePlacement actions return a single TKET pass in a list, which _apply_tket_action expects, and the BQSKit mapping/synthesis parametrization (including max_synthesis_size=3) remains consistent with the BQSKit helpers. No functional issues detected here.

Also applies to: 486-496, 532-564, 577-592

src/mqt/predictor/rl/helper.py (1)

20-21: Normalized per‑gate features are consistent with the new observation space.

Using get_openqasm_gates() to define both the normalized gate histogram (dict_to_featurevector) and the gate_spaces in PredictorEnv keeps the feature vector and observation_space in sync; excluding "barrier" from the total while treating "measure" separately also makes sense. The Supermarq features are preserved unchanged. Looks good.

Also applies to: 72-82, 84-110

src/mqt/predictor/rl/predictorenv.py (3)

120-123: Action indexing, mapping‑preserving tracking, and observation space wiring look solid.

  • Using get_actions_by_pass_type() plus actions_mapping_preserving_indices keyed off preserve_layout integrates cleanly with the new determine_valid_actions_for_state.
  • The observation_space’s gate_spaces derived from get_openqasm_gates() matches the per‑gate features built in create_feature_dict, including the normalized "measure" feature.
  • New env attributes (max_iter, noise‑error caches) align with their later use in fom_aware_compile and NoiseAwarePlacement.

No functional issues here.

Also applies to: 130-142, 181-193, 196-199


431-459: Qiskit layout post‑processing wiring appears correct.

  • For layout/mapping/final‑opt actions, routing all postprocessing through _handle_qiskit_layout_postprocessing and using TranspileLayout to store initial_layout, original_qubit_indices, and final_layout mirrors how Qiskit exposes layout info via the property set.
  • The VF2PostLayout/VF2Layout branches guard on the corresponding stop reasons and assert presence of layouts before updating self.layout.

This structure matches the new action registrations and the VF2 helpers in parsing.py; I don’t see correctness issues here.

Also applies to: 469-500


597-630: determine_valid_actions_for_state matches the intended native/mapped/layout policy.

The decision tree over (only_nat_gates, mapped, self.layout):

  • Sends non‑native, unmapped circuits to synthesis+opt,
  • Restricts non‑native but mapped circuits to synthesis + mapping‑preserving opts,
  • For native+mapped circuits with a layout, allows only terminate + mapping‑preserving + final‑opt,
  • And otherwise routes between layout, mapping, and routing as expected,

which is exactly what you described in the PR for avoiding invalid reuse of outdated layouts. This looks correct and a clear improvement in policy.

Comment on lines +250 to 262
def calculate_reward(self, qc: QuantumCircuit | None = None) -> float:
"""Calculates and returns the reward for the current state."""
circuit = self.state if qc is None else qc
if self.reward_function == "expected_fidelity":
return expected_fidelity(self.state, self.device)
return expected_fidelity(circuit, self.device)
if self.reward_function == "estimated_success_probability":
return estimated_success_probability(self.state, self.device)
return estimated_success_probability(circuit, self.device)
if self.reward_function == "estimated_hellinger_distance":
return estimated_hellinger_distance(self.state, self.device, self.hellinger_model)
return estimated_hellinger_distance(circuit, self.device, self.hellinger_model)
if self.reward_function == "critical_depth":
return crit_depth(self.state)
assert_never(self.state)
return crit_depth(circuit)
assert_never(circuit)

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor doc / assert_never nit in calculate_reward.

Now that calculate_reward accepts an optional qc, the docstring (“for the current state”) is slightly misleading. Consider updating it to reflect the qc parameter. Also, using assert_never(self.reward_function) would be more idiomatic than assert_never(circuit) in the unreachable branch, though this is purely stylistic.

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 250 to 262, update the
docstring to reflect that calculate_reward can compute reward for either the
current state or an optionally provided QuantumCircuit qc, and replace the final
assert_never(circuit) with assert_never(self.reward_function) so the unreachable
branch asserts on the discriminant (reward_function) instead of the circuit;
keep behavior identical otherwise.

Comment on lines 371 to 545
def _apply_tket_action(self, action: Action, action_index: int) -> QuantumCircuit:
tket_qc = qiskit_to_tk(self.state, preserve_param_uuid=True)
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
if action.name == "NoiseAwarePlacement":
# Handle NoiseAwarePlacement separately (requires error data)
if self.node_err is None or self.edge_err is None or self.readout_err is None:
self.node_err, self.edge_err, self.readout_err = prepare_noise_data(self.device)
assert callable(action.transpile_pass)
transpile_pass = action.transpile_pass(self.device, self.node_err, self.edge_err, self.readout_err)
else:
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
assert isinstance(transpile_pass, list)
for p in transpile_pass:
p.apply(tket_qc)
# Map TKET placement into a Qiskit layout
if action_index in self.actions_layout_indices:
try:
placement = transpile_pass[0].get_placement_map(tket_qc)
except Exception as e:
logger.warning(f"Placement failed ({action.name}): {e}. Falling back to original circuit.")
return tk_to_qiskit(tket_qc, replace_implicit_swaps=True)
else:
qc_tmp = tk_to_qiskit(tket_qc, replace_implicit_swaps=True)

qiskit_mapping = {
qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
}
layout = Layout(qiskit_mapping)

pm = PassManager([
SetLayout(layout),
FullAncillaAllocation(coupling_map=CouplingMap(self.device.build_coupling_map())),
EnlargeWithAncilla(),
ApplyLayout(),
])
altered_qc = pm.run(qc_tmp)

self.layout = TranspileLayout(
initial_layout=pm.property_set.get("layout"),
input_qubit_mapping=pm.property_set["original_qubit_indices"],
final_layout=pm.property_set["final_layout"],
_output_qubit_list=altered_qc.qubits,
_input_qubit_count=self.num_qubits_uncompiled_circuit,
)
return altered_qc
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

TKET placement→Qiskit Layout mapping should not rely on dict key order.

In the TKET layout branch you compute:

placement = transpile_pass[0].get_placement_map(tket_qc)
qiskit_mapping = {
    qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0]
    for i in range(len(placement))
}

This implicitly assumes that:

  • placement.keys() are ordered by TKET qubit index, and
  • that index coincides with qc_tmp.qubits[i].

If get_placement_map ever changes iteration order, the layout will be wrong. It’s safer to iterate over placement.items() and use the TKET qubit’s index to pick the corresponding Qiskit qubit explicitly.

For example (pseudo‑code, adjust to the actual TKET types):

-        placement = transpile_pass[0].get_placement_map(tket_qc)
+        placement = transpile_pass[0].get_placement_map(tket_qc)
@@
-        qiskit_mapping = {
-            qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
-        }
+        qiskit_mapping = {}
+        for tket_qubit, node in placement.items():
+            # Map TKET qubit index back to the corresponding Qiskit qubit
+            qiskit_q = qc_tmp.qubits[tket_qubit.index[0]]
+            qiskit_mapping[qiskit_q] = node.index[0]

This makes the mapping robust to any dictionary ordering and clearer to future readers.


🌐 Web query:

Verify the exact return type and semantics of `GraphPlacement.get_placement_map` / `NoiseAwarePlacement.get_placement_map` in the current pytket documentation (mapping from `Qubit` to `Node` vs other variants) to ensure the explicit loop above uses the correct attributes (`.index`, etc.).

💡 Result:

Short answer: get_placement_map returns a dict mapping pytket.unit_id.Qubit -> pytket.unit_id.Node, so in your loop use the UnitID attributes (e.g. .index or .reg_name) on keys and values. Evidence:

  • Placement API: get_placement_map signature and return documented as dict[Qubit, Node]. [1]
  • Qubit / Node are UnitID types with the .index (index vector) and .reg_name properties. Use key.index and value.index to get numeric indices. [2]

Example (conceptual):
for q, n in placement.items():
q_idx = q.index
n_idx = n.index

References:
[1] pytket.placement (get_placement_map returns dict[Qubit, Node]).
[2] pytket.unit_id (Qubit, Node, and UnitID.property index / reg_name).


TKET placement→Qiskit Layout mapping should not rely on dict key order.

The current code assumes placement.keys() is ordered to match qc_tmp.qubits[i] indexing, which is fragile. Since get_placement_map() returns dict[Qubit, Node] where both types are UnitID objects with .index properties, refactor to iterate explicitly:

qiskit_mapping = {}
for tket_qubit, node in placement.items():
    qiskit_q = qc_tmp.qubits[tket_qubit.index[0]]
    qiskit_mapping[qiskit_q] = node.index[0]

This eliminates the ordering assumption, is clearer to readers, and correctly uses the pytket API.

🧰 Tools
🪛 Ruff (0.14.5)

519-519: Do not catch blind exception: Exception

(BLE001)


520-520: Logging statement uses f-string

(G004)

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 502–545 the
placement→Qiskit mapping assumes dict key order when building qiskit_mapping;
replace that fragile logic by iterating placement.items() and mapping each
tket_qubit to the corresponding qc_tmp.qubits[tket_qubit.index[0]] and assigning
node.index[0] as the physical index, i.e. build qiskit_mapping by looping over
(tket_qubit, node) in placement.items(), lookup qc_tmp.qubits at
tket_qubit.index[0] and set that as the key with value node.index[0]; this
removes reliance on dict ordering and correctly uses the pytket UnitID.index
fields.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/mqt/predictor/utils.py (1)

54-54: Consider using warning level instead of info.

This issue was previously flagged: the inability to enforce timeouts on Windows is a significant platform limitation that affects the function's behavior. An info log level may be too low for users to notice this constraint.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c73f098 and 4d613a2.

📒 Files selected for processing (2)
  • src/mqt/predictor/utils.py (2 hunks)
  • tests/hellinger_distance/test_estimated_hellinger_distance.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/mqt/predictor/utils.py (1)
src/mqt/predictor/ml/helper.py (1)
  • get_openqasm_gates (53-99)
tests/hellinger_distance/test_estimated_hellinger_distance.py (2)
src/mqt/predictor/ml/predictor.py (2)
  • compile_training_circuits (206-240)
  • generate_training_data (242-300)
tests/device_selection/test_predictor_ml.py (2)
  • path_compiled_circuits (33-35)
  • path_uncompiled_circuits (27-29)
⏰ Context from checks skipped due to timeout of 900000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: 🐍 Test (windows-2022) / 🐍 windows-2022
  • GitHub Check: 🐍 Test (ubuntu-24.04) / 🐍 ubuntu-24.04
  • GitHub Check: 🐍 Test (macos-14) / 🐍 macos-14
🔇 Additional comments (2)
tests/hellinger_distance/test_estimated_hellinger_distance.py (2)

219-221: Confirm necessity of very long per-circuit timeout (6000s) in this test.

The longer timeout likely accommodates slower stochastic actions, but a 6000s limit per circuit can significantly delay CI if something hangs. Please confirm this value is really needed with the new RL/max-iteration settings, or consider a lower but still generous bound (e.g., 1200–1800s) to keep failures timely.


224-224: LGTM on delegating worker selection to generate_training_data.

Dropping the explicit num_workers argument and just passing the test paths is consistent with the new NO_PARALLEL handling inside generate_training_data, and keeps the test aligned with the library’s behavior across platforms.

Comment on lines 148 to 185
def get_openqasm_gates() -> list[str]:
"""Returns a list of all quantum gates within the openQASM 2.0 standard header."""
# according to https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/qasm/libs/qelib1.inc
# u,u0,u1,u2,u3 gates are excluded as they are not meaningful as RL features
# Drop multi-qubit gates with more than two qubits, since Qiskit's `basis_gates` argument does not support them.
return [
"cx",
"id",
"p",
"x",
"y",
"z",
"h",
"s",
"sdg",
"t",
"tdg",
"r",
"rx",
"ry",
"rz",
"sx",
"sxdg",
"cz",
"cy",
"swap",
"ch",
"crx",
"cry",
"crz",
"cu1",
"cp",
"cu3",
"csx",
"cu",
"rxx",
"rzz",
]
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Code duplication: identical function name with different behavior.

This function has the same name as get_openqasm_gates() in src/mqt/predictor/ml/helper.py but returns a different gate list. The ML version includes u, u0, u1, u2, u3, ccx, cswap, rccx, rc3x, c3x, c3sqrtx, and c4x, while this version excludes those gates. Having identically named functions with different behaviors in separate modules creates confusion and maintenance burden.

Additionally, this version includes the "r" gate (line 165), which does not appear in the ML version or the referenced qelib1.inc. Verify that "r" is a valid Qiskit gate and is intentionally included for RL feature computation.

Consider one of the following approaches:

  1. Rename the functions to reflect their specific purposes (e.g., get_rl_openqasm_gates() and get_ml_openqasm_gates()).
  2. Consolidate into a single function with parameters to control filtering (e.g., include_parameterized=True, max_qubits=None).
  3. Factor out a common base and have each module apply its own filters.

To verify the "r" gate inclusion and usage:


🏁 Script executed:

#!/bin/bash
# Description: Verify "r" gate is valid and check its usage in the codebase

# Check if "r" gate is used in RL-related code
rg -nP --type=py -C3 '\br\b.*gate|gate.*\br\b' -g 'rl/**'

# Search for "r" gate references in Qiskit-related contexts
rg -nP --type=py -C3 '"r"' -g '**/*.py' | rg -A3 -B3 'gate|basis'

Length of output: 148


I'll verify the code duplication and the "r" gate issue with improved scripts and a web search.Now let me search for more specific information about the code duplication in the repository:


🏁 Script executed:

#!/bin/bash
# Find both get_openqasm_gates functions to verify code duplication
echo "=== Searching for get_openqasm_gates function definitions ==="
rg -nP --type=py 'def get_openqasm_gates' 

echo -e "\n=== Checking ml/helper.py location and content ==="
fd 'helper.py' -t f | head -20

echo -e "\n=== Searching for imports of get_openqasm_gates ==="
rg -nP --type=py 'from.*get_openqasm_gates|import.*get_openqasm_gates'

echo -e "\n=== Checking usage in RL-related code ==="
rg -nP --type=py 'get_openqasm_gates' -B2 -A2

Length of output: 4414


Now let me extract the actual gate lists to compare them precisely:


🏁 Script executed:

#!/bin/bash
# Extract both get_openqasm_gates function definitions to compare lists
echo "=== utils.py version (NEW - for RL) ==="
sed -n '148,185p' src/mqt/predictor/utils.py

echo -e "\n=== ml/helper.py version (EXISTING - for ML) ==="
sed -n '53,98p' src/mqt/predictor/ml/helper.py

Length of output: 1812


Now let me verify if the "r" gate is a valid Qiskit gate:

Based on my verification, I can now provide the final assessment:

Eliminate code duplication: consolidate or rename get_openqasm_gates() functions.

Both src/mqt/predictor/utils.py:148 and src/mqt/predictor/ml/helper.py:53 define identically named functions that return intentionally different gate lists. The RL version (utils.py) filters out u-family gates and excludes multi-qubit gates with >2 qubits, while the ML version includes them. The "r" gate is valid in Qiskit—a parametric rotation gate that can be applied to a QuantumCircuit.

The different gate lists serve distinct purposes (RL vs ML feature extraction), but identical function names across modules creates maintenance confusion and violates the single responsibility principle. Each module currently uses its local implementation (ml/helper.py uses its own; rl/* modules import from utils.py), but future developers may incorrectly assume both versions are identical.

Rename one or both functions to reflect their purpose:

  • get_rl_openqasm_gates() in utils.py
  • get_ml_openqasm_gates() in ml/helper.py

Or consolidate with a configuration parameter to reduce duplication while keeping the logic clear.

🤖 Prompt for AI Agents
In src/mqt/predictor/utils.py around lines 148-185, there is a
get_openqasm_gates() function whose name collides with an identically named
function in src/mqt/predictor/ml/helper.py but returns a different gate set for
RL; rename this function to get_rl_openqasm_gates() (or otherwise make its name
reflect RL usage) and update all internal imports/call sites in the RL code to
use the new name; alternatively, if you prefer a single implementation, extract
a shared function that accepts a mode/config parameter (e.g., mode="rl"|"ml")
and replace both implementations and their callers with the single configurable
function, ensuring tests and imports are updated accordingly.

@Shaobo-Zhou Shaobo-Zhou changed the title Add new actions and improves handling of stochastic actions Refined MDP for RL Nov 25, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4d613a2 and 16fa006.

📒 Files selected for processing (1)
  • src/mqt/predictor/ml/predictor.py (5 hunks)
🔇 Additional comments (1)
src/mqt/predictor/ml/predictor.py (1)

64-66: NO_PARALLEL guard and documentation look good

The platform/version condition and the accompanying comment (including the upstream issue link) clearly document why multiprocessing is disabled on Windows + Python 3.13+. This is a targeted, low‑risk workaround that centralizes the behavior in a single constant.

Comment on lines +234 to +235
# On Windows + Python 3.13, joblib's default "loky" process backend is broken
# (missing `_posixsubprocess`). Fall back to no multiprocessing.
num_jobs = 1 if NO_PARALLEL else num_workers
Parallel(n_jobs=num_jobs, verbose=100)(
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consistent use of NO_PARALLEL; consider a tiny helper to avoid repetition

The conditional num_jobs = 1 if NO_PARALLEL else ... pattern is applied consistently across compilation, data generation, and model training and correctly funnels all parallelism through the same platform guard.

If you want to reduce duplication and make future changes (e.g., tweaking behavior or adding logging) easier, you could factor this into a small helper:

+def _effective_num_jobs(requested: int) -> int:
+    return 1 if NO_PARALLEL else requested
+
 ...
-        num_jobs = 1 if NO_PARALLEL else num_workers
+        num_jobs = _effective_num_jobs(num_workers)
 ...
-        num_jobs = 1 if NO_PARALLEL else num_workers
+        num_jobs = _effective_num_jobs(num_workers)
 ...
-        num_jobs = 1 if NO_PARALLEL else 8
+        num_jobs = _effective_num_jobs(8)

Not mandatory, but it would centralize the logic if this workaround needs revisiting later.

Also applies to: 277-287, 417-420

🤖 Prompt for AI Agents
In src/mqt/predictor/ml/predictor.py around lines 234-237 (and also apply to
ranges 277-287 and 417-420), the code repeats the pattern `num_jobs = 1 if
NO_PARALLEL else num_workers` before Parallel calls; create a small helper
function (e.g., _resolve_num_jobs(num_workers) or get_num_jobs) near the top of
the module that returns 1 when NO_PARALLEL is true otherwise returns the
provided num_workers, then replace the repeated inline conditional with calls to
that helper in all three locations; keep behavior identical but centralize logic
so future changes or logging can be added in one place.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (5)
src/mqt/predictor/utils.py (1)

54-54: Consider using warning level for platform limitation.

The inability to enforce timeouts on Windows is a significant platform constraint that affects function behavior. Users may overlook an info-level message when troubleshooting timeout-related issues.

Apply this diff:

-        logger.info("Timeout is not supported on Windows; running without timeout.")
+        logger.warning("Timeout is not supported on Windows; running without timeout.")
src/mqt/predictor/rl/predictorenv.py (4)

422-432: Improve exception handling and logging practices.

Three issues flagged by static analysis and best practices:

  1. Lines 422, 431: Broad Exception catches (BLE001) may hide unexpected errors
  2. Lines 423, 431: f-string logging (G004) defeats lazy evaluation
  3. Line 431: Should use logger.exception to include traceback

Apply this diff to narrow exceptions and fix logging:

                 except Exception as e:
-                    logger.warning(f"[Fallback to SWAP counts] Synthesis or fidelity computation failed: {e}")
+                    logger.warning("[Fallback to SWAP counts] Synthesis or fidelity computation failed: %s", e)
                     swap_count = out_circ.count_ops().get("swap", 0)
                     if best_result is None or swap_count < best_swap_count:
                         best_swap_count = swap_count
                         best_result = out_circ
                         best_property_set = prop_set
 
             except Exception:
-                logger.exception(f"[Error] Pass failed at iteration {i + 1}")
+                logger.exception("[Error] Pass failed at iteration %d", i + 1)
                 continue

For production robustness, consider catching specific Qiskit exceptions (e.g., qiskit.transpiler.TranspilerError, qiskit.exceptions.QiskitError) before the broad Exception catch, though the current broad handling is acceptable for the RL loop's fault tolerance requirements.


250-261: Update docstring and fix assert_never argument.

The docstring states "for the current state" but the method now accepts an optional qc parameter. Additionally, assert_never should be called on the discriminant (self.reward_function) rather than the data (circuit).

Apply this diff:

-    def calculate_reward(self, qc: QuantumCircuit | None = None) -> float:
-        """Calculates and returns the reward for the current state."""
+    def calculate_reward(self, qc: QuantumCircuit | None = None) -> float:
+        """Calculates and returns the reward for either the current state or a provided quantum circuit."""
         circuit = self.state if qc is None else qc
         if self.reward_function == "expected_fidelity":
             return expected_fidelity(circuit, self.device)
         if self.reward_function == "estimated_success_probability":
             return estimated_success_probability(circuit, self.device)
         if self.reward_function == "estimated_hellinger_distance":
             return estimated_hellinger_distance(circuit, self.device, self.hellinger_model)
         if self.reward_function == "critical_depth":
             return crit_depth(circuit)
-        assert_never(circuit)
+        assert_never(self.reward_function)

240-246: Fix decomposition logic and remove unused noqa directive.

Two issues:

  1. The elif at line 243 prevents decomposing both unitary and clifford when a circuit contains both gate types. Only unitary would be decomposed.
  2. Line 246 has an unused noqa: SLF001 directive (Ruff RUF100) since SLF001 is not enabled in your lint configuration.

Apply this diff:

-        # in case the Qiskit.QuantumCircuit has unitary or u gates or clifford in it, decompose them (because otherwise qiskit will throw an error when applying the BasisTranslator
+        # Decompose unitary/clifford gates to avoid BasisTranslator errors
         if self.state.count_ops().get("unitary"):
             self.state = self.state.decompose(gates_to_decompose="unitary")
-        elif self.state.count_ops().get("clifford"):
+        if self.state.count_ops().get("clifford"):
             self.state = self.state.decompose(gates_to_decompose="clifford")
 
-        self.state._layout = self.layout  # noqa: SLF001
+        self.state._layout = self.layout

524-553: TKET placement mapping should not rely on dict key order.

Line 534 uses placement[list(placement.keys())[i]] which assumes dictionary keys are ordered to match qc_tmp.qubits[i] indexing. This is fragile and may break if TKET's get_placement_map() changes iteration order.

Since get_placement_map() returns dict[Qubit, Node] where both have .index properties, iterate explicitly:

-                qiskit_mapping = {
-                    qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
-                }
+                qiskit_mapping = {}
+                for tket_qubit, node in placement.items():
+                    qiskit_q = qc_tmp.qubits[tket_qubit.index[0]]
+                    qiskit_mapping[qiskit_q] = node.index[0]
                 layout = Layout(qiskit_mapping)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 16fa006 and a3d4baa.

📒 Files selected for processing (4)
  • CHANGELOG.md (1 hunks)
  • src/mqt/predictor/rl/helper.py (3 hunks)
  • src/mqt/predictor/rl/predictorenv.py (12 hunks)
  • src/mqt/predictor/utils.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/mqt/predictor/rl/helper.py (2)
src/mqt/predictor/utils.py (2)
  • calc_supermarq_features (93-145)
  • get_rl_openqasm_gates (148-185)
src/mqt/predictor/ml/helper.py (1)
  • dict_to_featurevector (102-109)
src/mqt/predictor/rl/predictorenv.py (4)
src/mqt/predictor/rl/actions.py (4)
  • CompilationOrigin (103-109)
  • PassType (112-121)
  • Action (125-141)
  • run (686-714)
src/mqt/predictor/rl/helper.py (1)
  • create_feature_dict (84-110)
src/mqt/predictor/rl/parsing.py (4)
  • final_layout_bqskit_to_qiskit (186-226)
  • final_layout_pytket_to_qiskit (167-183)
  • postprocess_vf2postlayout (229-250)
  • apply (89-92)
src/mqt/predictor/utils.py (1)
  • get_rl_openqasm_gates (148-185)
🪛 Ruff (0.14.6)
src/mqt/predictor/rl/predictorenv.py

246-246: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)


422-422: Do not catch blind exception: Exception

(BLE001)


423-423: Logging statement uses f-string

(G004)


431-431: Logging statement uses f-string

(G004)


527-527: Do not catch blind exception: Exception

(BLE001)


528-528: Logging statement uses f-string

(G004)

🔇 Additional comments (5)
CHANGELOG.md (1)

14-14: LGTM!

The changelog entry is well-formatted, includes the PR reference and author attribution, and accurately describes the benchmark level change.

src/mqt/predictor/utils.py (1)

148-185: LGTM!

The function is well-documented with clear rationale for excluding certain gates. The renaming to get_rl_openqasm_gates() successfully resolves the name collision with the ML version in ml/helper.py, making the distinction between RL and ML gate lists explicit.

src/mqt/predictor/rl/helper.py (2)

72-81: LGTM!

The function correctly computes normalized gate fractions, properly excluding "barrier" from the total count. The normalization (returning dict[str, float]) intentionally differs from the ML version in ml/helper.py (which returns dict[str, int]), reflecting the distinct feature requirements for RL training.


93-99: LGTM!

The integration of per-gate features via dict_to_featurevector and the normalized "measure" feature enhances the state representation for RL. The zero-division guard (if total > 0) is appropriate.

src/mqt/predictor/rl/predictorenv.py (1)

605-638: LGTM!

The refactored logic clearly distinguishes between native/non-native and mapped/unmapped states, with helpful inline comments explaining each branch. The separation of mapping-preserving actions ensures layout consistency when the circuit structure changes.

Comment on lines 527 to 529
except Exception as e:
logger.warning(f"Placement failed ({action.name}): {e}. Falling back to original circuit.")
return tk_to_qiskit(tket_qc, replace_implicit_swaps=True)
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Improve exception handling and logging for TKET placement.

Two issues flagged by static analysis:

  1. Line 527: Broad Exception catch (BLE001)
  2. Line 528: f-string logging (G004)

Apply this diff:

             try:
                 placement = transpile_pass[0].get_placement_map(tket_qc)
             except Exception as e:
-                logger.warning(f"Placement failed ({action.name}): {e}. Falling back to original circuit.")
+                logger.warning("Placement failed (%s): %s. Falling back to original circuit.", action.name, e)
                 return tk_to_qiskit(tket_qc, replace_implicit_swaps=True)

If possible, catch the specific TKET exception type raised by get_placement_map instead of all exceptions.

🧰 Tools
🪛 Ruff (0.14.6)

527-527: Do not catch blind exception: Exception

(BLE001)


528-528: Logging statement uses f-string

(G004)

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 527 to 529, replace the
broad except Exception and f-string logging: import and catch the specific TKET
exception raised by get_placement_map (for example pytket.routing.PlacementError
or the correct exception type from pytket in use) instead of Exception, and
change the logger call to use parameterized logging (e.g.
logger.warning("Placement failed (%s): %s. Falling back to original circuit.",
action.name, err)) so you pass the exception object as a parameter rather than
using an f-string; keep the existing return behavior.

Shaobo-Zhou and others added 2 commits November 27, 2025 15:32
Signed-off-by: Shaobo-Zhou <109073755+Shaobo-Zhou@users.noreply.github.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (3)
src/mqt/predictor/rl/predictorenv.py (3)

240-248: Decomposition coverage and unused noqa

Two small issues here:

  • With if ... "unitary" ... elif ... "clifford", a circuit containing both gate types will only have unitary decomposed in this step. If the intent is to decompose both to avoid BasisTranslator issues in one go, consider two independent if checks.
  • The # noqa: SLF001 is reported as unused (RUF100). Since SLF001 is not enabled, you can drop the # noqa comment.

250-261: Update calculate_reward docstring and unreachable‑branch assert

The behavior and typing are fine, but two nits:

  • The docstring still says “for the current state” even though qc allows computing a reward for an arbitrary circuit; it would be clearer to mention that.
  • In the unreachable branch, assert_never(self.reward_function) is more idiomatic than asserting on circuit, since the discriminant is self.reward_function.

497-541: TKET placement→Qiskit layout mapping relies on dict key order

The TKET layout branch is a nice addition, but the current mapping:

qiskit_mapping = {
    qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0]
    for i in range(len(placement))
}

implicitly assumes that:

  • placement.keys() iteration order aligns with TKET qubit indices, and
  • those indices correspond to qc_tmp.qubits[i].

If get_placement_map ever changes iteration order, or if keys are not ordered by index, the resulting Layout can be wrong.

More robust is to iterate items explicitly and use the TKET qubit’s index to look up the corresponding Qiskit qubit, e.g.:

qiskit_mapping: dict[Qubit, int] = {}
for tket_qubit, node in placement.items():
    qiskit_q = qc_tmp.qubits[tket_qubit.index[0]]
    qiskit_mapping[qiskit_q] = node.index[0]
layout = Layout(qiskit_mapping)

This removes the ordering assumption and directly uses the UnitID indices.

The broad except Exception as e around get_placement_map is also acceptable for robustness, but if you know the concrete TKET exception type, catching that instead would avoid masking unrelated bugs.

Also applies to: 548-553

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a3d4baa and 914192c.

📒 Files selected for processing (3)
  • pyproject.toml (5 hunks)
  • src/mqt/predictor/rl/predictorenv.py (12 hunks)
  • tests/hellinger_distance/test_estimated_hellinger_distance.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/hellinger_distance/test_estimated_hellinger_distance.py (2)
src/mqt/predictor/ml/predictor.py (2)
  • compile_training_circuits (206-240)
  • generate_training_data (242-300)
tests/device_selection/test_predictor_ml.py (2)
  • path_compiled_circuits (33-35)
  • path_uncompiled_circuits (27-29)
🪛 Ruff (0.14.6)
src/mqt/predictor/rl/predictorenv.py

246-246: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)


409-409: Do not catch blind exception: Exception

(BLE001)


410-410: Logging statement uses f-string

(G004)


418-418: Logging statement uses f-string

(G004)


514-514: Do not catch blind exception: Exception

(BLE001)

⏰ Context from checks skipped due to timeout of 900000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: 🐍 Test (macos-14) / 🐍 macos-14
  • GitHub Check: 🐍 Test (windows-2022) / 🐍 windows-2022
  • GitHub Check: 🐍 Test (ubuntu-24.04) / 🐍 ubuntu-24.04
🔇 Additional comments (8)
tests/hellinger_distance/test_estimated_hellinger_distance.py (2)

219-221: Verify the 10x timeout increase is intentional.

The timeout has been increased from the default 600 seconds to 6000 seconds. While this change simplifies the code by removing OS-specific branching (which is good), the 10x timeout increase is significant.

Please confirm this timeout value is necessary for test stability. If this is a conservative upper bound, consider whether a lower value might be sufficient to keep test execution time reasonable.


224-224: LGTM!

Removing the num_workers parameter is correct since it has a default value and the internal NO_PARALLEL flag now handles platform-specific parallelism issues.

pyproject.toml (3)

35-38: LGTM!

The pytket_qiskit upgrade to >=0.71.0 is necessary for compatibility with recent qiskit-ibm-runtime versions, as confirmed in the past review comments. The TODO comment appropriately tracks the constraint on qiskit-ibm-runtime.


134-135: LGTM!

The Windows timeout warning suppression aligns with the internal NO_PARALLEL handling introduced in the codebase to address Windows + Python 3.13 joblib issues. This is consistent with the test file changes.


173-173: LGTM!

Adding qiskit_ibm_transpiler.* to the mypy ignore list is necessary for the new transpiler dependencies that lack type stubs.

src/mqt/predictor/rl/predictorenv.py (3)

27-85: Import and helper wiring looks consistent

The additional TYPE_CHECKING imports and helper/parsing imports (actions, helper, parsing, utils) are all used below and align with the new functionality; I don’t see issues here.


120-199: State extensions (mapping‑preserving indices, gate features, noise cache) are coherent

The introduction of actions_mapping_preserving_indices, the extended observation space using get_rl_openqasm_gates(), and the cached node_err/edge_err/readout_err fields are internally consistent and match the PR’s described behavior.


592-625: State‑dependent action selection is much clearer now

The reworked determine_valid_actions_for_state cleanly separates:

  • non‑native vs native gates,
  • mapped vs unmapped circuits, and
  • presence/absence of a layout,

and uses actions_mapping_preserving_indices exactly as described in the PR to avoid layout‑breaking passes after mapping. This matches the intended policy and significantly improves readability.

Comment on lines +51 to +52
"qiskit-ibm-transpiler==0.13.1; python_version < '3.13'", # Do not support python 3.13 yet
"qiskit-ibm-ai-local-transpiler==0.4.2; python_version < '3.13'",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check latest versions for the pinned transpiler packages

echo "=== Checking qiskit-ibm-transpiler ==="
curl -s https://pypi.org/pypi/qiskit-ibm-transpiler/json | \
  jq -r '"Version pinned: 0.13.1\nLatest version: " + .info.version'

echo -e "\n=== Checking qiskit-ibm-ai-local-transpiler ==="
curl -s https://pypi.org/pypi/qiskit-ibm-ai-local-transpiler/json | \
  jq -r '"Version pinned: 0.4.2\nLatest version: " + .info.version'

echo -e "\n=== Checking release dates ==="
echo "qiskit-ibm-transpiler releases:"
curl -s https://pypi.org/pypi/qiskit-ibm-transpiler/json | \
  jq -r '.releases | keys[] | select(. == "0.13.1" or startswith("0.14") or startswith("0.15") or startswith("1."))' | head -5

echo -e "\nqiskit-ibm-ai-local-transpiler releases:"
curl -s https://pypi.org/pypi/qiskit-ibm-ai-local-transpiler/json | \
  jq -r '.releases | keys[] | select(. == "0.4.2" or startswith("0.5") or startswith("1."))' | head -5

Repository: munich-quantum-toolkit/predictor

Length of output: 208


🌐 Web query:

qiskit-ibm-transpiler latest version PyPI

💡 Result:

The latest PyPI release is qiskit-ibm-transpiler 0.14.5 — published on Nov 21, 2025. [1]

You can install it with: pip install qiskit-ibm-transpiler. [1]

Sources:
[1] PyPI project page for qiskit-ibm-transpiler.


🌐 Web query:

qiskit-ibm-ai-local-transpiler latest version PyPI

💡 Result:

Latest PyPI version: 0.4.2 (released Jul 2, 2025). [1]

Install: pip install qiskit-ibm-ai-local-transpiler==0.4.2. [1]

Sources:
[1] PyPI project page — qiskit-ibm-ai-local-transpiler (release history).


🌐 Web query:

qiskit-ibm-transpiler security advisories CVE

💡 Result:

Relevant security advisories / CVEs affecting Qiskit (including QPY/Qiskit SDK / IBM runtime components):

  • CVE‑2025‑2000 — Arbitrary code execution when deserializing QPY format < 13 (qiskit.qpy.load). Fixed by requiring QPY v13+ / patches. (ibm.com)
  • CVE‑2025‑1403 — DoS via maliciously crafted QPY (malformed symengine serialization causes segfault). CVSS 8.6. (ibm.com)
  • CVE‑2024‑29032 — qiskit-ibm-runtime RuntimeDecoder unsafe deserialization leading to possible arbitrary code execution (fixed in qiskit-ibm-runtime 0.21.2+). (wiz.io)
  • GHSA/GitHub advisories (e.g., unsafe eval in qiskit-ibm-runtime Options.get_program_inputs) — patched in point releases (see package advisories). (wiz.io)

If you want, I can fetch official vendor advisory pages, NVD entries, and exact fixed versions/mitigations for each CVE.

Citations:


🌐 Web query:

qiskit-ibm-ai-local-transpiler security advisories CVE

💡 Result:

Short answer: I found no public CVE that specifically names qiskit-ibm-ai-local-transpiler. Related/nearby Qiskit security advisories you should watch:

  • IBM security bulletin for CVE‑2025‑1403 (Qiskit SDK QPY deserialization → remote DoS). [1]
  • CVE‑2024‑29032 (qiskit‑ibm‑runtime JSON deserialization issue) — affects runtime components, not the ai-local-transpiler package itself. [2]
  • The qiskit-ibm-ai-local-transpiler package page / releases (for tracking updates). [3]

Sources:
[1] IBM Security Bulletin — Qiskit SDK vulnerability (CVE‑2025‑1403). [turn0search1]
[2] CVE‑2024‑29032 entry / advisory references (qiskit‑ibm‑runtime). [turn0search5]
[3] qiskit-ibm-ai-local-transpiler PyPI / release info. [turn0search4]

If you want, I can: (a) monitor for new CVEs for that package and alert you, or (b) search specific GitHub security advisories/issue trackers for the repo. Which do you prefer?


🌐 Web query:

qiskit-ibm-transpiler 0.14.5 Python 3.13 support compatibility

💡 Result:

Yes — qiskit-ibm-transpiler 0.14.5 is published for Python 3 (built/uploaded with CPython 3.13.7), so it supports Python 3.13. [1][2]

Sources:

  • PyPI package details for qiskit-ibm-transpiler 0.14.5 (upload metadata shows twine/CPython/3.13.7). [1]
  • Qiskit Transpiler Service release notes (latest 0.14.x series). [2]

🌐 Web query:

qiskit-ibm-transpiler 0.14.0 release notes Python version requirements

💡 Result:

qiskit-ibm-transpiler 0.14.0 (released 2025-08-21) supports Python 3.9 and newer (the package wheels are published as py3 / py3-none-any). [1][2]

Sources:

  • Release notes (0.14.0 — Aug 21, 2025). [1]
  • piwheels / package wheel listings showing py3 wheels for 0.14.0. [2]

Update qiskit-ibm-transpiler — pinned version is outdated and Python 3.13 support now available.

qiskit-ibm-transpiler 0.13.1 is behind the current release (0.14.5, published Nov 21, 2025). Newer versions now support Python 3.13, so the python_version < '3.13' constraint can be removed or updated. Consider upgrading to 0.14.5 or verifying compatibility with the specific features in use.

qiskit-ibm-ai-local-transpiler 0.4.2 is already the latest version (released Jul 2, 2025) and has no known security advisories. The Python < 3.13 constraint for this package should be verified separately to determine if it still applies.

🤖 Prompt for AI Agents
In pyproject.toml around lines 51-52, the qiskit-ibm-transpiler entry is pinned
to 0.13.1 with a python_version < '3.13' constraint that is now outdated; update
the dependency to "qiskit-ibm-transpiler==0.14.5" and remove the ";
python_version < '3.13'" marker (or relax it) so Python 3.13 is allowed, then
run CI/tests to verify compatibility and adjust code if any API changes surface;
also verify whether the python_version constraint on
qiskit-ibm-ai-local-transpiler is still necessary and remove or update it if
not.

Comment on lines +261 to 264
[tool.uv]
override-dependencies = [
"networkx==2.8.5", # Required by `qiskit-ibm-transpiler`
]
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check qiskit-ibm-transpiler's networkx dependency requirements

echo "=== Checking qiskit-ibm-transpiler dependencies ==="
# Get the package info - try multiple versions to see requirements
curl -s https://pypi.org/pypi/qiskit-ibm-transpiler/json | jq -r '.releases | keys[]' | grep -E '^0\.(13|12|14)' | sort -V | tail -3

echo -e "\n=== Fetching latest qiskit-ibm-transpiler info ==="
curl -s https://pypi.org/pypi/qiskit-ibm-transpiler/json | jq -r '.info | "\(.version): requires_dist=\(.requires_dist)"' | head -20

echo -e "\n=== Checking networkx requirements from qiskit-ibm-transpiler ==="
curl -s https://pypi.org/pypi/qiskit-ibm-transpiler/json | jq -r '.info.requires_dist[]? | select(. | contains("networkx"))' 

echo -e "\n=== Checking networkx releases around 2.8.5 ==="
curl -s https://pypi.org/pypi/networkx/json | jq -r '.releases | keys[] | select(startswith("2.8") or startswith("2.9") or startswith("3."))' | sort -V

echo -e "\n=== Checking if 2.8.5 exists ==="
curl -s https://pypi.org/pypi/networkx/2.8.5/json | jq -r '.info.version // "NOT_FOUND"'

Repository: munich-quantum-toolkit/predictor

Length of output: 208


🏁 Script executed:

#!/bin/bash
# Check qiskit-ibm-transpiler's networkx dependency requirements

echo "=== Checking qiskit-ibm-transpiler latest version ==="
LATEST_QIT=$(curl -s https://pypi.org/pypi/qiskit-ibm-transpiler/json | jq -r '.info.version')
echo "Latest version: $LATEST_QIT"

echo -e "\n=== Checking networkx requirements from qiskit-ibm-transpiler ==="
curl -s https://pypi.org/pypi/qiskit-ibm-transpiler/json | jq -r '.info.requires_dist[]? | select(. | contains("networkx"))' 

echo -e "\n=== Checking networkx release history around 2.8.5 ==="
curl -s https://pypi.org/pypi/networkx/json | jq -r '.releases | keys[] | select(startswith("2.8") or startswith("2.9") or startswith("3.0") or startswith("3.1"))' | sort -V | tail -10

echo -e "\n=== Verifying networkx 2.8.5 exists ==="
curl -s https://pypi.org/pypi/networkx/2.8.5/json 2>/dev/null | jq -r '.info.version // .info // "NOT_FOUND"' | head -1

echo -e "\n=== Checking networkx compatibility across versions ==="
curl -s https://pypi.org/pypi/networkx/json | jq -r '.releases | keys[] | select(startswith("2.8") or startswith("2.9"))' | sort -V

Repository: munich-quantum-toolkit/predictor

Length of output: 208


🌐 Web query:

qiskit-ibm-transpiler networkx version requirement pypi

💡 Result:

On PyPI qiskit-ibm-transpiler declares networkx as a dependency but without a pinned version specifier (i.e. it requires "networkx" generically). [1][2]

Sources:

  • qiskit-ibm-transpiler PyPI project page (dependency: networkx). [1]
  • qiskit-ibm-transpiler release notes (added networkx to requirements in 0.11.1). [2]

🌐 Web query:

networkx 2.8.5 qiskit compatibility requirements

💡 Result:

Short answer: Qiskit no longer requires networkx for most core functionality, but some parts still need it — so networkx 2.8.5 is fine to use if you install it when needed.

Details (sources):

  • Since qiskit-terra's move to retworkx, networkx is not a mandatory dependency for Terra; only a few methods that convert DAGCircuit to/from networkx require you to install networkx manually. [1]
  • Some Qiskit subpackages (e.g., older Ignis modules / topological_codes) explicitly listed networkx as a dependency in earlier releases; if you use those subpackages or any API that converts to networkx, install networkx 2.8.x. [2][3]

Recommendation: If your code calls qiskit.dagcircuit.DAGCircuit.to_networkx / from_networkx or uses qiskit-ignis/topological code, pip-install networkx (pip install "networkx==2.8.5" or a compatible 2.8.x). Otherwise you can rely on the default qiskit installation which uses retworkx for graph operations. [1][2]

Sources:
[1] Qiskit release notes (qiskit 0.25) — networkx no longer required for qiskit-terra; DAGCircuit conversions remain.
[2] Qiskit release notes (0.18) — retworkx introduced as requirement replacing networkx for performance.
[3] Qiskit Ignis notes / earlier releases — networkx explicitly listed for Ignis topological modules.


The networkx==2.8.5 pin is unnecessarily restrictive and not actually required by qiskit-ibm-transpiler.

qiskit-ibm-transpiler declares networkx as a dependency without specifying a version constraint—it accepts any networkx version. Pinning to exactly 2.8.5 is overly strict and likely a legacy requirement that will cause unnecessary dependency conflicts. Consider using a more flexible constraint (e.g., networkx>=2.8 or removing the override entirely) unless there's a specific compatibility issue with newer versions.

🤖 Prompt for AI Agents
In pyproject.toml around lines 261 to 264, the hard pin "networkx==2.8.5" is
unnecessarily restrictive; update the override to a flexible constraint (for
example "networkx>=2.8" or "networkx>=2.8,<3" depending on compatibility) or
remove the override entirely so qiskit-ibm-transpiler can use its declared
networkx range; apply the change and run dependency resolution/tests to ensure
no regressions.

Comment on lines +362 to +424
def fom_aware_compile(
self, action: Action, device: Target, qc: QuantumCircuit, max_iteration: int = 4
) -> tuple[QuantumCircuit, PropertySet | None]:
"""Run a stochastic pass multiple times optimizing for the given figure of merit.
Args:
action: The action containing the transpile pass.
device: The compilation target device.
qc: The input quantum circuit.
max_iteration: Maximum number of attempts to run the pass.
Returns:
A tuple of the best circuit found and its property set (if available).
"""
best_result = None
best_property_set = None
best_fom = -1.0
best_swap_count = float("inf") # for fallback

assert callable(action.transpile_pass), "Mapping action should be callable"
for i in range(max_iteration):
pm = PassManager(action.transpile_pass(device))
try:
out_circ = pm.run(qc)
prop_set = dict(pm.property_set)

try:
# For fidelity-based metrics, do a cheap "lookahead" synthesis step:
# routing may have introduced non-native SWAPs, so we translate the
# circuit into the device's native basis before evaluating the metric.
#
# Note:
# - BasisTranslator *only* performs basis conversion; it does not optimize.
# - This isolates the effect of mapping (inserted SWAPs) on fidelity
# without conflating it with further optimizations.

synth_pass = PassManager([
BasisTranslator(StandardEquivalenceLibrary, target_basis=device.operation_names)
])
synth_circ = synth_pass.run(out_circ.copy())
fom = self.calculate_reward(synth_circ)

if fom > best_fom:
best_fom = fom
best_result = out_circ
best_property_set = prop_set

except Exception as e:
logger.warning(f"[Fallback to SWAP counts] Synthesis or fidelity computation failed: {e}")
swap_count = out_circ.count_ops().get("swap", 0)
if best_result is None or swap_count < best_swap_count:
best_swap_count = swap_count
best_result = out_circ
best_property_set = prop_set

except Exception:
logger.exception(f"[Error] Pass failed at iteration {i + 1}")
continue

if best_result is not None:
return best_result, best_property_set
logger.error("All attempts failed.")
return qc, None
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Unify pm_property_set typing and hard final_layout access

fom_aware_compile currently advertises PropertySet | None but actually returns dict(pm.property_set), and _apply_qiskit_action annotates pm_property_set: PropertySet | None while _handle_qiskit_layout_postprocessing expects dict[str, Any] | None. This mismatch is confusing for type checkers and readers.

Consider:

  • Changing fom_aware_compile’s return type to tuple[QuantumCircuit, dict[str, Any] | None].
  • Initializing pm_property_set as None and typing it as dict[str, Any] | None in _apply_qiskit_action.
  • Keeping _handle_qiskit_layout_postprocessing’s parameter type as dict[str, Any] | None so the whole pipeline is consistent.

Additionally, for the routing branch:

elif action_index in self.actions_routing_indices and self.layout and pm_property_set is not None:
    self.layout.final_layout = pm_property_set["final_layout"]

will raise KeyError if a pass fails to set "final_layout". Using .get("final_layout") (and checking for None) would make this more robust against variations in Qiskit pass behavior.

Also applies to: 427-461, 465-495

🧰 Tools
🪛 Ruff (0.14.6)

409-409: Do not catch blind exception: Exception

(BLE001)


410-410: Logging statement uses f-string

(G004)


418-418: Logging statement uses f-string

(G004)

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 362-424 (and similarly
apply to 427-461, 465-495), the function fom_aware_compile advertises returning
PropertySet | None but actually returns dict(pm.property_set), causing type
mismatches with downstream code; change its declared return type to
tuple[QuantumCircuit, dict[str, Any] | None], initialize pm_property_set
variables as dict[str, Any] | None (starting at None) in _apply_qiskit_action,
and propagate that type through the pipeline; additionally, replace direct hard
access pm_property_set["final_layout"] with a safe lookup (e.g.,
pm_property_set.get("final_layout")) and check for None before assigning to
self.layout.final_layout to avoid KeyError when a pass does not set
final_layout.

Comment on lines +409 to +411
except Exception as e:
logger.warning(f"[Fallback to SWAP counts] Synthesis or fidelity computation failed: {e}")
swap_count = out_circ.count_ops().get("swap", 0)
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Logging style and broad exception handling in fom_aware_compile

For the stochastic compile loop:

  • The broad except Exception blocks are probably intentional for robustness against arbitrary pass failures, but they will also hide programming errors. If feasible, narrowing to expected Qiskit exceptions (or documenting why a blanket catch is required) would make intent clearer.
  • To satisfy logging best practices and the Ruff G004 hint, you can switch to parameterized logging:
logger.warning(
    "[Fallback to SWAP counts] Synthesis or fidelity computation failed: %s",
    e,
)
logger.exception("[Error] Pass failed at iteration %d", i + 1)

Behavior stays the same, but avoids eager f‑string formatting.

Also applies to: 417-419

🧰 Tools
🪛 Ruff (0.14.6)

409-409: Do not catch blind exception: Exception

(BLE001)


410-410: Logging statement uses f-string

(G004)

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 409-411 and similarly at
417-419, replace the broad except Exception and the f-string logging with a
narrower, documented catch of expected Qiskit/runtime exceptions where possible
(or add a short comment justifying the blanket catch if truly required), and
switch logging to parameterized style (logger.warning("[Fallback to SWAP counts]
Synthesis or fidelity computation failed: %s", e)) to avoid eager formatting;
additionally consider adding logger.exception with context (e.g., iteration
number) when you want the traceback logged for debugging.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
src/mqt/predictor/rl/predictorenv.py (2)

250-261: Update docstring and use correct assert_never argument.

  1. The docstring should reflect that qc can be provided to calculate reward for a specific circuit.
  2. assert_never(circuit) should be assert_never(self.reward_function) since reward_function is the discriminant being exhaustively checked.
     def calculate_reward(self, qc: QuantumCircuit | None = None) -> float:
-        """Calculates and returns the reward for the current state."""
+        """Calculates and returns the reward for the current state or provided circuit."""
         circuit = self.state if qc is None else qc
         if self.reward_function == "expected_fidelity":
             return expected_fidelity(circuit, self.device)
         if self.reward_function == "estimated_success_probability":
             return estimated_success_probability(circuit, self.device)
         if self.reward_function == "estimated_hellinger_distance":
             return estimated_hellinger_distance(circuit, self.device, self.hellinger_model)
         if self.reward_function == "critical_depth":
             return crit_depth(circuit)
-        assert_never(circuit)
+        assert_never(self.reward_function)

240-246: Two minor issues: elif prevents decomposing both gate types, and unused noqa directive.

  1. If a circuit contains both unitary and clifford gates, only unitary will be decomposed. If both should be handled, use separate if statements.

  2. The # noqa: SLF001 directive is unused (rule not enabled) per static analysis.

Apply this diff if both gate types should be decomposed:

-        if self.state.count_ops().get("unitary"):
+        if self.state.count_ops().get("unitary"):
             self.state = self.state.decompose(gates_to_decompose="unitary")
-        elif self.state.count_ops().get("clifford"):
+        if self.state.count_ops().get("clifford"):
             self.state = self.state.decompose(gates_to_decompose="clifford")
 
-        self.state._layout = self.layout  # noqa: SLF001
+        self.state._layout = self.layout
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a3d4baa and 914192c.

📒 Files selected for processing (3)
  • pyproject.toml (5 hunks)
  • src/mqt/predictor/rl/predictorenv.py (12 hunks)
  • tests/hellinger_distance/test_estimated_hellinger_distance.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/hellinger_distance/test_estimated_hellinger_distance.py (2)
src/mqt/predictor/ml/predictor.py (2)
  • compile_training_circuits (206-240)
  • generate_training_data (242-300)
tests/device_selection/test_predictor_ml.py (2)
  • path_compiled_circuits (33-35)
  • path_uncompiled_circuits (27-29)
🪛 Ruff (0.14.6)
src/mqt/predictor/rl/predictorenv.py

246-246: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)


409-409: Do not catch blind exception: Exception

(BLE001)


410-410: Logging statement uses f-string

(G004)


418-418: Logging statement uses f-string

(G004)


514-514: Do not catch blind exception: Exception

(BLE001)

⏰ Context from checks skipped due to timeout of 900000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: 🐍 Test (macos-14) / 🐍 macos-14
  • GitHub Check: 🐍 Test (windows-2022) / 🐍 windows-2022
  • GitHub Check: 🐍 Test (ubuntu-24.04) / 🐍 ubuntu-24.04
🔇 Additional comments (10)
src/mqt/predictor/rl/predictorenv.py (6)

137-142: LGTM! The preserve_layout attribute check is now correct.

The previous bug where preserve was checked instead of preserve_layout has been properly fixed.


196-199: LGTM! Noise data caching is a good pattern.

The lazy initialization of node_err, edge_err, and readout_err avoids unnecessary computation until NoiseAwarePlacement is actually used.


362-424: LGTM! The FOM comparison logic is now correct.

The previous issue where the minimization branch was unreachable has been properly addressed. All reward functions are now uniformly maximized (including critical_depth which returns 1 - depth), and best_fom = -1.0 is appropriate since all metrics are in [0, 1].

The static analysis flags for broad Exception catches (BLE001) and f-string logging (G004) were noted in past reviews. The broad catches are reasonable here for RL robustness against arbitrary transpiler failures.


426-461: LGTM! Clean separation of stochastic vs. non-stochastic action handling.

The refactoring properly routes stochastic actions through fom_aware_compile while maintaining backward compatibility for deterministic passes. The conditional coupling map usage based on layout presence is correct.


464-495: LGTM! Cleaner interface using property set dictionary.

The refactoring to accept pm_property_set dictionary instead of direct PassManager references improves modularity and makes the function easier to test.


592-625: LGTM! Well-structured action validation logic with clear documentation.

The refactored determine_valid_actions_for_state method correctly handles all circuit states (native/non-native gates, mapped/unmapped, with/without layout) with informative comments explaining each case. This is a significant improvement in readability.

tests/hellinger_distance/test_estimated_hellinger_distance.py (1)

219-225: Updated predictor API usage looks consistent

Using keyword arguments for compile_training_circuits and generate_training_data without num_workers aligns with the updated predictor API and removes the Windows-specific control flow from the test. The wiring between source_path/target_path and the compiled/data-generation stages looks correct.

pyproject.toml (3)

35-52: Dependency bumps and new transpiler packages: validate cross-version compatibility

The bumps and new constraints here (pytket_qiskit>=0.71.0, bounded qiskit-ibm-runtime, stable-baselines3>=2.7.0, and the two IBM transpiler packages for python_version < "3.13") all look coherent with the new RL/transpilation functionality and the comments in this PR. The Python-version marker on the IBM transpiler deps is a sensible way to avoid pulling them in where they’re not yet supported.

Please double-check against the current release notes of pytket_qiskit, qiskit-ibm-runtime, stable-baselines3, and the IBM transpiler packages that:

  • their supported Python and Qiskit versions match these ranges, and
  • there are no newer bugfix releases you’d want to pick up before merging.

134-135: Windows timeout RuntimeWarning filter is correctly scoped

The new filterwarnings entry matches the exact RuntimeWarning message text and keeps the global error policy from failing tests on Windows for this known, non-fatal condition. That’s a reasonable, tightly scoped suppression.

Please confirm on a Windows + Python 3.13 environment that:

  • this warning originates from the expected code path (joblib/timeout handling), and
  • no unrelated warnings are accidentally silenced by this pattern.

173-173: Extending mypy ignore_missing_imports to qiskit_ibm_transpiler is appropriate

Adding qiskit_ibm_transpiler.* to the ignore_missing_imports override is consistent with how other heavy third-party dependencies are treated and keeps strict mypy settings workable while you rely on this package.

After this change, please run mypy once on the PR branch to confirm there are no new import-related regressions for the IBM transpiler modules.

Comment on lines +259 to +263
ignore = ["GH200"]

[tool.uv]
override-dependencies = [
"networkx==2.8.5", # Required by `qiskit-ibm-transpiler`
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Scope of networkx==2.8.5 override may be broader than needed

The uv override-dependencies pin of networkx==2.8.5 is justified for qiskit-ibm-transpiler, but it applies unconditionally to all environments, including those where qiskit-ibm-transpiler isn’t installed (e.g., Python ≥3.13). That may unnecessarily constrain networkx for other tools. If uv supports environment markers here, consider mirroring the Python guard you use for qiskit-ibm-transpiler so the override only activates where that dependency is present.

Please check uv’s documentation for override-dependencies to confirm whether environment markers (e.g., "; python_version < '3.13'") are supported, and, if so, adjust this entry to match the qiskit-ibm-transpiler Python constraint.

🤖 Prompt for AI Agents
In pyproject.toml around lines 259 to 263, the unconditional
override-dependencies pin "networkx==2.8.5" applies to all environments even
where qiskit-ibm-transpiler is not used; check uv's documentation to confirm
whether override-dependencies supports environment markers (for example ;
python_version < "3.13"), and if it does, change the override entry to include
the same Python guard used for qiskit-ibm-transpiler so the pin only applies in
environments where that package is present; if uv does not support markers,
document this limitation in a comment and consider moving the pin into the
specific environment or constraint file where qiskit-ibm-transpiler is declared.

Comment on lines +520 to +522
qiskit_mapping = {
qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Dict key ordering assumption is fragile.

The current code assumes placement.keys() iteration order matches qc_tmp.qubits[i] indexing:

qiskit_mapping = {
    qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
}

This is fragile and was flagged in a previous review. If get_placement_map ever changes iteration order, the layout will be incorrect.

Use explicit iteration over placement.items() with the TKET qubit's index:

-                qiskit_mapping = {
-                    qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
-                }
+                qiskit_mapping = {}
+                for tket_qubit, node in placement.items():
+                    qiskit_q = qc_tmp.qubits[tket_qubit.index[0]]
+                    qiskit_mapping[qiskit_q] = node.index[0]

This uses the pytket UnitID.index attributes directly, making the mapping robust to any dictionary ordering.

🤖 Prompt for AI Agents
In src/mqt/predictor/rl/predictorenv.py around lines 520 to 522, the dict
comprehension uses placement.keys() ordering to align TKET qubits with
qc_tmp.qubits indices which is fragile; instead iterate placement.items() and
use each TKET UnitID.index to select the corresponding qc_tmp.qubits entry so
the mapping does not rely on dict order. Replace the current comprehension with
one that loops over unit, loc in placement.items() (or equivalent) and maps
qc_tmp.qubits[unit.index] to loc.index[0], ensuring you use the UnitID.index
attribute directly for a stable mapping.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (4)
src/mqt/predictor/rl/predictorenv.py (4)

240-247: Decompose both unitary and clifford gates and remove unused noqa.

Right now, the elif means circuits containing both unitary and clifford gates will only have unitary decomposed, potentially leaving clifford in place and still upsetting BasisTranslator. Also, # noqa: SLF001 is flagged by Ruff as an unused directive since SLF001 isn’t enabled.

You can address both points like this:

-        # in case the Qiskit.QuantumCircuit has unitary or u gates or clifford in it, decompose them (because otherwise qiskit will throw an error when applying the BasisTranslator
-        if self.state.count_ops().get("unitary"):
-            self.state = self.state.decompose(gates_to_decompose="unitary")
-        elif self.state.count_ops().get("clifford"):
-            self.state = self.state.decompose(gates_to_decompose="clifford")
-
-        self.state._layout = self.layout  # noqa: SLF001
+        # in case the Qiskit.QuantumCircuit has unitary or clifford gates, decompose them
+        if self.state.count_ops().get("unitary"):
+            self.state = self.state.decompose(gates_to_decompose="unitary")
+        if self.state.count_ops().get("clifford"):
+            self.state = self.state.decompose(gates_to_decompose="clifford")
+
+        self.state._layout = self.layout

If you still want to enforce “private attribute access” linting elsewhere, you can configure that in Ruff instead of keeping a now-ineffective noqa.


250-261: Update calculate_reward docstring and assert to reflect the new signature.

The method now accepts an optional qc, but the docstring still only mentions the “current state”, and the unreachable branch asserts on circuit instead of the discriminant (reward_function).

A small refactor keeps behavior but makes intent clearer:

-    def calculate_reward(self, qc: QuantumCircuit | None = None) -> float:
-        """Calculates and returns the reward for the current state."""
-        circuit = self.state if qc is None else qc
-        if self.reward_function == "expected_fidelity":
-            return expected_fidelity(circuit, self.device)
-        if self.reward_function == "estimated_success_probability":
-            return estimated_success_probability(circuit, self.device)
-        if self.reward_function == "estimated_hellinger_distance":
-            return estimated_hellinger_distance(circuit, self.device, self.hellinger_model)
-        if self.reward_function == "critical_depth":
-            return crit_depth(circuit)
-        assert_never(circuit)
+    def calculate_reward(self, qc: QuantumCircuit | None = None) -> float:
+        """Calculates and returns the reward for either the current state or a given circuit."""
+        circuit = self.state if qc is None else qc
+        if self.reward_function == "expected_fidelity":
+            return expected_fidelity(circuit, self.device)
+        if self.reward_function == "estimated_success_probability":
+            return estimated_success_probability(circuit, self.device)
+        if self.reward_function == "estimated_hellinger_distance":
+            return estimated_hellinger_distance(circuit, self.device, self.hellinger_model)
+        if self.reward_function == "critical_depth":
+            return crit_depth(circuit)
+        assert_never(self.reward_function)

This also makes the “implicit return None” path clearly unreachable to both humans and type-checkers.


362-424: Strengthen fom_aware_compile sentinel and logging/typing.

The overall multi-trial selection logic looks good, but a few tweaks would make it more robust and clearer:

  • best_fom = -1.0 assumes all reward values are ≥ 0; using float("-inf") avoids coupling this helper to the current set of metrics and is safer for future figures of merit.
  • best_property_set is always a plain dict (via dict(pm.property_set)), so the return type PropertySet | None is misleading and forces downstream casts.
  • Ruff flags blind exceptions and f-strings in logging here; parameterized logging plus an explicit comment/# noqa keeps intent while satisfying tooling.

Suggested changes:

-    def fom_aware_compile(
-        self, action: Action, device: Target, qc: QuantumCircuit, max_iteration: int = 4
-    ) -> tuple[QuantumCircuit, PropertySet | None]:
+    def fom_aware_compile(
+        self, action: Action, device: Target, qc: QuantumCircuit, max_iteration: int = 4
+    ) -> tuple[QuantumCircuit, dict[str, Any] | None]:
@@
-        best_result = None
-        best_property_set = None
-        best_fom = -1.0
+        best_result = None
+        best_property_set: dict[str, Any] | None = None
+        best_fom = float("-inf")
@@
-                except Exception as e:
-                    logger.warning(f"[Fallback to SWAP counts] Synthesis or fidelity computation failed: {e}")
+                except Exception as e:  # noqa: BLE001  # Broad catch is intentional to keep RL loop robust to arbitrary passes
+                    logger.warning(
+                        "[Fallback to SWAP counts] Synthesis or fidelity computation failed: %s",
+                        e,
+                    )
@@
-            except Exception:
-                logger.exception(f"[Error] Pass failed at iteration {i + 1}")
+            except Exception:  # noqa: BLE001
+                logger.exception("[Error] Pass failed at iteration %d", i + 1)

You’d also want _apply_qiskit_action and _handle_qiskit_layout_postprocessing to treat pm_property_set as dict[str, Any] | None for consistency (see next comment).


497-540: Make TKET placement→Qiskit layout mapping independent of dict key order.

The current mapping:

qiskit_mapping = {
    qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
}

implicitly assumes that:

  • placement.keys() is ordered in a way that matches qc_tmp.qubits[i], and
  • TKET’s placement dict iteration order is stable and aligned with Qiskit’s qubit ordering.

That’s fragile and can lead to incorrect layouts if TKET ever changes insertion order or the mapping isn’t constructed in that exact pattern. It’s safer to use the TKET qubit’s own index to locate the corresponding Qiskit qubit explicitly.

For example:

-            try:
-                placement = transpile_pass[0].get_placement_map(tket_qc)
-            except Exception as e:
-                logger.warning("Placement failed (%s): %s. Falling back to original circuit.", action.name, e)
-                return tk_to_qiskit(tket_qc, replace_implicit_swaps=True)
-            else:
-                qc_tmp = tk_to_qiskit(tket_qc, replace_implicit_swaps=True)
-
-                qiskit_mapping = {
-                    qc_tmp.qubits[i]: placement[list(placement.keys())[i]].index[0] for i in range(len(placement))
-                }
+            try:
+                placement = transpile_pass[0].get_placement_map(tket_qc)
+            except Exception as e:  # noqa: BLE001  # TKET placement can fail in multiple ways; we fall back safely.
+                logger.warning("Placement failed (%s): %s. Falling back to original circuit.", action.name, e)
+                return tk_to_qiskit(tket_qc, replace_implicit_swaps=True)
+            else:
+                qc_tmp = tk_to_qiskit(tket_qc, replace_implicit_swaps=True)
+
+                qiskit_mapping = {}
+                for tket_qubit, node in placement.items():
+                    # Map TKET qubit index back to the corresponding Qiskit qubit
+                    qiskit_q = qc_tmp.qubits[tket_qubit.index[0]]
+                    qiskit_mapping[qiskit_q] = node.index[0]

This uses the Qubit.index[0] / Node.index[0] attributes directly and no longer depends on dict ordering, which should be more robust across pytket versions.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a3d4baa and 914192c.

📒 Files selected for processing (3)
  • pyproject.toml (5 hunks)
  • src/mqt/predictor/rl/predictorenv.py (12 hunks)
  • tests/hellinger_distance/test_estimated_hellinger_distance.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/mqt/predictor/rl/predictorenv.py (5)
src/mqt/predictor/rl/helper.py (2)
  • create_feature_dict (84-110)
  • get_state_sample (32-69)
src/mqt/predictor/rl/parsing.py (2)
  • final_layout_bqskit_to_qiskit (186-226)
  • prepare_noise_data (253-278)
src/mqt/predictor/utils.py (1)
  • get_rl_openqasm_gates (148-185)
src/mqt/predictor/reward.py (3)
  • expected_fidelity (46-74)
  • estimated_success_probability (102-226)
  • estimated_hellinger_distance (327-349)
tests/compilation/test_predictor_rl.py (1)
  • get_placement_map (162-164)
🪛 Ruff (0.14.6)
src/mqt/predictor/rl/predictorenv.py

246-246: Unused noqa directive (non-enabled: SLF001)

Remove unused noqa directive

(RUF100)


409-409: Do not catch blind exception: Exception

(BLE001)


410-410: Logging statement uses f-string

(G004)


418-418: Logging statement uses f-string

(G004)


514-514: Do not catch blind exception: Exception

(BLE001)

⏰ Context from checks skipped due to timeout of 900000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: 🐍 Test (macos-14) / 🐍 macos-14
  • GitHub Check: 🐍 Test (windows-2022) / 🐍 windows-2022
  • GitHub Check: 🐍 Test (ubuntu-24.04) / 🐍 ubuntu-24.04
🔇 Additional comments (6)
tests/hellinger_distance/test_estimated_hellinger_distance.py (2)

224-224: Unable to verify — repository access failure prevents codebase inspection.

The review comment requests verification that generate_training_data calls don't pass num_workers as an external caller concern. However, the repository could not be cloned for inspection. Without access to:

  • The actual generate_training_data definition and its signature changes
  • All call sites of generate_training_data across the codebase
  • Whether this is part of a public API

I cannot confirm or refute the verification request.


219-221: The repository is not accessible in the sandbox environment. However, based on the review comment provided, I can see the key concerns that were raised:

  1. The 6000-second (100-minute) timeout - flagged as exceptionally long
  2. Internal NO_PARALLEL logic - mentioned in PR context as part of Windows/Python 3.13 parallelism fallback

Since I cannot access the codebase to verify these implementation details directly, I'll provide the rewritten review comment based on the available context:


Verify the exceptionally long 6000-second timeout and confirm NO_PARALLEL logic handles all platforms correctly.

The simplification to a unified code path (removing Windows-specific handling and num_workers parameter) is good. However, the 6000-second (100-minute) timeout for a test operation is exceptionally long and may mask performance issues or cause tests to hang indefinitely if something fails.

Please confirm:

  1. Is the 6000s timeout required for CI stability on Windows/Python 3.13, or can it be reduced?
  2. Verify that the internal NO_PARALLEL fallback logic correctly handles parallel execution across Windows, Linux, and macOS with Python 3.13+, since the parallelism control has moved from explicit num_workers parameters to internal boolean logic.
pyproject.toml (2)

51-66: Clarify Python 3.13 support: dependencies are gated <3.13 but classifiers claim 3.14 support.

Lines 51-52 gate qiskit-ibm-transpiler and qiskit-ibm-ai-local-transpiler to python_version < '3.13', yet the project's classifiers (line 66) declare support for Python 3.14. This creates an ambiguity: will Python 3.14 users have these transpiler dependencies available, or is 3.14 support aspirational pending upstream fixes? Clarify the intent and update either the classifiers or the version constraints accordingly.


40-40: I'm unable to access the repository directly due to clone failures. However, I can assess the review comment based on the provided context. Let me evaluate whether the verification requests are reasonable and well-formed:

The original review comment raises three concerns:

  1. stable-baselines3>=2.7.0 version validation - requests confirmation this is the correct minimum version and that SB3 is used in codebase
  2. Transpiler dependencies usage - requests verification that qiskit_ibm_transpiler and qiskit_ibm_ai_local_transpiler are actually used
  3. Comment consistency - notes that line 52 lacks an explanatory comment (unlike line 51)

These are reasonable verification concerns for a dependency addition. Since I cannot access the codebase to verify these claims, I'll rewrite the review comment to be more focused and action-oriented while preserving the verification requests:


Verify stable-baselines3 minimum version and confirm transpiler dependencies are actively used.

The PR adds stable-baselines3>=2.7.0 (line 40) and gates two transpiler packages to Python <3.13 (lines 51-52). Please confirm: (1) that 2.7.0 is the correct minimum version for stable-baselines3, (2) that both qiskit_ibm_transpiler and qiskit_ibm_ai_local_transpiler are actually imported/used in the codebase, and (3) whether line 52 should include an explanatory comment for consistency with line 51.

src/mqt/predictor/rl/predictorenv.py (2)

181-199: Observation space extension looks consistent with feature extraction.

Using get_rl_openqasm_gates() to build gate_spaces and wiring those into spaces matches the structure of create_feature_dict (per helper.py) and keeps the observation space in sync with the RL gate list. This change looks solid.


592-625: determine_valid_actions_for_state logic is clear and matches the intended policy.

The refactored branching on (only_nat_gates, mapped, self.layout) is much easier to reason about and aligns with the described behavior:

  • Non-native + unmapped → synthesis/optimization only.
  • Non-native + mapped → synthesis + mapping-preserving optimizations.
  • Native + mapped + layout → terminate or mapping-preserving/final optimization.
  • Native + mapped w/o layout → layout/mapping improvements.
  • Layout-only → routing.
  • Otherwise → optimization + layout + mapping.

This is a nice improvement in readability and should make future adjustments to the action policy straightforward.

Comment on lines +36 to +38
# TODO(denialhaag): Remove once pytket_qiskit is updated
# https://github.com/munich-quantum-toolkit/predictor/issues/471
"qiskit-ibm-runtime>=0.30.0,<0.42.0",
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

TODO comment references issue #471 for qiskit-ibm-runtime constraint.

The upper bound <0.42.0 on qiskit-ibm-runtime is intentional (with a TODO for issue #471). Ensure this constraint is tracked and revisited once the underlying issue is resolved to allow future versions.

If you'd like, I can help monitor or generate a reminder to revisit this constraint when issue #471 is addressed.

🤖 Prompt for AI Agents
In pyproject.toml around lines 36 to 38, the qiskit-ibm-runtime upper bound
(<0.42.0) is intentionally pinned with a TODO referencing issue #471; keep the
version constraint as-is but update the TODO to include the full issue URL and a
clear action (e.g., "remove upper bound when issue #471 is resolved"), and add a
tracking reminder (create a GitHub issue/board card or calendar reminder) so
this constraint is revisited and removed/relaxed once the referenced issue is
closed.

Comment on lines +261 to 264
[tool.uv]
override-dependencies = [
"networkx==2.8.5", # Required by `qiskit-ibm-transpiler`
]
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

uv override-dependencies syntax Python version constraints

💡 Result:

Short answer: override-dependencies entries use standard PEP 508 dependency specifiers (name, optional extras, version specifier, optional environment marker). To constrain by Python version put the marker after a semicolon and quote the version. Examples:

  • Force exact version for all Pythons:
    override-dependencies = ["werkzeug==2.3.0"]. [1]

  • Only override for older Pythons:
    override-dependencies = [
    "numpy<1.24; python_version < "3.10"",
    "numpy>=1.24; python_version >= "3.10"",
    ]. [1][2]

Notes:

  • Version specifiers (outside markers) follow PEP 440 and are unquoted; marker version literals (inside the marker) must be quoted strings. [2]
  • uv treats override-dependencies as absolute replacements during resolution and reads them from [tool.uv] in pyproject.toml at the workspace root. [1]

Sources:
[1] uv settings / override-dependencies docs.
[2] uv dependency specifiers / PEP 508 markers (packaging) docs.


🏁 Script executed:

# Check the pyproject.toml around lines 51-52 to see transpiler dependency format
cat -n pyproject.toml | sed -n '40,65p'

Repository: munich-quantum-toolkit/predictor

Length of output: 208


🌐 Web query:

networkx 2.8.5 Python 3.13 compatibility constraint qiskit-ibm-transpiler

💡 Result:

Short answer: NetworkX 2.8.5 is not compatible with Python 3.13 (it supports Python 3.8–3.10), so if qiskit-ibm-transpiler requires or installs networkx==2.8.5 you cannot use that combo on Python 3.13. [1][2][3]

Details & sources:

  • NetworkX 2.8.5 supports Python 3.8–3.10 (release notes). [1]
  • NetworkX newer 3.x releases add support for Python 3.11–3.13 (e.g. NetworkX 3.4+ support 3.13). If you need Python 3.13 install a NetworkX 3.x release. [3]
  • qiskit-ibm-transpiler release notes indicate NetworkX was added to its requirements in recent releases (so the transpiler can pull in networkx from your environment). Check which networkx version qiskit-ibm-transpiler requests in your installed package/pyproject to confirm whether it pins 2.8.x. [2]

Recommendation: either use Python 3.10 (to keep networkx 2.8.5), or upgrade NetworkX to a 3.x release that supports Python 3.13 and ensure qiskit-ibm-transpiler is compatible with that NetworkX version (check the transpiler’s dependency metadata or test in a venv). [2][3]

Sources:
[1] NetworkX 2.8.5 release notes (supported Python versions).
[2] qiskit-ibm-transpiler release notes (networkx added to requirements).
[3] NetworkX 3.4/3.x release notes (Python 3.13 support).


🌐 Web query:

qiskit-ibm-transpiler networkx version Python 3.13

💡 Result:

Short answer: Yes — qiskit-ibm-transpiler depends on networkx (but does not pin a restrictive networkx version), and networkx releases (3.4+) support Python 3.13, while recent Qiskit releases add official Python 3.13 support. So you can run qiskit-ibm-transpiler on Python 3.13 after installing a networkx release that supports 3.13 (e.g. networkx 3.4+). [1][2][3]

Sources

  • qiskit-ibm-transpiler (package + release notes showing networkx listed as a dependency). [1]
  • Qiskit release notes stating Python 3.13 support added. [2]
  • NetworkX release notes / install docs showing support for Python 3.13 (networkx 3.4+). [3]

References

  1. qiskit-ibm-transpiler package / PyPI & release notes.
  2. Qiskit SDK / release notes (Python 3.13 support).
  3. NetworkX release notes / install docs (supports Python 3.13).

🏁 Script executed:

# Try to fetch raw file from GitHub
curl -s "https://raw.githubusercontent.com/munich-quantum-toolkit/predictor/HEAD/pyproject.toml" | sed -n '40,280p' | cat -n | grep -A 20 -B 5 "networkx\|qiskit-ibm-transpiler" | head -100

Repository: munich-quantum-toolkit/predictor

Length of output: 208


Based on the web search results, I can now provide a conclusive verification:

The review comment's concern is valid and the suggested fix is necessary.

Key findings:

  1. uv DOES support Python version constraints via PEP 508 environment markers (syntax: "package==version; python_version < \"X.Y\"")
  2. NetworkX 2.8.5 supports ONLY Python 3.8–3.10 — it is incompatible with Python 3.13
  3. The current code lacks the version constraint, which means projects targeting Python 3.13 would fail when trying to use networkx==2.8.5

The correct implementation should be:

[tool.uv]
override-dependencies = [
  "networkx==2.8.5; python_version < \"3.13\"",  # Required by `qiskit-ibm-transpiler`
]

Add a Python version constraint to the networkx override dependency.

NetworkX 2.8.5 supports only Python 3.8–3.10 and is incompatible with Python 3.13. Without the constraint, users on Python 3.13 will encounter resolution failures. Update the override to: "networkx==2.8.5; python_version < \"3.13\"".

🤖 Prompt for AI Agents
In pyproject.toml around lines 261 to 264, the override for networkx pins 2.8.5
but lacks a Python-version constraint so it will be applied on Python 3.13
(where that release is incompatible); update the override entry to use a PEP 508
environment marker restricting it to Python versions older than 3.13 (i.e., add
`; python_version < "3.13"` to the networkx override) so the override only
applies for Python < 3.13.

Comment on lines 426 to 461
def _apply_qiskit_action(self, action: Action, action_index: int) -> QuantumCircuit:
if action.name == "QiskitO3" and isinstance(action, DeviceDependentAction):
passes = action.transpile_pass(
self.device.operation_names,
CouplingMap(self.device.build_coupling_map()) if self.layout else None,
pm_property_set: PropertySet | None = {}
if getattr(action, "stochastic", False): # Wrap stochastic action to optimize for the used figure of merit
altered_qc, pm_property_set = self.fom_aware_compile(
action,
self.device,
self.state,
max_iteration=self.max_iter,
)
pm = PassManager([DoWhileController(passes, do_while=action.do_while)])
else:
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
pm = PassManager(transpile_pass)

altered_qc = pm.run(self.state)
if action.name in ["QiskitO3", "Opt2qBlocks_preserve"] and isinstance(action, DeviceDependentAction):
passes = action.transpile_pass(
self.device.operation_names,
CouplingMap(self.device.build_coupling_map()) if self.layout else None,
)
if action.name == "QiskitO3":
pm = PassManager([DoWhileController(passes, do_while=action.do_while)])
else:
pm = PassManager(passes)
altered_qc = pm.run(self.state)
pm_property_set = dict(pm.property_set) if hasattr(pm, "property_set") else {}
else:
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
pm = PassManager(transpile_pass)
altered_qc = pm.run(self.state)
pm_property_set = dict(pm.property_set) if hasattr(pm, "property_set") else {}

if action_index in (
self.actions_layout_indices + self.actions_mapping_indices + self.actions_final_optimization_indices
):
altered_qc = self._handle_qiskit_layout_postprocessing(action, pm, altered_qc)

elif action_index in self.actions_routing_indices and self.layout:
self.layout.final_layout = pm.property_set["final_layout"]
altered_qc = self._handle_qiskit_layout_postprocessing(action, pm_property_set, altered_qc)
elif action_index in self.actions_routing_indices and self.layout and pm_property_set is not None:
self.layout.final_layout = pm_property_set["final_layout"]

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

I'll help you verify the review comment about the Qiskit action application in the predictor environment. Let me start by examining the file and understanding the context.
<function_calls>

cd /tmp && git clone --depth 1 https://github.com/cda-tum/mqt-predictor.git 2>&1 | head -20

</function_calls>
<function_calls>

cd /tmp/mqt-predictor && find . -name "predictorenv.py" -type f

</function_calls>
<function_calls>

cd /tmp/mqt-predictor && head -100 src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>
<function_calls>

cd /tmp/mqt-predictor && wc -l src/mqt/predictor/rl/predictorenv.py

</function_calls>
<function_calls>

cd /tmp/mqt-predictor && sed -n '1,50p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>
<function_calls>

cd /tmp/mqt-predictor && sed -n '420,470p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me check the imports and PropertySet definition:
<function_calls>

cd /tmp/mqt-predictor && grep -n "PropertySet|from qiskit|import.*PassManager" src/mqt/predictor/rl/predictorenv.py | head -30

</function_calls>

Let me check the _handle_qiskit_layout_postprocessing method:
<function_calls>

cd /tmp/mqt-predictor && rg -n "_handle_qiskit_layout_postprocessing" src/mqt/predictor/rl/predictorenv.py -A 15

</function_calls>

Let me also check the fom_aware_compile method:
<function_calls>

cd /tmp/mqt-predictor && rg -n "def fom_aware_compile" src/mqt/predictor/rl/predictorenv.py -A 20

</function_calls>

Let me get a broader view of the file structure and imports:
<function_calls>

cd /tmp/mqt-predictor && head -80 src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Let me search for all PropertySet references in the file:
<function_calls>

cd /tmp/mqt-predictor && rg -n "PropertySet" src/mqt/predictor/rl/predictorenv.py

</function_calls>

Let me check the actual line 426 area more carefully:
<function_calls>

cd /tmp/mqt-predictor && sed -n '426,461p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me check the _handle_qiskit_layout_postprocessing definition more carefully:
<function_calls>

cd /tmp/mqt-predictor && rg -n "def _handle_qiskit_layout_postprocessing" src/mqt/predictor/rl/predictorenv.py -A 20

</function_calls>

Let me check what the fom_aware_compile returns:
<function_calls>

cd /tmp/mqt-predictor && rg -n "def fom_aware_compile" src/mqt/predictor/rl/predictorenv.py -A 30 | head -50

</function_calls>

Let me find where fom_aware_compile is defined:
<function_calls>

cd /tmp/mqt-predictor && find . -name "*.py" -exec grep -l "def fom_aware_compile" {} ;

</function_calls>

Let me check the full imports section:
<function_calls>

cd /tmp/mqt-predictor && sed -n '1,100p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me check if there's a parent class that has fom_aware_compile:
<function_calls>

cd /tmp/mqt-predictor && rg -n "class.*PredictorEnv" src/mqt/predictor/rl/predictorenv.py | head -5

</function_calls>

Let me check the class definition:
<function_calls>

cd /tmp/mqt-predictor && sed -n '1,200p' src/mqt/predictor/rl/predictorenv.py | grep -A 5 "^class"

</function_calls>

Let me search for the fom_aware_compile in this file:
<function_calls>

cd /tmp/mqt-predictor && grep -n "fom_aware_compile" src/mqt/predictor/rl/predictorenv.py

</function_calls>

Let me check more broadly:
<function_calls>

cd /tmp/mqt-predictor && find . -name "*.py" -exec grep -l "fom_aware_compile" {} ;

</function_calls>

Let me look at the structure of the entire method to understand the context better:
<function_calls>

cd /tmp/mqt-predictor && sed -n '410,500p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Good! Now let me search for the pm.property_set pattern to understand how it's being accessed:
<function_calls>

cd /tmp/mqt-predictor && rg -n "pm_property_set|property_set" src/mqt/predictor/rl/predictorenv.py -B 2 -A 2

</function_calls>

Let me check the entire method more carefully:
<function_calls>

cd /tmp/mqt-predictor && sed -n '424,465p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me search for the _handle_qiskit_layout_postprocessing method to check its signature:
<function_calls>

cd /tmp/mqt-predictor && grep -n "_handle_qiskit_layout_postprocessing" src/mqt/predictor/rl/predictorenv.py

</function_calls>

Let me check if there are any other files that might contain this method:
<function_calls>

cd /tmp/mqt-predictor && find . -name "*.py" -exec grep -l "_handle_qiskit_layout_postprocessing" {} ;

</function_calls>

Let me check if the method is in the same file:
<function_calls>

cd /tmp/mqt-predictor && wc -l src/mqt/predictor/rl/predictorenv.py && grep -n "def _handle_qiskit" src/mqt/predictor/rl/predictorenv.py

</function_calls>

Let me check the entire file to locate all method definitions:
<function_calls>

cd /tmp/mqt-predictor && grep -n "^ def " src/mqt/predictor/rl/predictorenv.py | head -20

</function_calls>

Let me look for the method around line 500:
<function_calls>

cd /tmp/mqt-predictor && sed -n '460,550p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me check if there's a type hint for pm_property_set in that method:
<function_calls>

cd /tmp/mqt-predictor && sed -n '462,520p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Let me read more to see the method signature:
<function_calls>

cd /tmp/mqt-predictor && sed -n '462,530p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Let me check Qiskit PassManager documentation and PropertySet:
<function_calls>

cd /tmp/mqt-predictor && python3 << 'EOF'

Check what type Qiskit PassManager.property_set returns

try:
from qiskit.transpiler import PassManager, PropertySet
import inspect

# Check PropertySet
print("PropertySet definition:")
print(f"  Type: {type(PropertySet)}")
print(f"  Module: {PropertySet.__module__}")

# Check PassManager
pm = PassManager([])
print("\nPassManager.property_set:")
print(f"  Type: {type(pm.property_set)}")
print(f"  Value: {pm.property_set}")
print(f"  Has hasattr: {hasattr(pm, 'property_set')}")

except ImportError as e:
print(f"Import error: {e}")
except Exception as e:
print(f"Error: {e}")
EOF


</function_calls>

Let me check the pip dependencies:
<function_calls>

cd /tmp/mqt-predictor && find . -name "requirements*.txt" -o -name "setup.py" -o -name "pyproject.toml" | head -5

</function_calls>

Let me check the pyproject.toml:
<function_calls>

cd /tmp/mqt-predictor && cat pyproject.toml | grep -A 20 "[project]" | head -40

</function_calls>

Let me check what Qiskit version is available and test the actual behavior:
<function_calls>

python3 << 'EOF'
try:
from qiskit.transpiler import PropertySet
print(f"PropertySet: {PropertySet}")
print(f"PropertySet.bases: {PropertySet.bases}")

# Try to understand what PropertySet is
import inspect
print(f"\nPropertySet source file: {inspect.getfile(PropertySet)}")

# Create instance
ps = PropertySet()
print(f"\nPropertySet instance type: {type(ps)}")
print(f"PropertySet is dict-like: {isinstance(ps, dict)}")

except ImportError as e:
print(f"Cannot import: {e}")
except Exception as e:
print(f"Error: {e}")
EOF


</function_calls>

Let me search more thoroughly for the _handle_qiskit_layout_postprocessing method:
<function_calls>

cd /tmp/mqt-predictor && find . -type f -name "*.py" | xargs grep -l "_handle_qiskit_layout_postprocessing" 2>/dev/null

</function_calls>

It seems that method might not exist yet or it's named differently. Let me check the entire predictorenv.py file for all methods:
<function_calls>

cd /tmp/mqt-predictor && grep "^ def " src/mqt/predictor/rl/predictorenv.py

</function_calls>

Let me check what actions_layout_indices and related are:
<function_calls>

cd /tmp/mqt-predictor && grep -n "actions_layout_indices|actions_mapping_indices|actions_final_optimization_indices|actions_routing_indices" src/mqt/predictor/rl/predictorenv.py

</function_calls>

Let me look more closely at the actual logic around line 445-460:
<function_calls>

cd /tmp/mqt-predictor && sed -n '445,465p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Let me verify if there's a problem with accessing "final_layout" without checking. Let me search for PassManager property_set behavior:
<function_calls>

python3 << 'EOF'

Test Qiskit PassManager behavior

import sys
try:
from qiskit.transpiler import PassManager, CouplingMap
from qiskit.circuit import QuantumCircuit

# Create a simple circuit
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)

# Create an empty PassManager
pm = PassManager([])

# Run it and check property_set
result = pm.run(qc)
print(f"property_set type: {type(pm.property_set)}")
print(f"property_set content: {pm.property_set}")
print(f"'final_layout' in property_set: {'final_layout' in pm.property_set}")
print(f"property_set.get('final_layout'): {pm.property_set.get('final_layout')}")

except Exception as e:
print(f"Error: {type(e).name}: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
EOF


</function_calls>

Good! Now let me check what the review comment mentions about the actual diff fix:
<function_calls>

cd /tmp/mqt-predictor && sed -n '424,465p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me verify the exact current state of the code in the PR:
<function_calls>

cd /tmp/mqt-predictor && python3 << 'EOF'
import sys

Read the method

with open('src/mqt/predictor/rl/predictorenv.py', 'r') as f:
lines = f.readlines()

Print lines 424-461 (0-indexed: 423-460)

for i in range(423, 461):
print(f"{i+1:3d}: {lines[i]}", end='')
EOF


</function_calls>

Perfect! Now let me look at lines around 451 to see the layout postprocessing call:
<function_calls>

cd /tmp/mqt-predictor && sed -n '450,465p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me check if the _handle_qiskit_layout_postprocessing method exists anywhere in the codebase or if it's a suggested addition:
<function_calls>

cd /tmp/mqt-predictor && python3 << 'EOF'
import subprocess
result = subprocess.run(['git', 'log', '--all', '--oneline', '-n', '20'],
capture_output=True, text=True, cwd='.')
print("Recent commits:")
print(result.stdout[:1000])
EOF


</function_calls>

Let me check the current git status:
<function_calls>

cd /tmp/mqt-predictor && git status 2>&1 | head -20

</function_calls>

Let me look at the actual type annotation on line 427:
<function_calls>

cd /tmp/mqt-predictor && python3 << 'EOF'

Check exact line 427

with open('src/mqt/predictor/rl/predictorenv.py', 'r') as f:
lines = f.readlines()

Line 427 (0-indexed: 426)

line_427 = lines[426]
print(f"Line 427: {repr(line_427)}")
print(f"Contains 'PropertySet': {'PropertySet' in line_427}")
print(f"Contains 'None': {'None' in line_427}")
print(f"Actual init value: {line_427.split('=')[1].strip() if '=' in line_427 else 'N/A'}")
EOF


</function_calls>

Let me check if PropertySet is imported:
<function_calls>

cd /tmp/mqt-predictor && head -50 src/mqt/predictor/rl/predictorenv.py | grep -n "PropertySet|from|import"

</function_calls>

Let me get the full import section:
<function_calls>

cd /tmp/mqt-predictor && sed -n '1,40p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me analyze the key concerns from the review:
<function_calls>

cd /tmp/mqt-predictor && python3 << 'EOF'

Verify the review's concerns:

1. Type annotation vs actual assignment

2. KeyError risk

with open('src/mqt/predictor/rl/predictorenv.py', 'r') as f:
content = f.read()
lines = content.split('\n')

print("=== Issue 1: Type annotation check ===")
print(f"Line 427: {lines[426]}")
print(f" - Has 'PropertySet | None': {'PropertySet | None' in lines[426]}")
print(f" - Has '{} assignment: {'{' in lines[426]}")

print("\n=== Actual assignments of pm_property_set ===")
for i, line in enumerate(lines[423:465], start=424):
if 'pm_property_set' in line and '=' in line:
print(f"Line {i}: {line.strip()}")

print("\n=== Issue 2: final_layout access ===")
for i, line in enumerate(lines[423:465], start=424):
if 'final_layout' in line:
print(f"Line {i}: {line.strip()}")
EOF


</function_calls>

Let me check the full imports and see if PropertySet is indeed not imported:
<function_calls>

cd /tmp/mqt-predictor && sed -n '1,60p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Good! Now let me verify the fom_aware_compile return value:
<function_calls>

cd /tmp/mqt-predictor && grep -A 30 "def fom_aware_compile" src/mqt/predictor/rl/predictorenv.py | head -35

</function_calls>

Let me search in all Python files for fom_aware_compile:
<function_calls>

cd /tmp/mqt-predictor && find . -name "*.py" -type f | xargs grep -l "def fom_aware_compile" 2>/dev/null

</function_calls>

Let me check if there's a parent class:
<function_calls>

cd /tmp/mqt-predictor && grep -n "^class.*PredictorEnv" src/mqt/predictor/rl/predictorenv.py

</function_calls>

Let me see if the method is inherited from a base class by checking imports:
<function_calls>

cd /tmp/mqt-predictor && sed -n '40,80p' src/mqt/predictor/rl/predictorenv.py | grep -A 5 "^class"

</function_calls>

Let me look for PredictorGym:
<function_calls>

cd /tmp/mqt-predictor && find . -name "*.py" -type f | xargs grep -l "class PredictorGym" 2>/dev/null

</function_calls>

Let me check what's in the base classes:
<function_calls>

cd /tmp/mqt-predictor && grep -B 5 "def fom_aware_compile" src/mqt/predictor/gym.py 2>/dev/null | head -20

</function_calls>

Let me find the gym.py file:
<function_calls>

cd /tmp/mqt-predictor && find . -name "gym.py" -type f

</function_calls>

Let me check the RL directory structure:
<function_calls>

cd /tmp/mqt-predictor && ls -la src/mqt/predictor/rl/

</function_calls>

Let me check if fom_aware_compile is called elsewhere:
<function_calls>

cd /tmp/mqt-predictor && grep -r "fom_aware_compile" . --include="*.py" 2>/dev/null | head -10

</function_calls>

Let me check the parent directory's init file to understand the class hierarchy:
<function_calls>

cd /tmp/mqt-predictor && cat src/mqt/predictor/rl/init.py 2>/dev/null || echo "No init.py"

</function_calls>

Let me examine the actual line where fom_aware_compile is called to understand its context:
<function_calls>

cd /tmp/mqt-predictor && sed -n '427,440p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Good! I can see line 432 uses unpacking from fom_aware_compile. Now let me understand the issue with the routing indices check. Let me trace through the logic:
<function_calls>

cd /tmp/mqt-predictor && sed -n '454,462p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Now let me verify if there's actually a scenario where final_layout might not be set. Let me search for how actions_routing_indices is populated:
<function_calls>

cd /tmp/mqt-predictor && rg -n "actions_routing_indices\s*=" src/mqt/predictor/rl/predictorenv.py -B 5 -A 5

</function_calls>

Let me search for where these indices are set:
<function_calls>

cd /tmp/mqt-predictor && grep -n "actions_routing_indices|actions_layout_indices|actions_mapping" src/mqt/predictor/rl/predictorenv.py

</function_calls>

Let me verify the type of self.layout to understand the context better:
<function_calls>

cd /tmp/mqt-predictor && grep -n "self.layout\s*=" src/mqt/predictor/rl/predictorenv.py | head -5

</function_calls>

Let me check if the condition at line 457 (and self.layout) checks for None:
<function_calls>

cd /tmp/mqt-predictor && sed -n '455,462p' src/mqt/predictor/rl/predictorenv.py | cat -n

</function_calls>

Perfect! Now I have all the information. Let me verify the core concerns from the review:

  1. Type annotation mismatch: Line 427 says PropertySet | None but assignments are dict or dict() converted from PropertySet
  2. KeyError risk: Line 460 accesses pm_property_set["final_layout"] without checking if the key exists
  3. Initial value mismatch: Line 427 initializes to {} but type says | None

The review suggests:

  • Changing initial value to None
  • Changing type to dict[str, Any] | None
  • Adding a guard for the final_layout key access

All these concerns appear to be valid. The code could indeed raise a KeyError if routing passes don't populate final_layout, and the type annotation doesn't match the actual usage.

Now let me rewrite the review comment:


Fix type inconsistency and add bounds check for pm_property_set["final_layout"] access.

The code has two issues:

  1. Line 427 declares pm_property_set: PropertySet | None = {}, but:

    • PropertySet is not imported
    • Actual values assigned are dict objects (from dict(pm.property_set))
    • Initial value {} contradicts the | None annotation
  2. Line 460 accesses pm_property_set["final_layout"] unconditionally after checking pm_property_set is not None, but routing passes may not populate this key, raising KeyError.

Fix by:

  • Changing type to dict[str, Any] | None
  • Initializing to None (consistency with the type union)
  • Guarding the final_layout access with a membership check
-    def _apply_qiskit_action(self, action: Action, action_index: int) -> QuantumCircuit:
-        pm_property_set: PropertySet | None = {}
+    def _apply_qiskit_action(self, action: Action, action_index: int) -> QuantumCircuit:
+        pm_property_set: dict[str, Any] | None = None
@@
-                altered_qc = pm.run(self.state)
-                pm_property_set = dict(pm.property_set) if hasattr(pm, "property_set") else {}
+                altered_qc = pm.run(self.state)
+                pm_property_set = dict(pm.property_set) if hasattr(pm, "property_set") else None
@@
-        elif action_index in self.actions_routing_indices and self.layout and pm_property_set is not None:
-            self.layout.final_layout = pm_property_set["final_layout"]
+        elif (
+            action_index in self.actions_routing_indices
+            and self.layout is not None
+            and pm_property_set is not None
+            and "final_layout" in pm_property_set
+        ):
+            self.layout.final_layout = pm_property_set["final_layout"]

Also update _handle_qiskit_layout_postprocessing's pm_property_set parameter type to dict[str, Any] | None for consistency.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _apply_qiskit_action(self, action: Action, action_index: int) -> QuantumCircuit:
if action.name == "QiskitO3" and isinstance(action, DeviceDependentAction):
passes = action.transpile_pass(
self.device.operation_names,
CouplingMap(self.device.build_coupling_map()) if self.layout else None,
pm_property_set: PropertySet | None = {}
if getattr(action, "stochastic", False): # Wrap stochastic action to optimize for the used figure of merit
altered_qc, pm_property_set = self.fom_aware_compile(
action,
self.device,
self.state,
max_iteration=self.max_iter,
)
pm = PassManager([DoWhileController(passes, do_while=action.do_while)])
else:
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
pm = PassManager(transpile_pass)
altered_qc = pm.run(self.state)
if action.name in ["QiskitO3", "Opt2qBlocks_preserve"] and isinstance(action, DeviceDependentAction):
passes = action.transpile_pass(
self.device.operation_names,
CouplingMap(self.device.build_coupling_map()) if self.layout else None,
)
if action.name == "QiskitO3":
pm = PassManager([DoWhileController(passes, do_while=action.do_while)])
else:
pm = PassManager(passes)
altered_qc = pm.run(self.state)
pm_property_set = dict(pm.property_set) if hasattr(pm, "property_set") else {}
else:
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
pm = PassManager(transpile_pass)
altered_qc = pm.run(self.state)
pm_property_set = dict(pm.property_set) if hasattr(pm, "property_set") else {}
if action_index in (
self.actions_layout_indices + self.actions_mapping_indices + self.actions_final_optimization_indices
):
altered_qc = self._handle_qiskit_layout_postprocessing(action, pm, altered_qc)
elif action_index in self.actions_routing_indices and self.layout:
self.layout.final_layout = pm.property_set["final_layout"]
altered_qc = self._handle_qiskit_layout_postprocessing(action, pm_property_set, altered_qc)
elif action_index in self.actions_routing_indices and self.layout and pm_property_set is not None:
self.layout.final_layout = pm_property_set["final_layout"]
def _apply_qiskit_action(self, action: Action, action_index: int) -> QuantumCircuit:
pm_property_set: dict[str, Any] | None = None
if getattr(action, "stochastic", False): # Wrap stochastic action to optimize for the used figure of merit
altered_qc, pm_property_set = self.fom_aware_compile(
action,
self.device,
self.state,
max_iteration=self.max_iter,
)
else:
if action.name in ["QiskitO3", "Opt2qBlocks_preserve"] and isinstance(action, DeviceDependentAction):
passes = action.transpile_pass(
self.device.operation_names,
CouplingMap(self.device.build_coupling_map()) if self.layout else None,
)
if action.name == "QiskitO3":
pm = PassManager([DoWhileController(passes, do_while=action.do_while)])
else:
pm = PassManager(passes)
altered_qc = pm.run(self.state)
pm_property_set = dict(pm.property_set) if hasattr(pm, "property_set") else None
else:
transpile_pass = (
action.transpile_pass(self.device) if callable(action.transpile_pass) else action.transpile_pass
)
pm = PassManager(transpile_pass)
altered_qc = pm.run(self.state)
pm_property_set = dict(pm.property_set) if hasattr(pm, "property_set") else None
if action_index in (
self.actions_layout_indices + self.actions_mapping_indices + self.actions_final_optimization_indices
):
altered_qc = self._handle_qiskit_layout_postprocessing(action, pm_property_set, altered_qc)
elif (
action_index in self.actions_routing_indices
and self.layout is not None
and pm_property_set is not None
and "final_layout" in pm_property_set
):
self.layout.final_layout = pm_property_set["final_layout"]

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants