Repository: Qiskit/qiskit — pull request #15143 (python)
Title: Fix incorrect mapping of ControlFlow block qargs in `BasisTranslator`

<!--\r\n⚠️  If you do not respect this template, your pull request will be closed.\r\n⚠️  Your pull request title should be short detailed and understandable for all.\r\n⚠️  Also, please add a release note file using reno if the change needs to be documented in the release notes.\r\n⚠️  If your pull request fixes an open issue, please link to the issue. Use "Fixes #XXXX" if this PR *fully* closes the issue XXXX.  \r\n☢️  If you used an AI tool to code this PR, add "AI tool used: <Name and version of the tool>". For example, "AI tool used: Microsoft Copilot Chat with GPT-5". Failing to disclose the use of AI tools may result in the PR being closed without further review.  \r\n\r\n\r\n- [x] I have added the tests to cover my changes.\r\n- [x] I have updated the documentation accordingly.\r\n- [x] I have read the CONTRIBUTING document.\r\n-->\r\n\r\n### Summary\r\nWhen extracting the basis of the target and applying the translations onto the dag, the `BasisTranslator` would not use the absolute indices of the operating qubits when a control flow operation block was processed during recursion. The following commit adds a mapping to the following `BasisTranslator` methods:\r\n- `extract_target_basis_circuit` which only gets called during a recursion step.\r\n- `apply_translation` in which it is always `None` unless a recursion step happens.\r\n\r\nThe provided mapping will correctly direct local block qubit indices into the absolute ones stored by the original circuit from which the operation comes from.\r\n\r\n### Details and comments\r\nThis should help fix #13728.\r\n\r\n\r\n

Resolved issues:
title: Transpiler pass does not update qubits registers within body of control flow operations
body: ### Environment\n\n- **Qiskit version**: 1.3.2\n- **Python version**: 3.10\n- **Operating system**: Mac OS Sequoia\n\n### What is happening?\n\nThe issue arises due to the way Qiskit handles control flow structures like for_loop when applying transpilation passes. Specifically, the for_loop construct internally stores a new QuantumCircuit object that does not retain the physical qubit mapping intended for the calibrated custom gate (as defined in the backend target object).\n\nWhen the transpilation pipeline reaches the HighLevelSynthesis pass, the method _definitely_skip_node should return True if the gate already has a valid calibration. However, it does not because the physical qubits referenced in the circuit structure do not align with those recorded in the initial_layout during pass execution.\n\n### How can we reproduce the issue?\n\n```python\nfrom qiskit.circuit import QuantumCircuit, ParameterVector, Gate, Parameter\nfrom qiskit import pulse, schedule, transpile\nfrom qiskit.providers.fake_provider import GenericBackendV2\nfrom qiskit.transpiler import InstructionProperties, generate_preset_pass_manager, CouplingMap\n\nparams = [Parameter(f'a_{i}') for i in range(2)]\nqc = QuantumCircuit(2)\necr_cal = Gate(name='ecr_cal', num_qubits=2, params=params)\nqc_loop = QuantumCircuit(2)\nqc.append(ecr_cal, [0, 1])\nwith qc_loop.for_loop([0, 3]) as i:\n    qc_loop.compose(qc, inplace=True)\n\nbackend = GenericBackendV2(num_qubits=4,\n                           basis_gates=["x", "sx", "ecr", "rz", "measure", "reset"],\n                           coupling_map=CouplingMap.from_full(4),\n                           calibrate_instructions=True,\n                           control_flow=True)\n\nwith pulse.build(backend, name="custom_sched") as custom_sched:\n    pulse.play(pulse.Constant(100, params[0]), pulse.DriveChannel(3))\n    pulse.play(pulse.Constant(100, params[1]), pulse.DriveChannel(4))\n\nbackend.target.add_instruction(ecr_cal, properties={(3,4): InstructionProperties(calibration=custom_sched)})\npm = generate_preset_pass_manager(optimization_level=1,\n                                  backend=backend,\n                                  initial_layout=(3,4))\n\ntranspiled_circ = pm.run(qc_loop)\ntranspiled_circ.draw('mpl')`\n```\n\n### What should happen?\n\nThe transpilation process should correctly recognize that the ecr_cal gate, mapped to physical qubits (3,4), already has a calibration, and avoid unnecessary synthesis steps.\nInstead of skipping synthesis, the transpiler does not identify the calibration, likely due to the for_loop internally storing logical qubit indices instead of physical qubits.\n\n### Any suggestions?\n\nA potential solution could involve ensuring that physical qubit mappings are correctly maintained within control flow constructs, or updating the handling of control flow operations within transpilation passes to correctly propagate the intended qubit mappings.\n

Hints:

Fix patch:
diff --git a/crates/transpiler/src/passes/basis_translator/mod.rs b/crates/transpiler/src/passes/basis_translator/mod.rs\nindex 70228ca6c62b..a8b9b0410da8 100644\n--- a/crates/transpiler/src/passes/basis_translator/mod.rs\n+++ b/crates/transpiler/src/passes/basis_translator/mod.rs\n@@ -173,7 +173,7 @@ pub fn run_basis_translator(\n             }\n         } else {\n             expanded_target = expanded_target\n-                .union(&qargs_with_non_global_operation[&Qargs::from_iter(qargs.iter().copied())])\n+                .union(&qargs_with_non_global_operation[&QargsRef::Concrete(qargs)])\n                 .cloned()\n                 .collect();\n         }\n@@ -213,6 +213,7 @@ pub fn run_basis_translator(\n         &extra_inst_map,\n         min_qubits,\n         &qargs_with_non_global_operation,\n+        None,\n     )?;\n     Ok(Some(out_dag))\n }\n@@ -320,12 +321,22 @@ fn extract_basis_target(\n         }\n         if node_obj.op.control_flow() {\n             for block in node_obj.op.blocks() {\n+                // Generate a mapping with the absolute indices and the local ones.\n+                let qarg_mapping: HashMap<Qubit, Qubit> = dag\n+                    .qargs_interner()\n+                    .get(node_obj.qubits)\n+                    .iter()\n+                    .copied()\n+                    .zip((0..(block.num_qubits() as u32)).map(Qubit))\n+                    .map(|(k, v)| (v, k))\n+                    .collect();\n                 extract_basis_target_circ(\n                     &block,\n                     source_basis,\n                     qargs_local_source_basis,\n                     min_qubits,\n                     qargs_with_non_global_operation,\n+                    &qarg_mapping,\n                 );\n             }\n         }\n@@ -339,6 +350,7 @@ fn extract_basis_target_circ(\n     qargs_local_source_basis: &mut AhashIndexMap<PhysicalQargs, AhashIndexSet<GateIdentifier>>,\n     min_qubits: usize,\n     qargs_with_non_global_operation: &AhashIndexMap<Qargs, AhashIndexSet<&str>>,\n+    qarg_mapping: &HashMap<Qubit, Qubit>,\n ) {\n     for node_obj in circuit.iter() {\n         let qargs = circuit.get_qargs(node_obj.qubits);\n@@ -354,7 +366,12 @@ fn extract_basis_target_circ(\n         // single qubit operation for (1,) as valid. This pattern also holds\n         // true for > 2q ops too (so for 4q operations we need to check for 3q, 2q,\n         // and 1q operations in the same manner)\n-        let physical_qargs: PhysicalQargs = qargs.iter().map(|x| PhysicalQubit(x.0)).collect();\n+        // Make sure to also map to the physical qubits of the target rather than\n+        // the virtual locations within the circuit or control-flow operation block.\n+        let physical_qargs: PhysicalQargs = qargs\n+            .iter()\n+            .map(|x| PhysicalQubit(qarg_mapping[x].0))\n+            .collect();\n         let physical_qargs_as_set: AhashIndexSet<PhysicalQubit> =\n             AhashIndexSet::from_iter(physical_qargs.iter().copied());\n         let physical_qargs: Qargs = physical_qargs.into();\n@@ -389,12 +406,22 @@ fn extract_basis_target_circ(\n         }\n         if node_obj.op.control_flow() {\n             for block in node_obj.op.blocks() {\n+                // Generate a mapping with the absolute qubit indices and the current local ones.\n+                let qarg_mapping: HashMap<Qubit, Qubit> = circuit\n+                    .qargs_interner()\n+                    .get(node_obj.qubits)\n+                    .iter()\n+                    .copied()\n+                    .zip((0..(block.num_qubits() as u32)).map(Qubit))\n+                    .map(|(k, v)| (v, qarg_mapping[&k]))\n+                    .collect();\n                 extract_basis_target_circ(\n                     &block,\n                     source_basis,\n                     qargs_local_source_basis,\n                     min_qubits,\n                     qargs_with_non_global_operation,\n+                    &qarg_mapping,\n                 );\n             }\n         }\n@@ -408,6 +435,7 @@ fn apply_translation(\n     extra_inst_map: &ExtraInstructionMap,\n     min_qubits: usize,\n     qargs_with_non_global_operation: &AhashIndexMap<Qargs, AhashIndexSet<&str>>,\n+    qarg_mapping: Option<&HashMap<Qubit, Qubit>>,\n ) -> Result<(DAGCircuit, bool), BasisTranslatorError> {\n     let mut is_updated = false;\n     let out_dag = dag.copy_empty_like(VarsMode::Alike).map_err(|_| {\n@@ -436,6 +464,13 @@ fn apply_translation(\n                     for block in node_obj.op.blocks() {\n                         let dag_block: DAGCircuit = DAGCircuit::from_circuit_data(&block, true, None, None, None, None)?;\n                         let updated_dag: DAGCircuit;\n+                        // Generate a mapping between the absolute and local qubit indices\n+                        // which will be used to correctly map the operation onto the dag.\n+                        let qarg_mapping: HashMap<Qubit, Qubit> = if let Some(qarg_mapping) = qarg_mapping {\n+                            dag.qargs_interner().get(node_obj.qubits).iter().zip((0..(block.num_qubits() as u32)).map(Qubit)).map(|(k, v)| (v, qarg_mapping[k])).collect()\n+                        } else {\n+                            dag.qargs_interner().get(node_obj.qubits).iter().zip((0..(block.num_qubits() as u32)).map(Qubit)).map(|(k, v)| (v, *k)).collect()\n+                        };\n                         (updated_dag, is_updated) = apply_translation(\n                             &dag_block,\n                             target_basis,\n@@ -443,6 +478,7 @@ fn apply_translation(\n                             extra_inst_map,\n                             min_qubits,\n                             qargs_with_non_global_operation,\n+                            Some(&qarg_mapping)\n                         ).map_err(PyErr::from)?;\n                         let flow_circ_block = if is_updated {\n                             QUANTUM_CIRCUIT.get_bound(py).call_method1(intern!(py, "_from_circuit_data"), (dag_to_circuit(&updated_dag, true)?,))?\n@@ -496,7 +532,16 @@ fn apply_translation(\n             }\n             continue;\n         }\n-        let node_qarg_as_physical: Qargs = node_qarg.iter().map(|x| PhysicalQubit(x.0)).collect();\n+        // Map to the absolute indices when provided to avoid mistakenly tracking\n+        // the operation as global.\n+        let node_qarg_as_physical: Qargs = if let Some(qarg_mapping) = qarg_mapping {\n+            node_qarg\n+                .iter()\n+                .map(|x| PhysicalQubit(qarg_mapping[x].0))\n+                .collect()\n+        } else {\n+            node_qarg.iter().map(|x| PhysicalQubit(x.0)).collect()\n+        };\n         if qargs_with_non_global_operation.contains_key(&node_qarg_as_physical)\n             && qargs_with_non_global_operation[&node_qarg_as_physical].contains(node_obj.op.name())\n         {\n@@ -518,7 +563,15 @@ fn apply_translation(\n             continue;\n         }\n \n-        let unique_qargs: PhysicalQargs = qubit_set.iter().map(|x| PhysicalQubit(x.0)).collect();\n+        // Map the unique qargs with the absolute indices as well\n+        let unique_qargs: PhysicalQargs = if let Some(qarg_mapping) = qarg_mapping {\n+            qubit_set\n+                .iter()\n+                .map(|x| PhysicalQubit(qarg_mapping[x].0))\n+                .collect()\n+        } else {\n+            qubit_set.iter().map(|x| PhysicalQubit(x.0)).collect()\n+        };\n         if extra_inst_map.contains_key(&unique_qargs) {\n             replace_node(\n                 &mut out_dag_builder,\ndiff --git a/releasenotes/notes/fix-basis-control-custom-gate-b62fd53c73776085.yaml b/releasenotes/notes/fix-basis-control-custom-gate-b62fd53c73776085.yaml\nnew file mode 100644\nindex 000000000000..40b719d7cb78\n--- /dev/null\n+++ b/releasenotes/notes/fix-basis-control-custom-gate-b62fd53c73776085.yaml\n@@ -0,0 +1,6 @@\n+---\n+fixes:\n+  - |\n+    Fixed incorrect behavior in the :class:`.BasisTranslator` pass where a multi-qubit\n+    gate within a :class:`.ControlFlowOp` block would track with its local qubit indices\n+    instead of using the absolute indices from the source circuit.\n\\ No newline at end of file\n

Test patch:
diff --git a/test/python/transpiler/test_basis_translator.py b/test/python/transpiler/test_basis_translator.py\nindex 751073deb28c..ba002b6bfa2e 100644\n--- a/test/python/transpiler/test_basis_translator.py\n+++ b/test/python/transpiler/test_basis_translator.py\n@@ -87,6 +87,13 @@ def __init__(self, name="2q0p"):\n         super().__init__(name, 2, [])\n \n \n+class TwoQubitOneParamGate(Gate):\n+    """Mock two qubit one param gate."""\n+\n+    def __init__(self, theta, name="2q1p"):\n+        super().__init__(name, 2, [theta])\n+\n+\n class VariadicZeroParamGate(Gate):\n     """Mock variadic zero param gate."""\n \n@@ -459,6 +466,32 @@ def test_nested_loop(self):\n         dag_expected = circuit_to_dag(expected)\n         self.assertEqual(dag_translated, dag_expected)\n \n+    def test_loop_custom_gate_local_indices(self):\n+        """Test with a custom gate inside a loop operation with different local qubit indices"""\n+\n+        # Create 3 qubit backend\n+        backend = GenericBackendV2(num_qubits=3, control_flow=True)\n+\n+        # Add only this pair as native to the Target.\n+        pair = (0, 2)\n+        alpha = Parameter("alpha")\n+        gate = TwoQubitOneParamGate(alpha)\n+\n+        # Add custom gate to be supported by the Target.\n+        backend.target.add_instruction(gate, {pair: None})  # properties omitted for brevity\n+\n+        # Make the tracking register\n+        qubits = QuantumRegister(3, "data")\n+\n+        # Create circuit with loop\n+        circ = QuantumCircuit(qubits)\n+        with circ.for_loop(range(2)):\n+            circ.append(gate, [qubits[0], qubits[2]])\n+\n+        # Run the basis translator\n+        result = BasisTranslator(std_eq_lib, [], backend.target)(circ)\n+        self.assertEqual(circ, result)\n+\n     def test_different_bits(self):\n         """Test that the basis translator correctly works when the inner blocks of control-flow\n         operations are not over the same bits as the outer blocks."""\n