Skip to content
Open
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 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, matching how a single-level external custom gate is already handled. ([#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))
Expand Down
21 changes: 19 additions & 2 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:

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: Low

Rationale: This branching arm is reachable but has no observable effect today, and no test covers it.

Evidence: replacing the whole else body with pass leaves the full PR suite green — 718 passed, 4 skipped, and only the two pre-existing test_cli_commands.py failures that also fail on base. Depth was also unchanged across six conditional programs (external gate in if, ctrl @ external in if, external plus a second gate in if, external on qubits idle outside the branch, external gate inside an external custom gate in if, and a nested if) — every one reports 2 on base, on this PR, and with the arm deleted.

The reason is that the arm duplicates marking the validation-only call already performs. _recording_ext_gate_depth guards only _update_qubit_depth_for_gate (visitor.py:1098); it does not guard _mark_branch_qubit. So the suppressed _visit_basic_gate_operation call still reaches the branch-marking block at visitor.py:1201-1208 and marks the same qubits.

This is worth keeping rather than deleting. The arm becomes load-bearing the moment the suppression is extended to cover branch marking too — which is the natural reading of "suppress depth recording" and a plausible future refactor. Right now nothing pins that behaviour, so a later change could silently regress conditional depth with no test failing.

Change Requested: Add a test that fixes the conditional contract, so the arm is covered and a future change to the guard cannot regress it silently. For example, in tests/qasm3/test_depth.py, assert that a crz inside an if block reports the same depth whether or not crz is external (both are 2 today), and ideally one case where the external gate acts on qubits untouched outside the branch.

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.

Added in ecd3cf5 — two parametrized cases in test_depth.py, one with the conditional gate on qubits also used outside the branch and one where it is the only thing touching them. Both assert depth 2 external and non-external.

Worth being precise about what they buy, though: I reproduced your pass experiment and the new tests also pass with the arm neutered, for exactly the reason you gave — the validation-only call still reaches _mark_branch_qubit. So these pin the conditional contract, not the arm's current effect. That is what you asked for and it is the useful half: if someone extends _recording_ext_gate_depth to cover branch marking, the arm becomes load-bearing and these tests start failing if it is wrong. But nothing here would catch the arm being deleted today, and I would rather that be on the record than imply more coverage than exists.

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)
Expand Down
83 changes: 79 additions & 4 deletions tests/qasm3/test_depth.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,12 +680,87 @@ 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


@pytest.mark.parametrize("external_gates", [None, ["crz"]])
def test_external_gate_in_conditional_depth(external_gates):
"""A gate inside an if block reports the same depth whether or not it is external:
the external gate marks the branch qubits itself instead of letting the skipped
decomposition mark them (issue #352).

The branch-marking arm is currently also reached by the validation-only call, since
``_recording_ext_gate_depth`` guards depth recording but not branch marking. This
pins the contract so extending that guard cannot regress conditional depth silently.
"""
qasm3_string = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
bit[1] c;
c[0] = measure q[0];
if (c[0] == 1) { crz(0.5) q[1], q[2]; }
"""
result = loads(qasm3_string)
result.unroll(external_gates=external_gates)
assert result.depth() == 2


@pytest.mark.parametrize("external_gates", [None, ["crz"]])
def test_external_gate_in_conditional_on_idle_qubits_depth(external_gates):
"""Same contract when the conditional gate acts on qubits untouched outside the
branch, so branch marking is the only thing giving them any depth (issue #352)"""
qasm3_string = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[4] q;
bit[1] c;
c[0] = measure q[0];
if (c[0] == 1) { crz(0.5) q[2], q[3]; }
"""
result = loads(qasm3_string)
result.unroll(external_gates=external_gates)
assert result.depth() == 2
17 changes: 17 additions & 0 deletions tests/qasm3/test_pragma.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading