From 582c2fe2ba0f65bb0f61235d41573d5ead7ff23f Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Wed, 22 Jul 2026 11:52:45 -0700 Subject: [PATCH 1/3] fix: remap shared operand nodes only once in remove_idle_qubits Signed-off-by: Sai Asish Y --- src/pyqasm/modules/base.py | 11 +++++++++-- tests/qasm3/test_transformations.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index 3b21330a..eeabeec9 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -377,14 +377,21 @@ def _remap_qubits(self, reg_name: str, size: int, idle_indices: list[int]): del self._qubit_depths[(reg_name, idx)] # update the operations that use the qubits + # gate decompositions can reuse the same operand node across multiple statements, + # so track visited nodes to avoid remapping a shared node more than once + visited_node_ids = set() for operation in self._unrolled_ast.statements: if isinstance(operation, QUANTUM_STATEMENTS): bit_list = Qasm3Analyzer.get_op_bit_list(operation) for bit in bit_list: assert isinstance(bit, qasm3_ast.IndexedIdentifier) if bit.name.name == reg_name: - old_idx = bit.indices[0][0].value # type: ignore[union-attr,index] - bit.indices[0][0].value = idx_map[old_idx] # type: ignore[union-attr,index] + index_node = bit.indices[0][0] # type: ignore[index] + if id(index_node) in visited_node_ids: + continue + visited_node_ids.add(id(index_node)) + old_idx = index_node.value # type: ignore[union-attr] + index_node.value = idx_map[old_idx] # type: ignore[union-attr] def _get_idle_qubit_indices(self) -> dict[str, list[int]]: """Get the indices of the idle qubits in the module diff --git a/tests/qasm3/test_transformations.py b/tests/qasm3/test_transformations.py index a8c0eabe..4235b7c9 100644 --- a/tests/qasm3/test_transformations.py +++ b/tests/qasm3/test_transformations.py @@ -45,6 +45,25 @@ def test_remove_idle_qubits_qasm3_small(): check_unrolled_qasm(dumps(module), expected_qasm3_str) +def test_remove_idle_qubits_qasm3_shared_operand_nodes(): + """Test remove_idle_qubits when a gate decomposition reuses operand nodes""" + qasm3_str = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[3] q; + crz(0.5) q[1], q[2]; + """ + module = loads(qasm3_str) + module.unroll() + assert module.num_qubits == 3 + module.remove_idle_qubits() + assert module.num_qubits == 2 + + unrolled_qasm = dumps(module) + assert "q[2]" not in unrolled_qasm + assert "cx q[0], q[1];" in unrolled_qasm + + def test_remove_idle_qubits_qasm3(): """Test conversion of qasm3 to compressed contiguous qasm3""" qasm3_str = """ From 26ac8cc178cf4f44702b3beed79675912573064d Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Wed, 22 Jul 2026 11:53:22 -0700 Subject: [PATCH 2/3] add changelog entry Signed-off-by: Sai Asish Y --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dde4024..831631b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Types of changes: ### Removed ### Fixed +- Fixed `remove_idle_qubits()` raising `KeyError` when the unrolled AST contains operand nodes shared across multiple statements (e.g. the `crz` decomposition) and an idle lower-indexed qubit shifts the register indices. `_remap_qubits` now remaps each operand node exactly once instead of once per statement that references it. ([#332](https://github.com/qBraid/pyqasm/pull/332)) - Fixed `reset` on a physical qubit rewriting the operand to the internal pulse register, e.g. `reset $2;` unrolled to `reset __PYQASM_QUBITS__[2];`. That names a register the program never declares, so the unrolled output did not round-trip through `dumps()`/`loads()`, and the qubit was never registered (a program whose only operation was `reset $3;` reported `num_qubits == 0`). Physical qubits are now kept as-is in plain QASM programs, matching how gate and measurement operands already treat them; the rename is still applied for OpenPulse programs, where the pulse visitor expects it. ([#325](https://github.com/qBraid/pyqasm/pull/325)) - Fixed `measure` and `reset` on a user register whose name merely starts with the reserved internal register name (e.g. `__PYQASM_QUBITS__foo`) being mistaken for the internal register itself. Such statements were short-circuited out of unrolling and emitted verbatim, so `c = measure __PYQASM_QUBITS__foo;` was never expanded per qubit and `reset __PYQASM_QUBITS__foo;` silently reset nothing. The register is now matched on its exact name, or on the name followed by an index or slice. ([#325](https://github.com/qBraid/pyqasm/pull/325)) - Fixed the `c4x` (4-controlled X) gate, which previously raised `TypeError: c4x_gate() takes 4 positional arguments but 5 were given` because it was declared with four parameters for a five-qubit gate. It is now implemented via qiskit's structured `rc3x`/`c3sx`/`cphaseshift` decomposition. ([#320](https://github.com/qBraid/pyqasm/pull/320)) From 76411148125c4387749c87c212846374bbdd0cee Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 24 Jul 2026 12:56:41 -0400 Subject: [PATCH 3/3] fix pylint R0914: drop redundant local in _remap_qubits --- src/pyqasm/modules/base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index eeabeec9..0020691e 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -390,8 +390,7 @@ def _remap_qubits(self, reg_name: str, size: int, idle_indices: list[int]): if id(index_node) in visited_node_ids: continue visited_node_ids.add(id(index_node)) - old_idx = index_node.value # type: ignore[union-attr] - index_node.value = idx_map[old_idx] # type: ignore[union-attr] + index_node.value = idx_map[index_node.value] # type: ignore[union-attr] def _get_idle_qubit_indices(self) -> dict[str, list[int]]: """Get the indices of the idle qubits in the module