Skip to content

fix: walk nested box and if bodies in module transformation passes - #345

Open
ryanhill1 wants to merge 3 commits into
mainfrom
fix-nested-statement-passes
Open

fix: walk nested box and if bodies in module transformation passes#345
ryanhill1 wants to merge 3 commits into
mainfrom
fix-nested-statement-passes

Conversation

@ryanhill1

@ryanhill1 ryanhill1 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary of changes

Closes #342.

Problem

remove_idle_qubits() and reverse_qubit_order() iterate _unrolled_ast.statements at the top level only. Box and BranchingStatement are not in QUANTUM_STATEMENTS and their bodies were never descended into, so nested operands kept their old indices while top-level ones were rewritten — the output silently addressed the wrong qubits, and where a nested index fell outside the shrunken register it no longer parsed at all. has_measurements / remove_measurements / has_barriers / remove_barriers had the same blind spot.

Two things the walk forced into scope

Branch qubits are not idle. Gates and measurements applied inside an if block never incremented any QubitDepthNode counter, and is_idle() is _total_ops() == 0 — so a qubit used only in a branch was removed as idle. Left alone, walking if bodies would turn that silent breakage into a KeyError in the remap. Branch use is now recorded in a used_in_branch flag, kept out of the operation counters because a branch body's depth is only settled when the branch closes and the body may be visited more than once. The flag is read by is_idle() alone, so _total_ops() still means "operations on this qubit" and is byte-identical to main. Depth values are unchanged.

Physical qubits. _remap_qubits asserted every operand was an IndexedIdentifier, so remove_idle_qubits() raised AssertionError on a program mixing $1 with a declared register. Physical operands are now skipped — they belong to no register, so there is nothing to remap.

Worth a look

A box emptied by remove_measurements/remove_barriers is dropped rather than left behind, because pyqasm's own validator rejects a box with no statements and the output would not round-trip. An if block emptied the same way is kept — if (c) {} parses fine, and dropping it would discard the condition.

Not closed here

The walk covers the unrolled path. has_measurements / remove_measurements / has_barriers / remove_barriers fall back to _statements when the module has not been unrolled, and that list can still hold for / while / switch bodies this walker does not descend into — so an occurrence inside a loop is invisible until unroll() runs. Pre-existing and identical on main; tracked in #354. On the unrolled path there is nothing left to cover: those three containers are fully unrolled and never reach _unrolled_ast with bodies intact, so Box and BranchingStatement really are the only two.

Tests

Side-by-side against main, the dangerous shapes being the ones where nothing raises and the program still parses — it just addresses different qubits than the author wrote:

Program main this branch
qubit[3] q; bit[1] c; c[0]=measure q[0]; if (c[0]) { x q[2]; } + remove_idle_qubits() emits qubit[1] q; while the body still reads x q[2]; — reloading raises Index 2 out of range for register of size 1 qubit[2] q; with x q[1];, round-trips
qubit[3] q; h q[0]; box { cx q[0], q[1]; } + reverse_qubit_order() h q[2]; at top level, box { cx q[0], q[1]; } untouched h q[2]; box { cx q[2], q[1]; }
has_measurements() / has_barriers() with the occurrence inside a box or if False True
remove_measurements() / remove_barriers(), same shape no-op filtered, output re-validates
qubit[3] q; h q[0]; h $1; + remove_idle_qubits() / reverse_qubit_order() AssertionError / AttributeError both correct

module.depth() is identical on both branches across four programs, and _total_ops() per qubit matches main exactly.

test_remove_measurement_not_in_place_leaves_the_original_alone and test_remove_barriers_not_in_place_leaves_the_original_alone cover in_place=False: QasmModule.copy() is a deepcopy, and hoisting it above the filtering means the original shares nothing with the returned copy. Reverting only that hoist fails the first of those tests.

remove_idle_qubits and reverse_qubit_order only looked at top-level
statements, so operands inside box and if blocks kept stale indices --
silently addressing the wrong qubit, or naming a qubit outside the
shrunken register, which no longer parses.

Both passes now walk nested bodies, as do the measurement and barrier
removal passes. A qubit touched only inside a branch is recorded as used
so it is not mistaken for idle, and operands that are physical qubits are
skipped by the remap instead of tripping an assert.
@ryanhill1
ryanhill1 requested a review from TheGupta2012 as a code owner August 4, 2026 17:30
@argus-eye

argus-eye Bot commented Aug 4, 2026

Copy link
Copy Markdown

Argus review

Auto-review is off for this repo. Tick the box below to run a review on this PR.

Running Argus review...

Estimated cost

  • Files changed: 7
  • Diff lines (±): 365
  • Historical avg: ~161.1k tokens · ~$1.16 · across last 2 review(s)

Tip: you can also comment @argus-eye review at any time.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7727dcae-08f6-49b1-8987-81e8f629be7d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/pyqasm/modules/base.py 94.91% 3 Missing ⚠️
src/pyqasm/visitor.py 90.90% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@argus-eye argus-eye Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔎 Argus · 9/10 — Argus found 3 issues (0 critical, 1 warnings) across 7 files.

🔍 PR intent vs diff (LLM analysis)

Argus read the diff against the stated intent. This is not an execution log — reviewer still needs to test behavior.

Goal: Walk nested box and if bodies in module transformation passes so qubit remapping and measurement/barrier handling work correctly.
Not in scope:

  • Fix shared AST nodes in remove_measurements(in_place=False); nested filtering retains the pre-existing mutation behavior.
  • Change branch qubit depth values; depth is still settled when the branch closes.
    Stated acceptance criteria (from PR/issue — not independently verified):
  • Nested quantum statements have operand indices remapped by remove_idle_qubits and reverse_qubit_order.
  • Measurements and barriers inside box and if bodies are found and removed by the corresponding passes.
  • A qubit used only inside a branch is not classified as idle or removed.
  • Physical qubit operands are skipped during remapping.
  • Empty boxes are dropped after removing measurements or barriers, while empty if blocks are retained.

✅ Intent delivered

Argus found 3 issues (0 critical, 1 warnings) across 7 files. Key concerns: bug, testing.


Simulation Results

Tested 1 scenarios, 1 potential issues found:

Scenario: src/pyqasm/visitor.py: A trailing pragma inside a box leaks verbatim state into the enclosing scope
Verdict: Broken (90% sure)
Why: The PR does not reset pragma verbatim state when leaving a box scope.
Fix: Restore the prior verbatim state after visiting a box body, including trailing pragmas.

1 additional findings on lines outside the diff
  • 💡 tests/qasm3/test_transformations.py:L362 [suggestion] The branch assertion cannot detect whether branch operands are remapped

    A future or existing traversal failure can silently leave branch operands unchanged while CI reports the transformation as correct.

3 findings · 2 inline · 1 folded

🔢 407.8k tokens · $1.4666 total
Stage Tokens Cost
Intent 3.4k $0.0024
Triage 5.2k $0.0009
Lead agent 4.0k $0.0098
Review · bug_hunter 88.2k $0.3393
Review · security 85.8k $0.2563
Review · architecture 87.3k $0.3385
Review · regression 88.1k $0.2904
Review 32.3k $0.2030
Acceptance 1.9k $0.0042
Simulation 8.7k $0.0116
Scoring 2.9k $0.0102

Contract: production/full · checked: bug_hunter, security, architecture, regression · review took 2m24s

Dashboard → · React 👎 to dismiss · Reply to any inline comment or use @argus-eye help to chat

Comment thread src/pyqasm/visitor.py
Comment on lines 1316 to +1319
for qubit_subset in [op_qubits] + [ctrls]:
for qubit in qubit_subset:
qubit_name, qubit_idx = QasmVisitor._get_qubit_name_and_id(qubit)
self._is_branch_qubits.add((qubit_name, qubit_idx))
self._mark_branch_qubit(qubit_name, qubit_idx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 **P1 (7/10) · Bug:** Custom gates double-count branch operations after expansion (medium confidence)

Operation metadata becomes inaccurate for every custom gate used in a branch, affecting consumers of QubitDepthNode._total_ops().

Suggested change
for qubit_subset in [op_qubits] + [ctrls]:
for qubit in qubit_subset:
qubit_name, qubit_idx = QasmVisitor._get_qubit_name_and_id(qubit)
self._is_branch_qubits.add((qubit_name, qubit_idx))
self._mark_branch_qubit(qubit_name, qubit_idx)
for qubit_subset in [op_qubits] + [ctrls]:
for qubit in qubit_subset:
qubit_name, qubit_idx = QasmVisitor._get_qubit_name_and_id(qubit)
self._is_branch_qubits.add((qubit_name, qubit_idx))

React 👎 to dismiss · Argus learns from feedback

Comment thread src/pyqasm/modules/base.py Outdated
- remove_measurements/remove_barriers(in_place=False) took the copy after
  filtering, and the nested filter rewrites box and if bodies in place,
  so the original lost its nested measurements and barriers too
- branch operations are now flagged rather than counted: a branch body
  can be visited more than once and a custom gate marks both its own
  operands and those of its expansion, so the count was never meaningful
- reverse-order test used a branch operand that maps to itself, which
  could not tell a walked branch from a skipped one
@ryanhill1

Copy link
Copy Markdown
Member Author

Both findings fixed in ea621d6.

Out-of-place removal mutating the original — confirmed: remove_measurements(in_place=False) on a program with a measurement inside a box left the original module without it, because the nested filter rewrites stmt.body in place and the copy was taken afterwards. Both removal passes now take the copy first and filter the copy's statement list. Regression tests in test_measurement.py and test_barrier.py assert the original still has its nested measurement/barrier.

Custom gates double-counting branch operations — the count was wrong, but narrowing it to the custom-gate site would not have fixed it: a branch body is visited more than once, so x q[1] alone in an if already recorded 2. Since the only consumer is is_idle(), the field is now a flag (used_in_branch: bool) instead of a count, and _total_ops() adds 1 for it. That removes the inaccuracy at the root — marking the same qubit from both the custom gate and its expansion is now idempotent — while keeping branch-only wires visible to the printer's idle-wire filter. Added test_remove_idle_qubits_keeps_qubits_used_by_a_custom_gate_in_a_branch.

Folded finding on the branch assertion — also valid. test_reverse_qubit_order_inside_box_and_branch used x q[1] in a 3-qubit register, which reverses to itself, so the assertion could not tell a walked branch from a skipped one. It now uses x q[0] -> x q[2]. Verified by mutation: stripping the recursion out of iter_quantum_statements fails that test (and two others), where before the change it passed.

@TheGupta2012 TheGupta2012 left a comment

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.

Approved with comments — this converts three classes of silently wrong qubit operands into correct output. None of the three notes below is blocking.

Findings

All findings are posted inline on the relevant lines. Checklist:

  • L1 — _total_ops() gains a non-operation term; second reader exists in printer.py
  • L2 — PR description's in_place=False aliasing caveat is contradicted by this PR's own tests
  • L3 — Non-unrolled path still misses for/while/switch bodies

How it was tested

Side-by-side on main vs this branch. The first two rows are the dangerous ones — nothing raises, the program still parses, it just addresses different qubits than the author wrote:

Program main this branch
qubit[3] q; bit[1] c; c[0]=measure q[0]; if (c[0]) { x q[2]; } + remove_idle_qubits() emits qubit[1] q; while the body still reads x q[2]; — reloading raises Index 2 out of range for register of size 1 qubit[2] q; with x q[1];, round-trips
qubit[3] q; h q[0]; box { cx q[0], q[1]; } + reverse_qubit_order() h q[2]; at top level, box { cx q[0], q[1]; } untouched h q[2]; box { cx q[2], q[1]; }
has_measurements() / has_barriers() with the occurrence inside a box or if False True
remove_measurements() / remove_barriers(), same shape no-op filtered, output re-validates
qubit[3] q; h q[0]; h $1; + remove_idle_qubits() / reverse_qubit_order() AssertionError / AttributeError: 'str' object has no attribute 'name' both correct
  • module.depth() identical on both branches across four programs (2 / 2 / 1 / 3) — the description's claim holds for the public API and the .depth field.
  • Container containment: switch, while and for fully unroll and do not reach _unrolled_ast with bodies intact (a switch over x q[2] unrolls to a bare x q[2]), so Box and BranchingStatement are the only two the walk must handle. box inside an if and else_block both covered.
  • Empty-container asymmetry: box emptied by a removal is dropped, if is kept as if (c[0] == true) { }; both re-load and validate.
  • used_in_branch staleness after remove_measurements() — does not bite, because remove_idle_qubits() re-unroll()s and rebuilds the depth map.
  • Physical qubits inside a branch register a ('$1', 1) key, so _mark_branch_qubit's direct index does not KeyError.
  • Reverting only the curr_module = self if in_place else self.copy() hoist fails test_remove_measurement_not_in_place_leaves_the_original_alone — confirming that reordering is load-bearing, not cosmetic.
  • Suite on branch: 645 passed, 4 skipped (excluding tests/cli, which fails identically on main in this environment).

Next steps

Merge when ready. L2 is a one-paragraph edit to the PR description and worth doing before merge so it does not leave a future reader hunting a phantom aliasing bug. L1 is attached as an applicable suggestion and worth taking while the code is fresh; L3 is fine as a follow-up issue.

"""
# copy first: the filtering rewrites nested box and if bodies in place, so it
# has to run on the module that is being returned
curr_module = self if in_place else self.copy()

@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] The description's aliasing caveat is contradicted by this line — Maintenance · Low

Rationale: the PR description's last paragraph states that remove_measurements(in_place=False) "shares AST nodes between the original and the copy (pre-existing); the nested filtering mutates stmt.body in place, so it inherits that behaviour rather than fixing it."

This hoisted copy() is what rules that out. QasmModule.copy() is return deepcopy(self) (src/pyqasm/modules/base.py:803-805), so the returned module shares nothing with the original. Testing for exactly the aliasing the caveat predicts — box { h q[0]; c[0] = measure q[0]; barrier q; }, calling remove_measurements(in_place=False) and remove_barriers(in_place=False), on both the unrolled (_unrolled_ast.statements) and non-unrolled (_statements) paths — the original is byte-identical before and after in all four combinations, and only the returned copy is filtered. Same for remove_idle_qubits(in_place=False) with a box.

This PR's own tests assert it too: test_remove_measurement_not_in_place_leaves_the_original_alone and test_remove_barriers_not_in_place_leaves_the_original_alone. The caveat reads as a note written before the copy was hoisted and not revisited afterwards.

Change requested: drop that paragraph from the description before merge, so it does not leave a future reader hunting for a latent aliasing bug that this hoist already rules out.

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 — paragraph dropped from the description.

You are right that the hoisted copy() rules it out: QasmModule.copy() is deepcopy, so the returned module shares nothing. The caveat was written before the hoist and not revisited. The description now states the opposite where it matters — that hoisting the copy above the filtering is what makes in_place=False safe, and that reverting only the hoist fails test_remove_measurement_not_in_place_leaves_the_original_alone.

elif isinstance(stmt, BranchingStatement):
yield from iter_quantum_statements(stmt.if_block)
yield from iter_quantum_statements(stmt.else_block)
elif isinstance(stmt, QUANTUM_STATEMENTS):

@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.

[L3] Non-unrolled path still misses loop and switch bodies — Implementation · Low

Rationale: the walk closes the gap on the unrolled path, but has_measurements / remove_measurements / has_barriers / remove_barriers fall back to self._statements when the module has not been unrolled, and that list can still contain for / while / switch bodies this walker does not descend into. Verified on this branch:

qubit[2] q; bit[2] c;
for int i in [0:1] { c[i] = measure q[i]; barrier q; }

Without unroll(), has_measurements() and has_barriers() both return False and remove_measurements() is a no-op; after unroll() both return True and removal works.

This is pre-existing — main behaves identically — and the PR strictly improves the situation, so it is not worth holding the merge on. Flagged because this walker is the natural home for the remainder, and it is easy to read the PR as having closed the class of bug entirely.

Change requested: either extend iter_quantum_statements to loop and switch bodies for the non-unrolled path, or document on those four methods that they are only meaningful after unroll(). A follow-up issue is fine.

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.

Follow-up filed as #354 and referenced in the description, per your note that an issue is fine here.

Reproduced your case on this branch: for int i in [0:1] { c[i] = measure q[i]; barrier q; } gives has_measurements() == has_barriers() == False without unroll(), True after. #354 records both remedies you offered (extend iter_quantum_statements, or document the four methods as post-unroll() only).

The description now has a "Not closed here" section naming the gap, and carries your finding that for/while/switch never reach _unrolled_ast with bodies intact — so the unrolled path really is complete with Box and BranchingStatement, and the remainder is non-unrolled only.

Comment thread src/pyqasm/elements.py Outdated
Comment on lines 96 to 105
return (
self.num_resets
+ self.num_measurements
+ self.num_gates
+ self.num_barriers
+ int(self.used_in_branch)
)

def is_idle(self) -> bool:
return self._total_ops() == 0

@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] _total_ops() gains a term that is not an operation count — Maintenance · Low

Rationale: int(self.used_in_branch) was folded in so that is_idle() picks it up, but _total_ops() has a second consumer at src/pyqasm/printer.py:181. Measured per-qubit values for qubit[3] q; bit[1] c; h q[0]; c[0]=measure q[0]; if (c[0]) { x q[1]; }:

  • main: {('q',0): 2, ('q',1): 0, ('q',2): 0}
  • this branch: {('q',0): 2, ('q',1): 1, ('q',2): 0}

To be fair to the change, the impact was chased and there is none today: the value feeds max_depth in _compute_line_nums, which computes it (printer.py:167, 182) but returns only line_nums, sizes (printer.py:186). It is dead code, and pre-existing dead code this PR does not touch — so there is no rendering regression to fix.

The concern is narrower. _total_ops() no longer means "number of operations on this qubit", it means "operations, plus one if the qubit appears in a branch", and the one place that reads it for a non-idleness purpose looks like unfinished intent someone may well wire up later.

Change requested: keep the flag out of the arithmetic and express it in is_idle(), where it is the only thing it was introduced to affect. This also restores _total_ops() to the exact body it has on main.

Suggested change
return (
self.num_resets
+ self.num_measurements
+ self.num_gates
+ self.num_barriers
+ int(self.used_in_branch)
)
def is_idle(self) -> bool:
return self._total_ops() == 0
return self.num_resets + self.num_measurements + self.num_gates + self.num_barriers
def is_idle(self) -> bool:
return self._total_ops() == 0 and not self.used_in_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.

Resolved in 8b1f2ac — suggestion applied verbatim.

_total_ops() is now byte-identical to main, and the flag is read by is_idle() alone. Confirmed your measurement flips back:

main         {('q',0): 2, ('q',1): 0, ('q',2): 0}
this branch  {('q',0): 2, ('q',1): 0, ('q',2): 0}   <- was 1 for ('q',1)

with idleness still correct — q[1] is kept (used in branch), q[2] removed. Good catch on the second reader in printer.py; even dead, it is exactly the kind of thing someone wires up later against the wrong meaning.

_total_ops() means 'operations on this qubit' again -- byte-identical to main --
and the branch flag is expressed in is_idle(), the only thing it was introduced
to affect. printer.py reads _total_ops() for max_depth, so the term did not
belong in the arithmetic (L1).
@ryanhill1
ryanhill1 requested a review from TheGupta2012 August 5, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transformation passes ignore statements nested inside box and if blocks

3 participants