Skip to content

fix: emit fresh operand nodes from gate decompositions - #335

Open
ryanhill1 wants to merge 6 commits into
mainfrom
fix-unroll-aliased-operand-nodes
Open

fix: emit fresh operand nodes from gate decompositions#335
ryanhill1 wants to merge 6 commits into
mainfrom
fix-unroll-aliased-operand-nodes

Conversation

@ryanhill1

@ryanhill1 ryanhill1 commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary of changes

Closes #333. Root-cause fix for the operand-node aliasing behind #331 as well.

Gate decomposition functions in maps/gates.py pass the same qubit operand nodes into every statement they emit (e.g. crz_gate hands qubit1 to two u3_gate and two cx expansions), so the unrolled AST contained IndexedIdentifier objects shared across many statements. Any transformation that rewrites qubit indices in place then mutated a shared node once per referencing statement:

This PR fixes the class of bug at the source: the five statement constructors in maps/gates.py (one_qubit_gate_op, one_qubit_rotation_op, two_qubit_gate_op, ccx_gate_op, global_phase_gate) now copy their qubit operands via a shared fresh_qubits helper, and Decomposer does the same for the operands of each rule-emitted gate. Every decomposition composes these constructors, so no statement emitted by a decomposition can share a node with another statement or with the source operation.

Scope note: the negctrl modifier expansion in visitor.py is an independent aliasing source that this PR does not touch — it re-inserts the same QuantumGate object before and after the controlled gate. That is pre-existing on main and tracked separately in #350. The visited_node_ids guard added in #332 is what keeps remove_idle_qubits() working on those circuits, so it remains load-bearing rather than redundant.

fresh_qubits rebuilds the (small, fully known) operand node tree by hand rather than calling deepcopy — a shallow copy is not sufficient because transforms mutate the nested IntegerLiteral in place. Microbenchmark over 200k iterations on q[3]: deepcopy 1.550 s, shallow copy 0.182 s (and incorrect), structural rebuild 0.110 s. deepcopy is kept only as a fallback for operand shapes that are not a plain reg[int].

Verified beyond the test suite: the reversed/remapped crz circuits are unitarily equivalent to the expected reference circuits via qiskit Operator.equiv.

Tests

  • test_reverse_qubit_order_gate_decomposition: the reverse_qubit_order() KeyError on aliased operand nodes (same root cause as #331) #333 repro, checked against the full expected unrolled program.
  • test_unroll_emits_fresh_operand_nodes: invariant test asserting no two quantum statements share an operand or index node after unroll(), parametrized over crz, crx, c4x, ecr, and inv @ crz.
  • test_rebase_emits_fresh_operand_nodes: same invariant for rebase(), parametrized over swap and cz (which route through Decomposer._get_decomposed_gates) and crz (which does not).
  • test_fresh_qubits_shares_no_nodes: covers the plain reg[int], physical-qubit, and deepcopy-fallback paths of the helper.

Gate decomposition functions passed the same IndexedIdentifier objects
into every statement they emitted, so in-place index rewrites in
remove_idle_qubits() and reverse_qubit_order() mutated a shared node
once per referencing statement, raising KeyError whenever the remap
was not the identity.

Statement constructors in maps/gates.py and Decomposer now deep-copy
their qubit operands so every emitted statement owns its nodes.

Fixes #333
@ryanhill1
ryanhill1 requested a review from TheGupta2012 as a code owner July 24, 2026 16:36
@coderabbitai

coderabbitai Bot commented Jul 24, 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: eece08e9-0760-4e75-bd6b-fee036700c1c

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-unroll-aliased-operand-nodes

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

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment thread src/pyqasm/maps/gates.py Outdated
(e.g. ``remove_idle_qubits``, ``reverse_qubit_order``) would mutate a
shared node once per referencing statement.
"""
return [deepcopy(qubit) for qubit in qubits]

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.

Does an IndexedIdentifier have nested statements as well? If not, maybe a shallow copy can work here too. The performance of deepcopy is known to be a little slow, and since this _fresh_qubits operation will be used quite frequently, it is best to figure out the optimized approach for copying

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.

Good catch on the cost — but a shallow copy unfortunately isn't enough here.

IndexedIdentifier has no nested statements, but it does have a nested node tree: IndexedIdentifier.name is an Identifier, and .indices is a list[list[Expression]] holding the IntegerLiteral. The transforms mutate the innermost node in place:

bit.indices[0][0].value = idx_map[old_idx]   # _remap_qubits
bit.indices[0][0].value = -1 * new_reg_idx - 1  # reverse_qubit_order

so copy.copy leaves the very object that gets mutated shared:

s = copy(q)
s.indices[0][0].value = 99
q.indices[0][0].value  # 99  <- still aliased

What does work, and is faster than both, is rebuilding the node tree by hand. At this point in the pipeline every operand is either a plain reg[int] or a bare physical-qubit Identifier, so the copy is three tiny constructor calls with no reflection:

def _copy_qubit(qubit):
    if isinstance(qubit, Identifier):
        fresh = Identifier(name=qubit.name)
    else:
        indices = qubit.indices
        if not (len(indices) == 1 and isinstance(indices[0], list)
                and len(indices[0]) == 1 and isinstance(indices[0][0], IntegerLiteral)):
            return deepcopy(qubit)   # fallback for any other operand shape
        fresh = IndexedIdentifier(
            name=Identifier(name=qubit.name.name),
            indices=[[IntegerLiteral(value=indices[0][0].value)]],
        )
    fresh.span = qubit.span
    return fresh

Microbenchmark, 200k iterations on q[3]:

approach time
deepcopy 1.550 s
copy (insufficient) 0.182 s
structural rebuild 0.110 s

~14x faster than deepcopy, and still faster than the shallow copy it can't use. deepcopy is kept only as a fallback for operand shapes that aren't a plain reg[int] (e.g. a DiscreteSet or multi-dimensional index), so correctness doesn't depend on that assumption holding forever.

span is carried over explicitly so error reporting on emitted statements is unchanged.

Added test_fresh_qubits_shares_no_nodes covering the plain, physical-qubit, and fallback paths.

Comment thread src/pyqasm/decomposer.py Outdated
qubits=qubits,
# copy so the emitted gates do not share operand nodes with each
# other or with the source statement (see #333)
qubits=[deepcopy(qubit) for qubit in qubits],

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.

Why not use _fresh_qubits here too?

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.

Done — good call. Renamed the helper to fresh_qubits (dropping the leading underscore now that it crosses module boundaries) and _get_decomposed_gates now calls it:

qubits=fresh_qubits(*qubits),

decomposer.py no longer imports deepcopy at all.

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

Thanks for flagging this @ryanhill1 , can you see if copy works here as well? If yes, best to use that instead of deepcopy.

Addresses review feedback on #335:

- A shallow `copy` cannot replace `deepcopy` here: transforms rewrite the
  index in place as `bit.indices[0][0].value = ...`, so the nested
  `IntegerLiteral` must be a distinct object, which `copy` does not give.
  Instead, `_copy_qubit` rebuilds the (small, fully known) `reg[int]` node
  tree directly — ~14x faster than `deepcopy` in a microbenchmark, and
  faster than `copy.copy` as well — falling back to `deepcopy` only for
  operand shapes that are not a plain `reg[int]`.
- `Decomposer._get_decomposed_gates` now uses the shared `fresh_qubits`
  helper (renamed from `_fresh_qubits` now that it crosses modules)
  instead of its own inline `deepcopy` comprehension.
- Added `test_fresh_qubits_shares_no_nodes` covering the plain, physical
  qubit, and `deepcopy` fallback paths.
@argus-eye

argus-eye Bot commented Jul 31, 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.

  • Trigger Argus review

Estimated cost

  • Files changed: 4
  • Diff lines (±): 195

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

@ryanhill1
ryanhill1 requested a review from TheGupta2012 July 31, 2026 17:35

@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 — the right fix at the right level: #331 and #333 were two symptoms of one defect, and this fixes the source rather than guarding each consumer. One test gap worth closing before merge; the other notes are follow-ups.

Findings

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

  • M1 — decomposer.py hunk is unpinned; reverting it leaves the suite green
  • M2 — negctrl path still aliases statement objects; PR body over-promises (follow-up)
  • L1 — #332 guard comment is now untrue and invites deletion of load-bearing code
  • L2 — Test helper assumes an indexed operand shape

How it was tested

  • Repro: crz(0.5) q[1],q[2] + unroll() + reverse_qubit_order() raises KeyError: -1 on main, passes here.
  • Aliasing invariant: 0 shared operand/index nodes after unroll() across crz, crx, ecr, inv @ crz, ccx, c4x.
  • Reverting qubits=fresh_qubits(*qubits) in decomposer.py alone, leaving gates.py intact, still leaves the full suite green (see M1).
  • Removing the #332 guard from the branch: crz fine, negctrl @ x raises KeyError: 0, suite still green (see L1).
  • Instrumenting Decomposer._get_decomposed_gates to confirm which gates reach it: crz → no calls, swap → one call.
  • Microbenchmark reproduced (200k iterations on q[3]): deepcopy 1.499 s, shallow copy 0.168 s, _copy_qubit 0.112 s. Mutating a shallow copy's indices[0][0].value writes through to the original; _copy_qubit's result does not.
  • Perf end to end: 200 crz gates → 2402 statements in 0.046 s; the deepcopy fallback is not on the hot path.
  • Suite on branch: 647 passed, 4 skipped. Branch current with main (0 behind), MERGEABLE.
  • The parametrized test suggested in M1 was applied and run both ways: 3 passed with the fix; reverting decomposer.py fails the swap and cz cases and passes crz.

Next steps

Add the swap/cz case from M1 to the rebase test — that is the one ask before merge; it is attached as an applicable suggestion. M2 and L1 want follow-up issues (and L1 a one-line comment edit), L2 is optional. Independent of the rest of the open batch, so it can land at any point in the sequence.

Comment thread src/pyqasm/maps/gates.py
return fresh


def fresh_qubits(*qubits: QubitOperand) -> list[QubitOperand]:

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.

[M2] negctrl path still aliases statement objects — Implementation · Medium (pre-existing; follow-up issue, not a blocker)

Rationale: this helper delivers what it promises for decomposition-emitted statements. But the PR description says "no emitted statement can share a node with another statement or with the source operation", and the unroller has an independent aliasing source this PR does not touch, in visitor.py:

negs = [qasm3_ast.QuantumGate([], qasm3_ast.Identifier("x"), [], [ctrl]) for ctrl in negctrls]
result = negs + result + negs

That places the same QuantumGate object at two positions in the statement list, not two equal copies. Verified on this branch:

negctrl @ x q[0], q[1];  ->  x q[0];  cx q[0], q[1];  x q[0];
statement object ids: [4337616448, 4336546384, 4337616448]

So reverse_qubit_order() still walks the same operand node twice and raises KeyError: -2. Identical behaviour on main (unroll() + reverse_qubit_order() on negctrl @ x fails on both), so this is not a regression from this PR — it is outside the stated scope, and no test covers it on either side.

Change requested: nothing in this PR. Open a follow-up for the negctrl path (presumably a second negs list rather than reusing the first, plus fresh_qubits on ctrl), and consider narrowing the sentence in the PR body to "decomposition no longer emits shared nodes" so the changelog does not over-promise.

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.

Acknowledged — follow-up filed as #350, PR body narrowed.

Reproduced on this branch: statement object ids for negctrl @ x q[0], q[1]; come back [4362757584, 4362758224, 4362757584], so positions 0 and 2 are the same object. Left out of scope here since it's pre-existing on main and lives in the modifier expansion, not decomposition.

The PR body now says "no statement emitted by a decomposition can share a node", with an explicit scope note pointing at #350.

Comment thread src/pyqasm/decomposer.py
qubits=qubits,
# copy so the emitted gates do not share operand nodes with each
# other or with the source statement (see #333)
qubits=fresh_qubits(*qubits),

@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] The #332 guard comment is now untrue — and the guard is still load-bearing — Maintenance · Low

Concerns the visited_node_ids guard in _remap_qubits, src/pyqasm/modules/base.py — not changed by this PR, hence the anchor here.

Rationale: the PR body describes that guard as "useful belt-and-braces" now that nodes are fresh. That undersells it, and the understatement is the risk — it invites a future reader to delete it as dead code. Removing the guard from this branch and re-running gives:

crz         : remove_idle_qubits OK          <- this PR did make the guard redundant here
negctrl @ x : remove_idle_qubits KeyError: 0 <- still the only thing holding this up
647 passed, 4 skipped                        <- no test would have caught it

So the guard is precisely what keeps remove_idle_qubits() working on the negctrl circuits from M2, and nothing in the suite protects it.

Change requested: update the comment above visited_node_ids to name the surviving case (statements re-inserted by the negctrl expansion), rather than "gate decompositions can reuse the same operand node" — which this PR has now made untrue. One line, but it is the difference between the next reader keeping it and removing it.

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 6620dbe — comment rewritten to name the surviving case.

Verified the guard is still load-bearing: remove_idle_qubits() on negctrl @ x passes only because of it. The comment now reads:

# the negctrl expansion in the visitor re-inserts the same QuantumGate object before
# and after the controlled gate, so a single index node can be reached more than once;
# track visited nodes so it is remapped exactly once

Cross-referenced from #350 so the two stay linked.

check_unrolled_qasm(dumps(module), expected_qasm3_str)


def _assert_no_shared_operand_nodes(module):

@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] Helper assumes an indexed operand shape — Maintenance · Low

Rationale: _assert_no_shared_operand_nodes does bit.indices[0][0] unconditionally, so it would raise on a bare-Identifier physical qubit or a DiscreteSet index rather than reporting a clean assertion failure. Not hit by the current parameters, and test_fresh_qubits_shares_no_nodes covers those shapes directly.

Change requested: none required. Worth a guard only if this helper gets reused for physical-qubit circuits later — noted so the limitation is known rather than rediscovered.

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 6620dbe — guard added, since it was cheap.

_assert_no_shared_operand_nodes still checks operand-node identity for every bit, but skips the index check for bare Identifier operands and non-plain index shapes rather than raising on bit.indices[0][0].

Comment thread tests/qasm3/test_transformations.py Outdated
Comment on lines +238 to +247
def test_rebase_emits_fresh_operand_nodes():
"""Test that rebase() never emits statements sharing operand nodes (issue #333)"""
qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
crz(0.5) q[1], q[2];
"""
module = loads(qasm3_str).rebase(BasisSet.ROTATIONAL_CX)
_assert_no_shared_operand_nodes(module)

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

[M1] This test does not reach the decomposer.py hunk — Implementation · Medium

Rationale: test_rebase_emits_fresh_operand_nodes uses crz, which is not in DECOMPOSITION_RULES (ROTATIONAL_CX covers x, y, z, h, s, t, sx, sdg, tdg, cz, swap). Instrumenting Decomposer._get_decomposed_gates confirms the test never reaches it:

crz  (the test in this PR) -> _get_decomposed_gates calls: []
swap (suggested)           -> _get_decomposed_gates calls: ['swap']

crz is handled entirely by the maps/gates.py constructors, so this rebase test is really a second unroll test. Consequence: reverting qubits=fresh_qubits(*qubits) back to qubits=qubits in decomposer.py, leaving gates.py intact, still leaves the entire suite passing — 647 passed, including this test. Half the diff is currently unpinned.

The hunk is genuinely fixing something, to be clear. On main, swap q[0], q[2] rebased to CLIFFORD_T emits three cx statements sharing both index nodes:

cx [('q', 0, 4345354976), ('q', 2, 4345355792)]
cx [('q', 2, 4345355792), ('q', 0, 4345354976)]
cx [('q', 0, 4345354976), ('q', 2, 4345355792)]

4 duplicate operand nodes and 4 duplicate index nodes on main, 0 on this branch. _get_qubits_for_gate hands back references straight into statement.qubits, so every rule-emitted gate aliases the source statement. The fix is correct; it just isn't tested.

Change requested: parametrize over a gate that actually reaches _get_decomposed_gates. The suggestion below was applied and run both ways — 3 passed with the fix in place, and reverting the decomposer.py line fails the swap and cz cases while crz still passes.

Suggested change
def test_rebase_emits_fresh_operand_nodes():
"""Test that rebase() never emits statements sharing operand nodes (issue #333)"""
qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
crz(0.5) q[1], q[2];
"""
module = loads(qasm3_str).rebase(BasisSet.ROTATIONAL_CX)
_assert_no_shared_operand_nodes(module)
@pytest.mark.parametrize(
"operation", ["crz(0.5) q[1], q[2];", "swap q[0], q[2];", "cz q[1], q[2];"]
)
def test_rebase_emits_fresh_operand_nodes(operation):
"""Test that rebase() never emits statements sharing operand nodes (issue #333)
``swap`` and ``cz`` are in DECOMPOSITION_RULES so they exercise
Decomposer._get_decomposed_gates; ``crz`` is handled by maps/gates.py instead.
"""
qasm3_str = f"""
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
{operation}
"""
module = loads(qasm3_str).rebase(BasisSet.ROTATIONAL_CX)
_assert_no_shared_operand_nodes(module)

Note for whoever applies it: don't reach for rebase(...) followed by reverse_qubit_order() as the regression test — the rebase is silently discarded before the remap runs, so that test would pass for the wrong reason.

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 6620dbe — suggestion applied as written.

Confirmed your finding before and after: with qubits=fresh_qubits(*qubits) reverted in decomposer.py, the swap and cz cases fail and crz still passes; with the fix in place all three pass. The hunk is pinned now.

- test_rebase_emits_fresh_operand_nodes now parametrizes over swap and cz,
  which reach Decomposer._get_decomposed_gates; crz alone never did, so the
  decomposer.py hunk was unpinned (M1)
- _remap_qubits guard comment now names the negctrl expansion, the case that
  still re-inserts the same statement object, rather than gate decompositions,
  which this PR made fresh (L1)
- _assert_no_shared_operand_nodes skips the index check for bare Identifier
  and non-plain index shapes instead of raising (L2)
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.

reverse_qubit_order() KeyError on aliased operand nodes (same root cause as #331)

3 participants