diff --git a/CHANGELOG.md b/CHANGELOG.md index b19335f..296a3b9 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 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)) 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.