-
Notifications
You must be signed in to change notification settings - Fork 27
fix: drop unroll-emitted global phase for QASM 2 targets #358
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
34693e1
d3c9e0e
1246c65
85620fe
3c87fab
1f7db55
2d66286
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 | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -96,12 +96,11 @@ def _filter_branch_body(self, statement: qasm3_ast.BranchingStatement) -> None: | |||||||||||||||||||||||||
| self._filter_branch_body(inner_stmt) | ||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||
| if isinstance(inner_stmt, qasm3_ast.QuantumPhase): | ||||||||||||||||||||||||||
| # not something the user wrote: rzz/rxx decompose to a global phase, so this | ||||||||||||||||||||||||||
| # is only reachable by re-filtering an already-unrolled body (see issue #351) | ||||||||||||||||||||||||||
| # unroll-emitted phases are dropped in accept() (issue #351), so only a | ||||||||||||||||||||||||||
| # user-written gphase reaches this | ||||||||||||||||||||||||||
| raise_qasm3_error( | ||||||||||||||||||||||||||
| "Global phase is not representable in QASM 2.0, so it cannot appear in " | ||||||||||||||||||||||||||
| "a conditional body; it is introduced by unrolling gates such as 'rzz' " | ||||||||||||||||||||||||||
| "and 'rxx'", | ||||||||||||||||||||||||||
| "a conditional body", | ||||||||||||||||||||||||||
| error_node=inner_stmt, | ||||||||||||||||||||||||||
| span=inner_stmt.span, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
@@ -148,6 +147,46 @@ def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module: | |||||||||||||||||||||||||
| qasm_program.version = "3.0" | ||||||||||||||||||||||||||
| return dumps(qasm_program) if as_str else Qasm3Module(self._name, qasm_program) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def finalize(self, statements: list[qasm3_ast.Statement]) -> list[qasm3_ast.Statement]: | ||||||||||||||||||||||||||
| """Apply the QASM 2 transformations the finalized statement list needs. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||
| statements (list[Statement]): The finalized statements. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||
| list[Statement]: The statements to store as the unrolled AST. | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| return self._drop_global_phase(statements) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _drop_global_phase( | ||||||||||||||||||||||||||
| self, statements: list[qasm3_ast.Statement] | ||||||||||||||||||||||||||
| ) -> list[qasm3_ast.Statement]: | ||||||||||||||||||||||||||
| """Remove QuantumPhase statements the unroller emitted (e.g. from the rzz/rxx | ||||||||||||||||||||||||||
| decompositions), descending into conditional bodies. OpenQASM 2 has no | ||||||||||||||||||||||||||
| global-phase syntax, and a global phase is unobservable, so dropping it is | ||||||||||||||||||||||||||
| semantically safe (issue #351).""" | ||||||||||||||||||||||||||
| filtered = [] | ||||||||||||||||||||||||||
| for stmt in statements: | ||||||||||||||||||||||||||
| if isinstance(stmt, qasm3_ast.QuantumPhase): | ||||||||||||||||||||||||||
| # a controlled phase is relative, not global, and is observable; the | ||||||||||||||||||||||||||
| # visitor rewrites those to 'p' gates, so none should reach here | ||||||||||||||||||||||||||
| if stmt.modifiers: | ||||||||||||||||||||||||||
| raise_qasm3_error( | ||||||||||||||||||||||||||
| "Modified global phase cannot be dropped for a QASM 2 target", | ||||||||||||||||||||||||||
| error_node=stmt, | ||||||||||||||||||||||||||
| span=stmt.span, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||
| if isinstance(stmt, qasm3_ast.BranchingStatement): | ||||||||||||||||||||||||||
| stmt.if_block = self._drop_global_phase(stmt.if_block) | ||||||||||||||||||||||||||
| stmt.else_block = self._drop_global_phase(stmt.else_block) | ||||||||||||||||||||||||||
| if not stmt.if_block and not stmt.else_block: | ||||||||||||||||||||||||||
| # the body was nothing but global phase, and QASM 2 has no | ||||||||||||||||||||||||||
| # form for a conditional without a qop | ||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||
| filtered.append(stmt) | ||||||||||||||||||||||||||
|
Comment on lines
+180
to
+187
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. Type: Implementation | Severity: Medium Rationale: The drop can empty a conditional body, and nothing then removes the now-bodiless branch. A gate whose body is nothing but a phase reaches this: On this branch that unrolls to Narrow reachability, granted — Change requested: Drop the branch when both blocks come back empty. Guarding on
Suggested change
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. Confirmed and applied in 85620fe. Your repro produces exactly Pinned as |
||||||||||||||||||||||||||
| return filtered | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def accept(self, visitor: QasmVisitor) -> None: | ||||||||||||||||||||||||||
| """Accept a visitor for the module. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -158,4 +197,4 @@ def accept(self, visitor: QasmVisitor) -> None: | |||||||||||||||||||||||||
| unrolled_stmt_list = visitor.visit_basic_block(self._statements) | ||||||||||||||||||||||||||
| final_stmt_list = visitor.finalize(unrolled_stmt_list) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| self.unrolled_ast.statements = final_stmt_list # type: ignore[assignment] | ||||||||||||||||||||||||||
| self.unrolled_ast.statements = self.finalize(final_stmt_list) # type: ignore[assignment] | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ | |
|
|
||
| import pytest | ||
|
|
||
| from pyqasm.entrypoint import loads | ||
| from pyqasm.entrypoint import dumps, loads | ||
| from pyqasm.exceptions import ValidationError | ||
|
|
||
| QASM2_PREAMBLE = """OPENQASM 2.0; | ||
|
|
@@ -64,15 +64,30 @@ def test_conditional_non_qop_rejected(operation, keyword): | |
| module.validate() | ||
|
|
||
|
|
||
| def test_conditional_global_phase_reports_global_phase(): | ||
| """Test that the QuantumPhase unrolling introduces for rzz/rxx is reported as global | ||
| phase rather than as an AST class name. Reachable only by re-filtering an already | ||
| unrolled body, which remove_idle_qubits/reverse_qubit_order do (issue #351).""" | ||
| def test_conditional_rzz_survives_refiltering(): | ||
| """Test that transformations which re-filter an already unrolled body no longer | ||
| trip over the rzz global phase: it is dropped for a QASM 2 target (issue #351)""" | ||
|
Comment on lines
+67
to
+69
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. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Add the test annotation and return documentation. Add As per coding guidelines, 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| module = loads(QASM2_PREAMBLE + "if(m==1) rzz(0.3) q[0], q[1];\n") | ||
| module.unroll() | ||
| module.reverse_qubit_order() | ||
| module.remove_idle_qubits() | ||
|
Comment on lines
+67
to
+73
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. Type: Maintenance | Severity: Medium Rationale: This PR edits the wording of the conditional-body The new Change requested: Keep this test as written, and add one alongside it for the rejection path, so the branch this PR reworded stays pinned: def test_conditional_user_gphase_rejected():
"""A gphase the user wrote in a conditional body is still rejected (issue #351)."""
module = loads(QASM2_PREAMBLE + "if(m==1) gphase(0.3);\n")
with pytest.raises(ValidationError, match="Global phase is not representable in QASM 2.0"):
module.validate()
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. Good catch — I reworded that diagnostic and deleted its only test in the same commit. Added |
||
|
|
||
|
|
||
| def test_conditional_user_gphase_rejected(): | ||
| """Test that a gphase the user wrote in a conditional body is still rejected. Only | ||
| unroll-emitted phases are dropped; this branch keeps its own diagnostic (issue #351)""" | ||
| module = loads(QASM2_PREAMBLE + "if(m==1) gphase(0.3);\n") | ||
| with pytest.raises(ValidationError, match="Global phase is not representable in QASM 2.0"): | ||
| module.remove_idle_qubits() | ||
| module.validate() | ||
|
|
||
|
|
||
| def test_conditional_emptied_by_phase_drop_is_removed(): | ||
| """Test that a conditional whose body was nothing but global phase is dropped rather | ||
| than emitted bodiless: QASM 2 has no form for an 'if' without a qop (issue #351)""" | ||
| module = loads(QASM2_PREAMBLE + "gate ph(t) a { gphase(t); }\nif(m==1) ph(0.3) q[1];\n") | ||
| module.unroll() | ||
| assert "if" not in dumps(module) | ||
| loads(dumps(module)).validate() | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,6 +67,115 @@ def test_whitelisted_ops(): | |
| check_unrolled_qasm(dumps(result), expected_qasm) | ||
|
|
||
|
|
||
| def test_rzz_unrolls_without_gphase(): | ||
| """Test that the global phase from the rzz decomposition is dropped for a QASM 2 | ||
| target, which has no global-phase syntax (issue #351)""" | ||
|
Comment on lines
+70
to
+72
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. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Add test function annotations and return documentation. Add As per coding guidelines, Also applies to: 98-100, 126-127, 146-148, 163-165 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| qasm2_string = """ | ||
| OPENQASM 2.0; | ||
| include 'qelib1.inc'; | ||
| qreg q[2]; | ||
| rzz(0.3) q[0], q[1]; | ||
| """ | ||
|
|
||
| expected_qasm = """ | ||
| OPENQASM 2.0; | ||
| include 'qelib1.inc'; | ||
| qreg q[2]; | ||
| cx q[0], q[1]; | ||
| rz(0.3) q[1]; | ||
| rx(1.5707963267948966) q[1]; | ||
| rz(3.141592653589793) q[1]; | ||
| rx(1.5707963267948966) q[1]; | ||
| rz(3.141592653589793) q[1]; | ||
| cx q[0], q[1]; | ||
| """ | ||
|
|
||
| result = loads(qasm2_string) | ||
| result.unroll() | ||
| check_unrolled_qasm(dumps(result), expected_qasm) | ||
|
|
||
|
|
||
| def test_rxx_unrolls_without_gphase(): | ||
| """Test that the global phase from the rxx decomposition is dropped for a QASM 2 | ||
| target (issue #351)""" | ||
| qasm2_string = """ | ||
| OPENQASM 2.0; | ||
| include 'qelib1.inc'; | ||
| qreg q[2]; | ||
| rxx(0.3) q[0], q[1]; | ||
| """ | ||
|
|
||
| expected_qasm = """ | ||
| OPENQASM 2.0; | ||
| include 'qelib1.inc'; | ||
| qreg q[2]; | ||
| h q[0]; | ||
| h q[1]; | ||
| cx q[0], q[1]; | ||
| rz(0.3) q[1]; | ||
| cx q[0], q[1]; | ||
| h q[1]; | ||
| h q[0]; | ||
| """ | ||
|
|
||
| result = loads(qasm2_string) | ||
| result.unroll() | ||
| check_unrolled_qasm(dumps(result), expected_qasm) | ||
|
|
||
|
|
||
| def test_conditional_rzz_unrolls_without_gphase(): | ||
| """Test that a conditional rzz body carries no gphase statement either (issue #351)""" | ||
| qasm2_string = """ | ||
| OPENQASM 2.0; | ||
| include 'qelib1.inc'; | ||
| qreg q[2]; | ||
| creg m[1]; | ||
| measure q[0] -> m[0]; | ||
| if(m==1) rzz(0.3) q[0], q[1]; | ||
| """ | ||
|
|
||
| result = loads(qasm2_string) | ||
| result.unroll() | ||
| unrolled = dumps(result) | ||
| assert "gphase" not in unrolled | ||
|
|
||
| # the unrolled output must still re-load in pyqasm. It is not yet accepted by a | ||
| # strict QASM 2 parser: the conditional still prints as `if (m[0] == true) { ... }`, | ||
| # which is QASM 3 syntax. That half is #338's territory, not this PR's. | ||
| loads(unrolled).validate() | ||
|
|
||
|
|
||
| def test_unrolled_qasm2_round_trips(): | ||
| """Test that unrolled rzz output loads and re-unrolls cleanly: no gphase means the | ||
| second filtering pass has nothing to reject (issue #351)""" | ||
| qasm2_string = """ | ||
| OPENQASM 2.0; | ||
| include 'qelib1.inc'; | ||
| qreg q[2]; | ||
| rzz(0.3) q[0], q[1]; | ||
| """ | ||
|
|
||
| result = loads(qasm2_string) | ||
| result.unroll() | ||
| round_tripped = loads(dumps(result)) | ||
| round_tripped.unroll() | ||
| check_unrolled_qasm(dumps(round_tripped), dumps(result)) | ||
|
|
||
|
|
||
| def test_user_written_gphase_rejected(): | ||
| """Test that a gphase statement written in QASM 2 source is still rejected -- | ||
| OpenQASM 2 has no global-phase syntax, so only unroller-introduced phases are dropped""" | ||
| qasm2_string = """ | ||
| OPENQASM 2.0; | ||
| include 'qelib1.inc'; | ||
| qreg q[2]; | ||
| gphase(0.3); | ||
| """ | ||
|
|
||
| with pytest.raises(ValidationError): | ||
| loads(qasm2_string).validate() | ||
|
|
||
|
|
||
| def test_subroutine_blacklist(): | ||
|
|
||
| # subroutines | ||
|
|
||
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.
Type: Maintenance | Severity: Low
Rationale: The drop keys on the node type alone. It is correct today, and that was confirmed rather than assumed:
_visit_phase_operationinvisitor.pyclears.modifiersand rewrites any controlled phase into apgate, so nothing carrying a control modifier ever arrives here. That invariant lives in another module, though, and nothing at this site records the dependency. Should it ever weaken, a controlled phase — a relative phase, and fully observable — would vanish with no error and no visible signal. Silent physical incorrectness is expensive to find later.Change requested: Assert the invariant instead of relying on it.
raise_qasm3_erroris already imported at line 26.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.
Applied in 85620fe. Fair point that the invariant lives in
_visit_phase_operationand nothing at this site records the dependency — a silently dropped relative phase is exactly the kind of thing that costs a day to find. Took the guard verbatim.