Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Types of changes:
### Fixed
- Fixed `remove_idle_qubits()` and `reverse_qubit_order()` ignoring statements nested inside `box` and `if` blocks. Top-level operands were rewritten while nested ones kept their old indices, so the result silently addressed the wrong qubits — and when a nested index fell outside the shrunken register, the output was not a loadable program at all. Both passes now walk nested bodies, as do `has_measurements()` / `remove_measurements()` and `has_barriers()` / `remove_barriers()`; a box left empty by a removal is dropped, since pyqasm rejects a box with no statements. Two consequences of the same blind spot are fixed alongside: a qubit operated on only inside an `if` block no longer counts as idle, and `remove_idle_qubits()` no longer raises `AssertionError` on a program that mixes physical qubits with declared registers. ([#345](https://github.com/qBraid/pyqasm/pull/345))
- Fixed `unroll(consolidate_qubits=True)` raising `AttributeError: 'str' object has no attribute 'name'` for any gate applied to a physical qubit, e.g. `h $1;`. Consolidation assumed every gate operand was an `IndexedIdentifier`, but a physical qubit survives unrolling as `Identifier("$1")`. Physical qubits are absolute hardware indices belonging to no declared register, so they are now left as written — matching how `measure`, `reset` and `barrier` already treat them. ([#344](https://github.com/qBraid/pyqasm/pull/344))
- Fixed `unroll()` and `rebase()` emitting statements that share operand AST nodes: gate decompositions passed the same `IndexedIdentifier` objects into every statement they emitted, so transformations that rewrite qubit indices in place mutated a shared node once per referencing statement. This crashed `reverse_qubit_order()` (`KeyError: -1`) and `remove_idle_qubits()` (`KeyError`, [#331](https://github.com/qBraid/pyqasm/issues/331)) on any decomposed gate (e.g. `crz`) whenever the remap was not the identity. Statement constructors in `maps/gates.py` and `Decomposer` now copy their qubit operands so every emitted statement owns its nodes. ([#333](https://github.com/qBraid/pyqasm/issues/333))
- 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 `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 `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))
Expand Down
6 changes: 4 additions & 2 deletions src/pyqasm/decomposer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from pyqasm.exceptions import RebaseError
from pyqasm.maps.decomposition_rules import DECOMPOSITION_RULES, AppliedQubit
from pyqasm.maps.gates import BASIS_GATE_MAP
from pyqasm.maps.gates import BASIS_GATE_MAP, fresh_qubits


class Decomposer:
Expand Down Expand Up @@ -133,7 +133,9 @@ def _get_decomposed_gates(cls, decomposition_rules, statement, gate):
modifiers=[],
name=qasm3_ast.Identifier(name=rule["gate"]),
arguments=arguments,
qubits=qubits,
# copy so the emitted gates do not share operand nodes with each
# other or with the source statement (see #333)
qubits=fresh_qubits(*qubits),

@TheGupta2012 TheGupta2012 Aug 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[L1] The #332 guard comment is now untrue — and the guard is still load-bearing — Maintenance · Low

Concerns the visited_node_ids guard in _remap_qubits, src/pyqasm/modules/base.py — not changed by this PR, hence the anchor here.

Rationale: the PR body describes that guard as "useful belt-and-braces" now that nodes are fresh. That undersells it, and the understatement is the risk — it invites a future reader to delete it as dead code. Removing the guard from this branch and re-running gives:

crz         : remove_idle_qubits OK          <- this PR did make the guard redundant here
negctrl @ x : remove_idle_qubits KeyError: 0 <- still the only thing holding this up
647 passed, 4 skipped                        <- no test would have caught it

So the guard is precisely what keeps remove_idle_qubits() working on the negctrl circuits from M2, and nothing in the suite protects it.

Change requested: update the comment above visited_node_ids to name the surviving case (statements re-inserted by the negctrl expansion), rather than "gate decompositions can reuse the same operand node" — which this PR has now made untrue. One line, but it is the difference between the next reader keeping it and removing it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Resolved in 6620dbe — comment rewritten to name the surviving case.

Verified the guard is still load-bearing: remove_idle_qubits() on negctrl @ x passes only because of it. The comment now reads:

# the negctrl expansion in the visitor re-inserts the same QuantumGate object before
# and after the controlled gate, so a single index node can be reached more than once;
# track visited nodes so it is remapped exactly once

Cross-referenced from #350 so the two stay linked.

)

decomposed_gates.append(new_gate)
Expand Down
64 changes: 58 additions & 6 deletions src/pyqasm/maps/gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,66 @@

"""

from copy import deepcopy
from typing import Callable

import numpy as np
from openqasm3.ast import FloatLiteral, Identifier, IndexedIdentifier, QuantumGate, QuantumPhase
from openqasm3.ast import (
FloatLiteral,
Identifier,
IndexedIdentifier,
IntegerLiteral,
QuantumGate,
QuantumPhase,
)

from pyqasm.elements import BasisSet, InversionOp
from pyqasm.exceptions import ValidationError, raise_qasm3_error
from pyqasm.linalg import kak_decomposition_angles
from pyqasm.maps.expressions import CONSTANTS_MAP

QubitOperand = IndexedIdentifier | Identifier


def _copy_qubit(qubit: QubitOperand) -> QubitOperand:
"""Return a copy of a single qubit operand that shares no nodes with it.

A shallow ``copy`` is not enough: transforms rewrite the index in place as
``bit.indices[0][0].value = ...``, so the nested ``IntegerLiteral`` has to
be a distinct object. Rebuilding the (small, fully known) node tree is an
order of magnitude cheaper than ``deepcopy``, so the general case is only
used as a fallback for operands that are not a plain ``reg[int]``.
"""
fresh: QubitOperand
if isinstance(qubit, Identifier):
fresh = Identifier(name=qubit.name)
else:
indices = qubit.indices
if not (
len(indices) == 1
and isinstance(indices[0], list)
and len(indices[0]) == 1
and isinstance(indices[0][0], IntegerLiteral)
):
return deepcopy(qubit)
fresh = IndexedIdentifier(
name=Identifier(name=qubit.name.name),
indices=[[IntegerLiteral(value=indices[0][0].value)]],
)
fresh.span = qubit.span
return fresh


def fresh_qubits(*qubits: QubitOperand) -> list[QubitOperand]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[M2] negctrl path still aliases statement objects — Implementation · Medium (pre-existing; follow-up issue, not a blocker)

Rationale: this helper delivers what it promises for decomposition-emitted statements. But the PR description says "no emitted statement can share a node with another statement or with the source operation", and the unroller has an independent aliasing source this PR does not touch, in visitor.py:

negs = [qasm3_ast.QuantumGate([], qasm3_ast.Identifier("x"), [], [ctrl]) for ctrl in negctrls]
result = negs + result + negs

That places the same QuantumGate object at two positions in the statement list, not two equal copies. Verified on this branch:

negctrl @ x q[0], q[1];  ->  x q[0];  cx q[0], q[1];  x q[0];
statement object ids: [4337616448, 4336546384, 4337616448]

So reverse_qubit_order() still walks the same operand node twice and raises KeyError: -2. Identical behaviour on main (unroll() + reverse_qubit_order() on negctrl @ x fails on both), so this is not a regression from this PR — it is outside the stated scope, and no test covers it on either side.

Change requested: nothing in this PR. Open a follow-up for the negctrl path (presumably a second negs list rather than reusing the first, plus fresh_qubits on ctrl), and consider narrowing the sentence in the PR body to "decomposition no longer emits shared nodes" so the changelog does not over-promise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Acknowledged — follow-up filed as #350, PR body narrowed.

Reproduced on this branch: statement object ids for negctrl @ x q[0], q[1]; come back [4362757584, 4362758224, 4362757584], so positions 0 and 2 are the same object. Left out of scope here since it's pre-existing on main and lives in the modifier expansion, not decomposition.

The PR body now says "no statement emitted by a decomposition can share a node", with an explicit scope note pointing at #350.

"""Copy qubit operands so every emitted statement owns its nodes.

Decomposition functions pass the same operand nodes to each statement they
emit; without copies, transforms that later rewrite qubit indices in place
(e.g. ``remove_idle_qubits``, ``reverse_qubit_order``) would mutate a
shared node once per referencing statement.
"""
return [_copy_qubit(qubit) for qubit in qubits]


def u3_gate(
theta: int | float,
Expand Down Expand Up @@ -113,7 +163,9 @@ def global_phase_gate(theta: float, qubit_list: list[IndexedIdentifier]) -> list
"""
return [
QuantumPhase(
argument=FloatLiteral(value=theta), qubits=qubit_list, modifiers=[] # type: ignore
argument=FloatLiteral(value=theta),
qubits=fresh_qubits(*qubit_list), # type: ignore
modifiers=[],
)
]

Expand Down Expand Up @@ -702,7 +754,7 @@ def ccx_gate_op(
modifiers=[],
name=Identifier(name="ccx"),
arguments=[],
qubits=[qubit0, qubit1, qubit2],
qubits=fresh_qubits(qubit0, qubit1, qubit2),
)
]

Expand Down Expand Up @@ -905,7 +957,7 @@ def one_qubit_gate_op(gate_name: str, qubit_id: IndexedIdentifier) -> list[Quant
modifiers=[],
name=Identifier(name=gate_name),
arguments=[],
qubits=[qubit_id],
qubits=fresh_qubits(qubit_id),
)
]

Expand All @@ -918,7 +970,7 @@ def one_qubit_rotation_op(
modifiers=[],
name=Identifier(name=gate_name),
arguments=[FloatLiteral(value=rotation)],
qubits=[qubit_id],
qubits=fresh_qubits(qubit_id),
)
]

Expand All @@ -931,7 +983,7 @@ def two_qubit_gate_op(
modifiers=[],
name=Identifier(name=gate_name.lower()),
arguments=[],
qubits=[qubit_id1, qubit_id2],
qubits=fresh_qubits(qubit_id1, qubit_id2),
)
]

Expand Down
5 changes: 3 additions & 2 deletions src/pyqasm/modules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,9 @@ 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
# the negctrl expansion in the visitor re-inserts the same QuantumGate object before
# and after the controlled gate, so a single index node can be reached more than once;
# track visited nodes so it is remapped exactly once
visited_node_ids = set()
for operation in iter_quantum_statements(self._unrolled_ast.statements):
bit_list = Qasm3Analyzer.get_op_bit_list(operation)
Expand Down
140 changes: 140 additions & 0 deletions tests/qasm3/test_transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@

"""

import pytest
from openqasm3.ast import DiscreteSet, Identifier, IndexedIdentifier, IntegerLiteral, Span

from pyqasm.analyzer import Qasm3Analyzer
from pyqasm.elements import BasisSet
from pyqasm.entrypoint import dumps, loads
from pyqasm.maps import QUANTUM_STATEMENTS
from pyqasm.maps.gates import fresh_qubits
from tests.utils import check_unrolled_qasm


Expand Down Expand Up @@ -156,6 +163,139 @@ def test_reverse_qubit_order_qasm3():
check_unrolled_qasm(dumps(module), expected_qasm3_str)


def test_reverse_qubit_order_gate_decomposition():
"""Test reverse_qubit_order on a decomposed gate whose statements previously
shared operand nodes (issue #333)"""
qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
crz(0.5) q[1], q[2];
"""

expected_qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
rz(0.25) q[0];
rx(1.5707963267948966) q[0];
rz(3.141592653589793) q[0];
rx(1.5707963267948966) q[0];
rz(3.141592653589793) q[0];
cx q[1], q[0];
rz(-0.25) q[0];
rx(1.5707963267948966) q[0];
rz(3.141592653589793) q[0];
rx(1.5707963267948966) q[0];
rz(3.141592653589793) q[0];
cx q[1], q[0];
"""

module = loads(qasm3_str)
module.unroll()
module.reverse_qubit_order()
check_unrolled_qasm(dumps(module), expected_qasm3_str)


def _assert_no_shared_operand_nodes(module):

@TheGupta2012 TheGupta2012 Aug 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[L2] Helper assumes an indexed operand shape — Maintenance · Low

Rationale: _assert_no_shared_operand_nodes does bit.indices[0][0] unconditionally, so it would raise on a bare-Identifier physical qubit or a DiscreteSet index rather than reporting a clean assertion failure. Not hit by the current parameters, and test_fresh_qubits_shares_no_nodes covers those shapes directly.

Change requested: none required. Worth a guard only if this helper gets reused for physical-qubit circuits later — noted so the limitation is known rather than rediscovered.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Resolved in 6620dbe — guard added, since it was cheap.

_assert_no_shared_operand_nodes still checks operand-node identity for every bit, but skips the index check for bare Identifier operands and non-plain index shapes rather than raising on bit.indices[0][0].

"""Assert no two quantum statements in the unrolled AST share an operand node"""
seen_bits: set[int] = set()
seen_indices: set[int] = set()
for statement in module._unrolled_ast.statements:
if isinstance(statement, QUANTUM_STATEMENTS):
for bit in Qasm3Analyzer.get_op_bit_list(statement):
assert id(bit) not in seen_bits, f"operand node shared: {bit}"
seen_bits.add(id(bit))
# physical qubits are bare Identifiers, and an index may be a DiscreteSet
# or multi-dimensional; only plain reg[int] operands have an index node
if not isinstance(bit, IndexedIdentifier):
continue
indices = bit.indices
if not (
len(indices) == 1 and isinstance(indices[0], list) and len(indices[0]) == 1
):
continue
index_node = indices[0][0]
assert id(index_node) not in seen_indices, f"index node shared: {bit}"
seen_indices.add(id(index_node))


@pytest.mark.parametrize(
"operation",
[
"crz(0.5) q[1], q[2];",
"crx(0.5) q[0], q[1];",
"c4x q[0], q[1], q[2], q[3], q[4];",
"ecr q[0], q[1];",
"inv @ crz(0.5) q[1], q[2];",
],
)
def test_unroll_emits_fresh_operand_nodes(operation):
"""Test that unroll() never emits statements sharing operand nodes, so in-place
transformations remap each operand exactly once (issues #331, #333)"""
qasm3_str = f"""
OPENQASM 3.0;
include "stdgates.inc";
qubit[5] q;
{operation}
"""
module = loads(qasm3_str)
module.unroll()
_assert_no_shared_operand_nodes(module)


@pytest.mark.parametrize(
"operation", ["crz(0.5) q[1], q[2];", "swap q[0], q[2];", "cz q[1], q[2];"]
)
def test_rebase_emits_fresh_operand_nodes(operation):
"""Test that rebase() never emits statements sharing operand nodes (issue #333)

``swap`` and ``cz`` are in DECOMPOSITION_RULES so they exercise
Decomposer._get_decomposed_gates; ``crz`` is handled by maps/gates.py instead.
"""
qasm3_str = f"""
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
{operation}
"""
module = loads(qasm3_str).rebase(BasisSet.ROTATIONAL_CX)
_assert_no_shared_operand_nodes(module)


@pytest.mark.parametrize(
"qubit",
[
# plain reg[int] operand, rebuilt node by node
IndexedIdentifier(Identifier("q"), [[IntegerLiteral(2)]]),
# physical qubit, kept as a bare Identifier
Identifier("$0"),
# anything else falls back to a deep copy
IndexedIdentifier(Identifier("q"), [DiscreteSet([IntegerLiteral(0), IntegerLiteral(1)])]),
IndexedIdentifier(Identifier("q"), [[IntegerLiteral(0)], [IntegerLiteral(1)]]),
],
)
def test_fresh_qubits_shares_no_nodes(qubit):
"""Test that fresh_qubits returns an equal operand that shares no node with the original"""
qubit.span = Span(1, 0, 1, 4)

(copied,) = fresh_qubits(qubit)

assert copied == qubit
assert copied.span == qubit.span
assert copied is not qubit
if isinstance(qubit, IndexedIdentifier):
assert copied.name is not qubit.name
for copied_dim, original_dim in zip(copied.indices, qubit.indices):
assert copied_dim is not original_dim
copied_exprs = copied_dim.values if isinstance(copied_dim, DiscreteSet) else copied_dim
original_exprs = (
original_dim.values if isinstance(original_dim, DiscreteSet) else original_dim
)
for copied_index, original_index in zip(copied_exprs, original_exprs):
assert copied_index is not original_index


def test_populate_idle_qubits_qasm3():
"""Test the populate idle qubits function for qasm3 string"""

Expand Down
Loading