-
Notifications
You must be signed in to change notification settings - Fork 27
fix: emit fresh operand nodes from gate decompositions #335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c296368
e4293d6
53f9ca0
87f41d5
c0c3e8d
6620dbe
6eac4da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,16 +19,66 @@ | |
|
|
||
| """ | ||
|
|
||
| from copy import deepcopy | ||
| from typing import Callable | ||
|
|
||
| import numpy as np | ||
| from openqasm3.ast import FloatLiteral, Identifier, IndexedIdentifier, QuantumGate, QuantumPhase | ||
| from openqasm3.ast import ( | ||
| FloatLiteral, | ||
| Identifier, | ||
| IndexedIdentifier, | ||
| IntegerLiteral, | ||
| QuantumGate, | ||
| QuantumPhase, | ||
| ) | ||
|
|
||
| from pyqasm.elements import BasisSet, InversionOp | ||
| from pyqasm.exceptions import ValidationError, raise_qasm3_error | ||
| from pyqasm.linalg import kak_decomposition_angles | ||
| from pyqasm.maps.expressions import CONSTANTS_MAP | ||
|
|
||
| QubitOperand = IndexedIdentifier | Identifier | ||
|
|
||
|
|
||
| def _copy_qubit(qubit: QubitOperand) -> QubitOperand: | ||
| """Return a copy of a single qubit operand that shares no nodes with it. | ||
|
|
||
| A shallow ``copy`` is not enough: transforms rewrite the index in place as | ||
| ``bit.indices[0][0].value = ...``, so the nested ``IntegerLiteral`` has to | ||
| be a distinct object. Rebuilding the (small, fully known) node tree is an | ||
| order of magnitude cheaper than ``deepcopy``, so the general case is only | ||
| used as a fallback for operands that are not a plain ``reg[int]``. | ||
| """ | ||
| fresh: QubitOperand | ||
| 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) | ||
| fresh = IndexedIdentifier( | ||
| name=Identifier(name=qubit.name.name), | ||
| indices=[[IntegerLiteral(value=indices[0][0].value)]], | ||
| ) | ||
| fresh.span = qubit.span | ||
| return fresh | ||
|
|
||
|
|
||
| def fresh_qubits(*qubits: QubitOperand) -> list[QubitOperand]: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [M2] 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 negs = [qasm3_ast.QuantumGate([], qasm3_ast.Identifier("x"), [], [ctrl]) for ctrl in negctrls]
result = negs + result + negsThat places the same So Change requested: nothing in this PR. Open a follow-up for the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The PR body now says "no statement emitted by a decomposition can share a node", with an explicit scope note pointing at #350. |
||
| """Copy qubit operands so every emitted statement owns its nodes. | ||
|
|
||
| Decomposition functions pass the same operand nodes to each statement they | ||
| emit; without copies, transforms that later rewrite qubit indices in place | ||
| (e.g. ``remove_idle_qubits``, ``reverse_qubit_order``) would mutate a | ||
| shared node once per referencing statement. | ||
| """ | ||
| return [_copy_qubit(qubit) for qubit in qubits] | ||
|
|
||
|
|
||
| def u3_gate( | ||
| theta: int | float, | ||
|
|
@@ -113,7 +163,9 @@ def global_phase_gate(theta: float, qubit_list: list[IndexedIdentifier]) -> list | |
| """ | ||
| return [ | ||
| QuantumPhase( | ||
| argument=FloatLiteral(value=theta), qubits=qubit_list, modifiers=[] # type: ignore | ||
| argument=FloatLiteral(value=theta), | ||
| qubits=fresh_qubits(*qubit_list), # type: ignore | ||
| modifiers=[], | ||
| ) | ||
| ] | ||
|
|
||
|
|
@@ -702,7 +754,7 @@ def ccx_gate_op( | |
| modifiers=[], | ||
| name=Identifier(name="ccx"), | ||
| arguments=[], | ||
| qubits=[qubit0, qubit1, qubit2], | ||
| qubits=fresh_qubits(qubit0, qubit1, qubit2), | ||
| ) | ||
| ] | ||
|
|
||
|
|
@@ -905,7 +957,7 @@ def one_qubit_gate_op(gate_name: str, qubit_id: IndexedIdentifier) -> list[Quant | |
| modifiers=[], | ||
| name=Identifier(name=gate_name), | ||
| arguments=[], | ||
| qubits=[qubit_id], | ||
| qubits=fresh_qubits(qubit_id), | ||
| ) | ||
| ] | ||
|
|
||
|
|
@@ -918,7 +970,7 @@ def one_qubit_rotation_op( | |
| modifiers=[], | ||
| name=Identifier(name=gate_name), | ||
| arguments=[FloatLiteral(value=rotation)], | ||
| qubits=[qubit_id], | ||
| qubits=fresh_qubits(qubit_id), | ||
| ) | ||
| ] | ||
|
|
||
|
|
@@ -931,7 +983,7 @@ def two_qubit_gate_op( | |
| modifiers=[], | ||
| name=Identifier(name=gate_name.lower()), | ||
| arguments=[], | ||
| qubits=[qubit_id1, qubit_id2], | ||
| qubits=fresh_qubits(qubit_id1, qubit_id2), | ||
| ) | ||
| ] | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,7 +17,14 @@ | |
|
|
||
| """ | ||
|
|
||
| import pytest | ||
| from openqasm3.ast import DiscreteSet, Identifier, IndexedIdentifier, IntegerLiteral, Span | ||
|
|
||
| from pyqasm.analyzer import Qasm3Analyzer | ||
| from pyqasm.elements import BasisSet | ||
| from pyqasm.entrypoint import dumps, loads | ||
| from pyqasm.maps import QUANTUM_STATEMENTS | ||
| from pyqasm.maps.gates import fresh_qubits | ||
| from tests.utils import check_unrolled_qasm | ||
|
|
||
|
|
||
|
|
@@ -156,6 +163,139 @@ def test_reverse_qubit_order_qasm3(): | |
| check_unrolled_qasm(dumps(module), expected_qasm3_str) | ||
|
|
||
|
|
||
| def test_reverse_qubit_order_gate_decomposition(): | ||
| """Test reverse_qubit_order on a decomposed gate whose statements previously | ||
| shared operand nodes (issue #333)""" | ||
| qasm3_str = """ | ||
| OPENQASM 3.0; | ||
| include "stdgates.inc"; | ||
| qubit[3] q; | ||
| crz(0.5) q[1], q[2]; | ||
| """ | ||
|
|
||
| expected_qasm3_str = """ | ||
| OPENQASM 3.0; | ||
| include "stdgates.inc"; | ||
| qubit[3] q; | ||
| rz(0.25) q[0]; | ||
| rx(1.5707963267948966) q[0]; | ||
| rz(3.141592653589793) q[0]; | ||
| rx(1.5707963267948966) q[0]; | ||
| rz(3.141592653589793) q[0]; | ||
| cx q[1], q[0]; | ||
| rz(-0.25) q[0]; | ||
| rx(1.5707963267948966) q[0]; | ||
| rz(3.141592653589793) q[0]; | ||
| rx(1.5707963267948966) q[0]; | ||
| rz(3.141592653589793) q[0]; | ||
| cx q[1], q[0]; | ||
| """ | ||
|
|
||
| module = loads(qasm3_str) | ||
| module.unroll() | ||
| module.reverse_qubit_order() | ||
| check_unrolled_qasm(dumps(module), expected_qasm3_str) | ||
|
|
||
|
|
||
| def _assert_no_shared_operand_nodes(module): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [L2] Helper assumes an indexed operand shape — Maintenance · Low Rationale: 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolved in 6620dbe — guard added, since it was cheap.
|
||
| """Assert no two quantum statements in the unrolled AST share an operand node""" | ||
| seen_bits: set[int] = set() | ||
| seen_indices: set[int] = set() | ||
| for statement in module._unrolled_ast.statements: | ||
| if isinstance(statement, QUANTUM_STATEMENTS): | ||
| for bit in Qasm3Analyzer.get_op_bit_list(statement): | ||
| assert id(bit) not in seen_bits, f"operand node shared: {bit}" | ||
| seen_bits.add(id(bit)) | ||
| # physical qubits are bare Identifiers, and an index may be a DiscreteSet | ||
| # or multi-dimensional; only plain reg[int] operands have an index node | ||
| if not isinstance(bit, IndexedIdentifier): | ||
| continue | ||
| indices = bit.indices | ||
| if not ( | ||
| len(indices) == 1 and isinstance(indices[0], list) and len(indices[0]) == 1 | ||
| ): | ||
| continue | ||
| index_node = indices[0][0] | ||
| assert id(index_node) not in seen_indices, f"index node shared: {bit}" | ||
| seen_indices.add(id(index_node)) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "operation", | ||
| [ | ||
| "crz(0.5) q[1], q[2];", | ||
| "crx(0.5) q[0], q[1];", | ||
| "c4x q[0], q[1], q[2], q[3], q[4];", | ||
| "ecr q[0], q[1];", | ||
| "inv @ crz(0.5) q[1], q[2];", | ||
| ], | ||
| ) | ||
| def test_unroll_emits_fresh_operand_nodes(operation): | ||
| """Test that unroll() never emits statements sharing operand nodes, so in-place | ||
| transformations remap each operand exactly once (issues #331, #333)""" | ||
| qasm3_str = f""" | ||
| OPENQASM 3.0; | ||
| include "stdgates.inc"; | ||
| qubit[5] q; | ||
| {operation} | ||
| """ | ||
| module = loads(qasm3_str) | ||
| module.unroll() | ||
| _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) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "qubit", | ||
| [ | ||
| # plain reg[int] operand, rebuilt node by node | ||
| IndexedIdentifier(Identifier("q"), [[IntegerLiteral(2)]]), | ||
| # physical qubit, kept as a bare Identifier | ||
| Identifier("$0"), | ||
| # anything else falls back to a deep copy | ||
| IndexedIdentifier(Identifier("q"), [DiscreteSet([IntegerLiteral(0), IntegerLiteral(1)])]), | ||
| IndexedIdentifier(Identifier("q"), [[IntegerLiteral(0)], [IntegerLiteral(1)]]), | ||
| ], | ||
| ) | ||
| def test_fresh_qubits_shares_no_nodes(qubit): | ||
| """Test that fresh_qubits returns an equal operand that shares no node with the original""" | ||
| qubit.span = Span(1, 0, 1, 4) | ||
|
|
||
| (copied,) = fresh_qubits(qubit) | ||
|
|
||
| assert copied == qubit | ||
| assert copied.span == qubit.span | ||
| assert copied is not qubit | ||
| if isinstance(qubit, IndexedIdentifier): | ||
| assert copied.name is not qubit.name | ||
| for copied_dim, original_dim in zip(copied.indices, qubit.indices): | ||
| assert copied_dim is not original_dim | ||
| copied_exprs = copied_dim.values if isinstance(copied_dim, DiscreteSet) else copied_dim | ||
| original_exprs = ( | ||
| original_dim.values if isinstance(original_dim, DiscreteSet) else original_dim | ||
| ) | ||
| for copied_index, original_index in zip(copied_exprs, original_exprs): | ||
| assert copied_index is not original_index | ||
|
|
||
|
|
||
| def test_populate_idle_qubits_qasm3(): | ||
| """Test the populate idle qubits function for qasm3 string""" | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[L1] The
#332guard comment is now untrue — and the guard is still load-bearing — Maintenance · LowConcerns the
visited_node_idsguard 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:
So the guard is precisely what keeps
remove_idle_qubits()working on thenegctrlcircuits from M2, and nothing in the suite protects it.Change requested: update the comment above
visited_node_idsto name the surviving case (statements re-inserted by thenegctrlexpansion), 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.There was a problem hiding this comment.
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()onnegctrl @ xpasses only because of it. The comment now reads:Cross-referenced from #350 so the two stay linked.