From 4fd7dd0b6b3d5928ec5d59ac54913e0b6463e63d Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 7 Aug 2026 08:11:27 -0500 Subject: [PATCH] fix: record an external gate's own depth, not its skipped decomposition's _visit_external_gate_operation ran _visit_basic_gate_operation for validation only, but that call still updated qubit depths over the decomposition that was never emitted. Depth recording is now suppressed around the validation call and the external gate records its own depth, mirroring the existing custom-gate path. Affects both explicit external_gates and braket verbatim boxes. Fixes #352 --- CHANGELOG.md | 1 + src/pyqasm/visitor.py | 21 +++++++++++++++++-- tests/qasm3/test_depth.py | 43 ++++++++++++++++++++++++++++++++++---- tests/qasm3/test_pragma.py | 17 +++++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc32f0..7821924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Types of changes: ### Removed ### Fixed +- Fixed external and verbatim-box gates counting the depth of the decomposition they skipped: `unroll(external_gates=["crz"])` on a single `crz` reported `depth() == 12` while emitting one statement. An external gate now records its own depth, like the custom-gate path already did. ([#352](https://github.com/qBraid/pyqasm/issues/352)) - 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)) diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index ea9f5f3..602ca68 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -1364,8 +1364,15 @@ def _visit_external_gate_operation( # Don't need to check if custom gate exists, since we just validated the call gate_qubit_count = len(self._custom_gates[gate_name].qubits) else: - # Ignore result, this is just for validation - self._visit_basic_gate_operation(operation) + # Ignore result, this is just for validation. Suppress depth recording so the + # skipped decomposition does not count; the gate's own depth is recorded + # below (issue #352) + prev_recording = self._recording_ext_gate_depth + self._recording_ext_gate_depth = True + try: + self._visit_basic_gate_operation(operation) + finally: + self._recording_ext_gate_depth = prev_recording # Don't need to check if basic gate exists, since we just validated the call _, gate_qubit_count = map_qasm_op_to_callable(operation) @@ -1399,6 +1406,16 @@ def gate_function(*qubits): all_targets = self._unroll_multiple_target_qubits(operation, gate_qubit_count) result = self._broadcast_gate_operation(gate_function, all_targets) + # record the external gate's own depth; the custom-gate path has already done so + if gate_name not in self._custom_gates: + if not self._in_branching_statement: + self._update_qubit_depth_for_gate(all_targets, ctrls) + else: + for qubit_subset in all_targets + [ctrls]: + for qubit in qubit_subset: + qubit_name, qubit_idx = QasmVisitor._get_qubit_name_and_id(qubit) + self._mark_branch_qubit(qubit_name, qubit_idx) + # check for any duplicates for final_gate in result: Qasm3Analyzer.verify_gate_qubits(final_gate, operation.span) diff --git a/tests/qasm3/test_depth.py b/tests/qasm3/test_depth.py index 2c9db0b..aaaff3a 100644 --- a/tests/qasm3/test_depth.py +++ b/tests/qasm3/test_depth.py @@ -680,12 +680,47 @@ def test_gate_depth_decomposable_gates(input_qasm_str, before_decompose, after_d @pytest.mark.parametrize( - ["input_qasm_str", "before_decompose", "after_decompose"], - [(QASM3_DECOMPOSE_CUSTOM_GATE_DEPTH, 2, 2)], + ["input_qasm_str", "external_gates", "before_decompose", "after_decompose"], + [ + (QASM3_DECOMPOSE_CUSTOM_GATE_DEPTH, ["custom_crx", "custom_rccx"], 2, 2), + (QASM3_DECOMPOSE_GATE_DEPTH, ["crx", "rccx"], 2, 2), + ], ) -def test_gate_depth_decomposable_external_gates(input_qasm_str, before_decompose, after_decompose): +def test_gate_depth_decomposable_external_gates( + input_qasm_str, external_gates, before_decompose, after_decompose +): + """An external gate skips its decomposition, so it must not count the depth of + the decomposition it skipped (issue #352)""" result = loads(input_qasm_str) - result._external_gates = ["custom_crx", "custom_rccx"] + result._external_gates = external_gates assert result.depth(decompose_native_gates=False) == before_decompose # by default its true assert result.depth() == after_decompose + + +def test_external_basic_gate_counts_own_depth(): + """One external crz statement is emitted, so it counts as depth 1 (issue #352)""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + crz(0.5) q[0], q[1]; + """ + result = loads(qasm3_string) + result.unroll(external_gates=["crz"]) + assert result.depth() == 1 + + +def test_external_basic_gate_depth_with_neighbours(): + """External gate depth composes with surrounding gates like any single gate""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + x q[0]; + crz(0.5) q[0], q[1]; + x q[1]; + """ + result = loads(qasm3_string) + result.unroll(external_gates=["crz"]) + assert result.depth() == 3 diff --git a/tests/qasm3/test_pragma.py b/tests/qasm3/test_pragma.py index b1a5798..4f1d043 100644 --- a/tests/qasm3/test_pragma.py +++ b/tests/qasm3/test_pragma.py @@ -221,6 +221,23 @@ def test_verbatim_custom_gate_counts_once_towards_depth(): assert module.depth() == 1 +def test_verbatim_basic_gate_counts_once_towards_depth(): + """A decomposable stdgates gate inside a verbatim box is emitted as written, + so its depth is that of one gate, not of the skipped decomposition (issue #352).""" + qasm_str = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + #pragma braket verbatim + box { + crz(0.5) q[0], q[1]; + } + """ + module = loads(qasm_str) + module.unroll() + assert module.depth() == 1 + + def test_verbatim_marker_does_not_escape_a_box(): """A pragma at the end of a box body must not mark the next box verbatim.