From 1c4f3456feed6f7ea4507bd432ec88be91d1cafe Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 24 Jul 2026 12:38:03 -0400 Subject: [PATCH 1/2] fix: remove_idle_qubits(in_place=False) decrementing num_qubits on the original module remove_idle_qubits routed every mutation through qasm_module (self or a deepcopy depending on in_place) except the qubit-count update, which used self. With in_place=False the original module's count was decremented and the returned copy kept the stale pre-removal count. Fixes #334 --- CHANGELOG.md | 1 + src/pyqasm/modules/base.py | 4 ++-- tests/qasm3/test_transformations.py | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fac09e..7e95eb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Types of changes: ### Removed ### Fixed +- Fixed `remove_idle_qubits(in_place=False)` updating the qubit count on the wrong module: the original module's `num_qubits` was decremented while the returned copy kept the stale pre-removal count. The copy's AST was already correct; only the counters were swapped. ([#334](https://github.com/qBraid/pyqasm/issues/334)) - Fixed `box` duration validation summing `delay` durations across all qubits instead of tracking each qubit's timeline. Delays on disjoint qubits run in parallel, so `box[300ns] { delay[200ns] q[0]; delay[200ns] q[1]; }` was rejected while the identical schedule written as a broadcast delay (`delay[200ns] q;`) was accepted. Delays are now accumulated per qubit and the box is validated against the busiest single timeline; the error message names the offending qubit. Nested boxes now also contribute their declared duration to the enclosing box's timelines (previously the accumulator was reset when an inner box closed, dropping all inner delay accounting). ([#330](https://github.com/qBraid/pyqasm/pull/330)) - 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)) diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index 3b21330a..2fb22aa1 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -471,7 +471,7 @@ def remove_idle_qubits(self, in_place: bool = True): for idle_idx in idle_indices: del qasm_module._qubit_depths[(reg_name, idle_idx)] - size = self._qubit_registers[reg_name] + size = qasm_module._qubit_registers[reg_name] if len(idle_indices) == size: # all qubits are idle @@ -491,7 +491,7 @@ def remove_idle_qubits(self, in_place: bool = True): qasm_module._remap_qubits(reg_name, size, idle_indices) # update the number of qubits - self._num_qubits -= len(idle_indices) + qasm_module._num_qubits -= len(idle_indices) # the original ast will need to be updated to the unrolled ast as if we call the # unroll operation again, it will incorrectly choose the original ast WITH THE IDLE QUBITS diff --git a/tests/qasm3/test_transformations.py b/tests/qasm3/test_transformations.py index a8c0eabe..97e2d76c 100644 --- a/tests/qasm3/test_transformations.py +++ b/tests/qasm3/test_transformations.py @@ -45,6 +45,27 @@ def test_remove_idle_qubits_qasm3_small(): check_unrolled_qasm(dumps(module), expected_qasm3_str) +def test_remove_idle_qubits_not_in_place(): + """Test remove_idle_qubits(in_place=False) updates the copy and leaves the + original module untouched""" + qasm3_str = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + h q[1]; + cx q[1], q[3]; + """ + module = loads(qasm3_str) + module.unroll() + new_module = module.remove_idle_qubits(in_place=False) + + assert new_module.num_qubits == 2 + assert "qubit[2] q;" in dumps(new_module) + + assert module.num_qubits == 4 + assert "qubit[4] q;" in dumps(module) + + def test_remove_idle_qubits_qasm3(): """Test conversion of qasm3 to compressed contiguous qasm3""" qasm3_str = """ From 2c3ebce1290df914e99707d5a0b6c3cdaa410d8d Mon Sep 17 00:00:00 2001 From: Harshit Gupta Date: Tue, 28 Jul 2026 11:57:34 +0530 Subject: [PATCH 2/2] Apply suggestion from @TheGupta2012 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e95eb17..3a1dc4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Types of changes: ### Removed ### Fixed -- Fixed `remove_idle_qubits(in_place=False)` updating the qubit count on the wrong module: the original module's `num_qubits` was decremented while the returned copy kept the stale pre-removal count. The copy's AST was already correct; only the counters were swapped. ([#334](https://github.com/qBraid/pyqasm/issues/334)) +- Fixed `remove_idle_qubits(in_place=False)` updating the qubit count on the wrong module: the original module's `num_qubits` was decremented while the returned copy kept the stale pre-removal count. The copy's AST was already correct; only the counters were swapped. ([#336](https://github.com/qBraid/pyqasm/pull/336)) - Fixed `box` duration validation summing `delay` durations across all qubits instead of tracking each qubit's timeline. Delays on disjoint qubits run in parallel, so `box[300ns] { delay[200ns] q[0]; delay[200ns] q[1]; }` was rejected while the identical schedule written as a broadcast delay (`delay[200ns] q;`) was accepted. Delays are now accumulated per qubit and the box is validated against the busiest single timeline; the error message names the offending qubit. Nested boxes now also contribute their declared duration to the enclosing box's timelines (previously the accumulator was reset when an inner box closed, dropping all inner delay accounting). ([#330](https://github.com/qBraid/pyqasm/pull/330)) - 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))