Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Types of changes:
### Removed

### Fixed
- Fixed the `negctrl @` expansion emitting the same `x` `QuantumGate` object at both the leading and trailing position, so in-place transformations mutated its operands twice — crashing `reverse_qubit_order()` (`KeyError`) and `unroll(consolidate_qubits=True)` (`KeyError: '__PYQASM_QUBITS__'`) on any unrolled `negctrl` gate. The two `x` gates are now distinct statements with fresh operand nodes. ([#350](https://github.com/qBraid/pyqasm/issues/350))
- Fixed inaccurate `device_qubits` entry in `QasmModule.unroll()` docstring ([#349](https://github.com/qBraid/pyqasm/pull/349))
- 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))
Expand Down
16 changes: 11 additions & 5 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
MAX_ARRAY_DIMENSIONS,
)
from pyqasm.maps.gates import (
fresh_qubits,
map_qasm_ctrl_op_to_callable,
map_qasm_inv_op_to_callable,
map_qasm_op_num_params,
Expand Down Expand Up @@ -1608,11 +1609,16 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man
else:
result.extend(self._visit_basic_gate_operation(operation, inverse_value, ctrls))

# negctrl -> ctrl conversion
negs = [
qasm3_ast.QuantumGate([], qasm3_ast.Identifier("x"), [], [ctrl]) for ctrl in negctrls
]
result = negs + result + negs # type: ignore
# negctrl -> ctrl conversion; build each x gate with fresh operand nodes so
# the leading and trailing statements share nothing (issue #350)
def _neg_x_gates() -> list[qasm3_ast.QuantumGate]:
"""Build an x gate with fresh operand nodes for each negative control."""
return [
qasm3_ast.QuantumGate([], qasm3_ast.Identifier("x"), [], fresh_qubits(ctrl))
for ctrl in negctrls
]
Comment on lines +1614 to +1619

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a docstring to _neg_x_gates.

The new nested function has no docstring. Add a concise description of its purpose and return value.

As per coding guidelines, every Python function must have a docstring explaining its purpose, parameters, and return values.

Proposed fix
         def _neg_x_gates() -> list[qasm3_ast.QuantumGate]:
+            """Create X gates for the negative controls.
+
+            Returns:
+                list[qasm3_ast.QuantumGate]: The generated X gates.
+            """
             return [
📝 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 _neg_x_gates() -> list[qasm3_ast.QuantumGate]:
return [
qasm3_ast.QuantumGate([], qasm3_ast.Identifier("x"), [], fresh_qubits(ctrl))
for ctrl in negctrls
]
def _neg_x_gates() -> list[qasm3_ast.QuantumGate]:
"""Create X gates for the negative controls.
Returns:
list[qasm3_ast.QuantumGate]: The generated X gates.
"""
return [
qasm3_ast.QuantumGate([], qasm3_ast.Identifier("x"), [], fresh_qubits(ctrl))
for ctrl in negctrls
]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pyqasm/visitor.py` around lines 1614 - 1618, Update the nested
_neg_x_gates function with a concise docstring describing that it creates X
gates for the negative-control qubits and returns the resulting list of
qasm3_ast.QuantumGate objects; note that it takes no parameters.

Source: Coding guidelines


result = _neg_x_gates() + result + _neg_x_gates() # type: ignore
self._in_generic_gate_op_scope -= 1
if self._consolidate_qubits and not self._in_generic_gate_op_scope:
result = cast(
Expand Down
90 changes: 90 additions & 0 deletions tests/qasm3/test_transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,13 @@ def _assert_no_shared_operand_nodes(module):
"c4x q[0], q[1], q[2], q[3], q[4];",
"ecr q[0], q[1];",
"inv @ crz(0.5) q[1], q[2];",
"negctrl @ x q[0], q[1];",
"negctrl(2) @ x q[0], q[1], q[2];",

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.

Type: Maintenance
Severity: Low

Rationale: These two rows cover the plain negctrl shape only. Testing on origin/main shows the aliasing also reached six further operand shapes that this change fixes but that no test pins: a broadcast register control (negctrl @ x q, r;), a discrete-set control (negctrl @ x q[{0,1}], q[2];), negctrl inside a gate body invoked twice, negctrl inside a for body, pow(n) @ negctrl @, and ctrl @ negctrl @. Each takes a different _copy_qubit branch or repeats the expansion, so each is a distinct way for the pattern to regress. This is the fourth fix for one bug class (#331, #333, #335, #350), which argues for making this matrix the durable guard rather than adding a bespoke test per incident.

Change Requested: Optional. Consider adding two or three of the above as parametrize rows — one line each, no new helper needed.

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.

Agreed on making the matrix the durable guard rather than adding a bespoke test per incident. Added three rows in 32e1527:

negctrl @ x q[{0, 1}], q[2];
pow(2) @ negctrl @ x q[0], q[1];
ctrl @ negctrl @ x q[0], q[1], q[2];

I checked each against origin/main first — all three report a shared operand node there and are clean on this branch, so they are pulling their weight rather than just padding the matrix. (inv @ negctrl @ also fails on main, but it takes the same path as pow, so I left it out.)

I skipped the broadcast-register and gate-body/for-body shapes: the parametrize fixture is a single operation against one qubit[5] q, and those need a second register or a multi-statement body, so they would need a separate test rather than a row. Happy to add them if you think the coverage gap is worth the extra test.

# each of these reaches the negctrl expansion through a different
# operand shape or repeats it, so each is a distinct way to regress
"negctrl @ x q[{0, 1}], q[2];",
"pow(2) @ negctrl @ x q[0], q[1];",
"ctrl @ negctrl @ x q[0], q[1], q[2];",
],
)
def test_unroll_emits_fresh_operand_nodes(operation):
Expand All @@ -244,6 +251,89 @@ def test_unroll_emits_fresh_operand_nodes(operation):
_assert_no_shared_operand_nodes(module)


def test_reverse_qubit_order_negctrl():

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.

Type: Implementation
Severity: Medium

Rationale: This test covers one of the three consumers the aliasing broke. Issue #350 names remove_idle_qubits() explicitly, noting it survives only because of the visited_node_ids guard in Modules._remap_qubits — an incidental guard rather than a deliberate contract, so nothing stops a later refactor from dropping it and silently reintroducing the crash. And unroll(consolidate_qubits=True) did not merely survive on main; it raised KeyError: '__PYQASM_QUBITS__' outright. Both paths are fixed by this change and neither is asserted, which leaves the fix resting on a single pass.

Change Requested: Add two short cases alongside this one, so all three passes the aliasing reached are pinned:

  • remove_idle_qubits() on the unrolled negctrl @ x q[0], q[1]; — expect qubit[2] q; x q[0]; cx q[0], q[1]; x q[0];
  • unroll(consolidate_qubits=True) on the same program — expect qubit[3] __PYQASM_QUBITS__; x __PYQASM_QUBITS__[0]; cx __PYQASM_QUBITS__[0], __PYQASM_QUBITS__[1]; x __PYQASM_QUBITS__[0];

Both expected outputs were confirmed against this commit.

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.

Both added in 32e1527, with your expected outputs confirmed locally.

test_consolidate_qubits_negctrl is a genuine regression test — it raises KeyError: '__PYQASM_QUBITS__' on main. test_remove_idle_qubits_negctrl passes on main too, exactly as you say, since visited_node_ids rescues it; the docstring records that so nobody later reads a green test as proof the guard is unnecessary.

One correction to the expected consolidated output: the register declaration is emitted before the include, so it is qubit[3] __PYQASM_QUBITS__; then include "stdgates.inc";.

"""Test reverse_qubit_order on a negctrl gate whose leading and trailing x
statements previously were the same object (issue #350)"""
qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
negctrl @ x q[0], q[1];
"""

expected_qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
x q[2];
cx q[2], q[1];
x q[2];
"""

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


def test_remove_idle_qubits_negctrl():
"""Test remove_idle_qubits on a negctrl gate whose leading and trailing x
statements previously were the same object (issue #350).

This path survived the aliasing only via the ``visited_node_ids`` guard in
``_remap_qubits``, so it is pinned here to keep that incidental rescue from
being the only thing standing between a refactor and the crash.
"""
qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
negctrl @ x q[0], q[1];
"""

expected_qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
x q[0];
cx q[0], q[1];
x q[0];
"""

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


def test_consolidate_qubits_negctrl():
"""Test unroll(consolidate_qubits=True) on a negctrl gate whose leading and
trailing x statements previously were the same object (issue #350).

Consolidation rewrote the shared operand once, then met the already-renamed
node again and raised ``KeyError: '__PYQASM_QUBITS__'``.
"""
qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
negctrl @ x q[0], q[1];
"""

expected_qasm3_str = """
OPENQASM 3.0;
qubit[3] __PYQASM_QUBITS__;
include "stdgates.inc";
x __PYQASM_QUBITS__[0];
cx __PYQASM_QUBITS__[0], __PYQASM_QUBITS__[1];
x __PYQASM_QUBITS__[0];
"""

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


@pytest.mark.parametrize(
"operation", ["crz(0.5) q[1], q[2];", "swap q[0], q[2];", "cz q[1], q[2];"]
)
Expand Down
Loading