From 278f3942570f4f83eec2b2e4c2ab0a37ac108321 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 31 Jul 2026 12:46:47 -0500 Subject: [PATCH 1/6] test: cover OpenQASM 2 conditional serialization (#337) Failing tests for the qasm2 serializer emitting OpenQASM 3 syntax for classical conditionals: - braced if-blocks instead of `if (creg == int) `; - after `unroll()`, the per-bit chain the unroller ravels a register comparison into (`if (m[0] == true) { if (m[1] == false) { ... } }`), which additionally uses creg indexing and a `true` literal that OpenQASM 2 has no syntax for; - a body writing to the register under test, which cannot be expanded into one guarded statement per body statement without re-evaluating the condition mid-body. --- tests/qasm2/test_branching.py | 140 ++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/qasm2/test_branching.py diff --git a/tests/qasm2/test_branching.py b/tests/qasm2/test_branching.py new file mode 100644 index 0000000..4ae3424 --- /dev/null +++ b/tests/qasm2/test_branching.py @@ -0,0 +1,140 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Module containing unit tests for serializing classical conditionals as OpenQASM 2.0 + +""" + +import pytest + +from pyqasm.entrypoint import dumps, loads +from pyqasm.exceptions import ValidationError +from tests.utils import check_unrolled_qasm + + +def test_branch_emits_qasm2_syntax(): + """Test that a conditional round-trips as QASM 2 syntax rather than a braced + QASM 3 block (issue #337)""" + qasm2_string = """OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[1]; + creg c[2]; + h q[0]; + measure q[0] -> m[0]; + if(m==1) x q[1]; + measure q -> c; + """ + expected_qasm2_string = """OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[1]; + creg c[2]; + h q[0]; + measure q[0] -> m[0]; + if (m == 1) x q[1]; + measure q -> c; + """ + check_unrolled_qasm(dumps(loads(qasm2_string)), expected_qasm2_string) + + +def test_branch_body_expands_to_one_conditional_per_statement(): + """Test that a body which unrolls into several statements becomes one guarded + statement each, since QASM 2 has no braced blocks""" + qasm2_string = """OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[1]; + measure q[0] -> m[0]; + if(m==1) h q; + """ + expected_qasm2_string = """OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[1]; + measure q[0] -> m[0]; + if (m == 1) h q[0]; + if (m == 1) h q[1]; + """ + module = loads(qasm2_string) + module.unroll() + check_unrolled_qasm(dumps(module), expected_qasm2_string) + + +@pytest.mark.parametrize("value", [0, 1, 2, 3]) +def test_multibit_branch_survives_unrolling(value): + """Test that the per-bit conditionals unrolling produces for a multi-bit register + collapse back into the whole-register comparison QASM 2 requires""" + qasm2_string = f"""OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + creg m[2]; + measure q[0] -> m[0]; + measure q[1] -> m[1]; + if(m=={value}) x q[2]; + """ + expected_qasm2_string = f"""OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + creg m[2]; + measure q[0] -> m[0]; + measure q[1] -> m[1]; + if (m == {value}) x q[2]; + """ + module = loads(qasm2_string) + module.unroll() + check_unrolled_qasm(dumps(module), expected_qasm2_string) + + +@pytest.mark.parametrize( + "operation, expected", + [ + ("x q[1];", "if (m == 1) x q[1];"), + ("reset q[1];", "if (m == 1) reset q[1];"), + ("measure q[1] -> c[0];", "if (m == 1) measure q[1] -> c[0];"), + ], +) +def test_branch_body_statement_types(operation, expected): + """Test that every operation QASM 2 allows in a conditional body is emitted on the + same line as the condition""" + qasm2_string = f"""OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[1]; + creg c[1]; + measure q[0] -> m[0]; + if(m==1) {operation} + """ + module = loads(qasm2_string) + module.unroll() + unrolled = dumps(module) + assert "{" not in unrolled and "}" not in unrolled + assert expected in unrolled + + +def test_branch_body_writing_tested_register_raises(): + """Test that a multi-statement body assigning to the register under test is rejected + rather than emitted with the condition re-evaluated mid-body""" + qasm2_string = """OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[2]; + measure q[0] -> m[0]; + if(m==1) measure q -> m; + """ + module = loads(qasm2_string) + module.unroll() + with pytest.raises(ValidationError, match="writes to 'm' itself"): + dumps(module) From 5b27fb160849a17f8a33ba5436a4e1f5e886d5cc Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 31 Jul 2026 12:47:23 -0500 Subject: [PATCH 2/6] fix: emit OpenQASM 2 syntax for classical conditionals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Qasm2Module._qasm_ast_to_str` serialized with the stock openqasm3 printer and patched the result with regexes covering only declarations, so branching statements were emitted verbatim in OpenQASM 3 form under an `OPENQASM 2.0;` header. Downstream QASM 2 parsers reject the result. Adds `Qasm2Printer`, an `openqasm3.printer.Printer` subclass overriding `visit_BranchingStatement` to emit `if (creg == int) `: - a body that unrolled into several statements becomes one guarded statement each, QASM 2 having no braced blocks; - the nested per-bit chain `unroll()` ravels a register comparison into is walked back into that comparison, inverting the unroller's MSB-first bit ordering so `if(m==2)` round-trips as `if (m == 2)`; - branches QASM 2 cannot express — `else` blocks, nested `if`s, branches spanning registers, partial-register conditions (reachable via `>=` and `<=` unrolling) — raise a `ValidationError`. A multi-statement body is guarded per statement, which re-tests the register before each one. That is only faithful while the body leaves the register alone, so a body assigning to the register under test is rejected rather than silently given new semantics. Output verified against qiskit's OpenQASM 2 parser. --- CHANGELOG.md | 1 + src/pyqasm/modules/qasm2.py | 192 +++++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dac9966..e1fed4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Types of changes: ### Removed ### Fixed +- Fixed the OpenQASM 2 serializer emitting OpenQASM 3 syntax for classical conditionals while still declaring `OPENQASM 2.0`. `if(m==1) x q[1];` round-tripped as a braced `if (m == 1) { x q[1]; }` block, which QASM 2 parsers reject, so valid feed-forward programs were corrupted in-place. Unrolling made it worse: `unroll()` ravels a register comparison into a nested chain of per-bit tests (`if (m[0] == true) { if (m[1] == false) { ... } }`), which additionally uses creg indexing and a `true` literal that QASM 2 has no syntax for. Conditionals are now emitted as `if (creg == int) `, with the per-bit chain collapsed back into the whole-register comparison it came from and a body that unrolled into several statements expanded to one guarded statement each. Branches QASM 2 genuinely cannot express — `else` blocks, nested `if`s, partial-register conditions, or a multi-statement body that writes to the register under test — now raise a `ValidationError` instead of emitting invalid output. ([#337](https://github.com/qBraid/pyqasm/issues/337)) - Fixed `remove_idle_qubits()` raising `KeyError` when the unrolled AST contains operand nodes shared across multiple statements (e.g. the `crz` decomposition) and an idle lower-indexed qubit shifts the register indices. `_remap_qubits` now remaps each operand node exactly once instead of once per statement that references it. ([#332](https://github.com/qBraid/pyqasm/pull/332)) - Fixed `box` duration validation summing `delay` durations across all qubits instead of tracking each qubit's timeline. Delays on disjoint qubits run in parallel, so `box[300ns] { delay[200ns] q[0]; delay[200ns] q[1]; }` was rejected while the identical schedule written as a broadcast delay (`delay[200ns] q;`) was accepted. Delays are now accumulated per qubit and the box is validated against the busiest single timeline; the error message names the offending qubit. Nested boxes now also contribute their declared duration to the enclosing box's timelines (previously the accumulator was reset when an inner box closed, dropping all inner delay accounting). ([#330](https://github.com/qBraid/pyqasm/pull/330)) - Fixed `reset` on a physical qubit rewriting the operand to the internal pulse register, e.g. `reset $2;` unrolled to `reset __PYQASM_QUBITS__[2];`. That names a register the program never declares, so the unrolled output did not round-trip through `dumps()`/`loads()`, and the qubit was never registered (a program whose only operation was `reset $3;` reported `num_qubits == 0`). Physical qubits are now kept as-is in plain QASM programs, matching how gate and measurement operands already treat them; the rename is still applied for OpenPulse programs, where the pulse visitor expects it. ([#325](https://github.com/qBraid/pyqasm/pull/325)) diff --git a/src/pyqasm/modules/qasm2.py b/src/pyqasm/modules/qasm2.py index f4b0de9..a2d22fc 100644 --- a/src/pyqasm/modules/qasm2.py +++ b/src/pyqasm/modules/qasm2.py @@ -16,18 +16,203 @@ Defines a module for handling OpenQASM 2.0 programs. """ +import io import re from copy import deepcopy import openqasm3.ast as qasm3_ast from openqasm3.ast import Include, Program -from openqasm3.printer import dumps +from openqasm3.printer import Printer, PrinterState, dumps from pyqasm.exceptions import ValidationError from pyqasm.modules.base import QasmModule from pyqasm.modules.qasm3 import Qasm3Module +def _qasm3_repr(node: qasm3_ast.QASMNode) -> str: + """Render a node with the stock QASM 3 printer, for use in error messages.""" + out = io.StringIO() + Printer(out).visit(node) + return out.getvalue().strip() + + +def _creg_sizes(program: Program) -> dict[str, int]: + """Map each classical register in the program to its declared size.""" + sizes = {} + for statement in program.statements: + if isinstance(statement, qasm3_ast.ClassicalDeclaration) and isinstance( + statement.type, qasm3_ast.BitType + ): + size = statement.type.size + if size is None: + sizes[statement.identifier.name] = 1 + elif isinstance(size, qasm3_ast.IntegerLiteral): + sizes[statement.identifier.name] = size.value + return sizes + + +def _parse_branch_condition(condition: qasm3_ast.Expression) -> tuple[str, int | None, int]: + """Decompose a branch condition into ``(register name, bit index, value)``. + + ``bit index`` is ``None`` for a whole-register comparison (``c == 2``) and an + integer for the single-bit comparisons that unrolling produces (``c[0] == true``). + """ + if ( + not isinstance(condition, qasm3_ast.BinaryExpression) + or condition.op != qasm3_ast.BinaryOperator["=="] + ): + raise ValidationError( + f"Branch condition '{_qasm3_repr(condition)}' is not supported in QASM 2.0, " + "which only allows 'if (creg == int)'" + ) + + lhs, rhs = condition.lhs, condition.rhs + if not isinstance(rhs, (qasm3_ast.IntegerLiteral, qasm3_ast.BooleanLiteral)): + raise ValidationError( + f"Branch condition '{_qasm3_repr(condition)}' is not supported in QASM 2.0, " + "which only allows comparison against an integer literal" + ) + value = int(rhs.value) + + if isinstance(lhs, qasm3_ast.Identifier): + return lhs.name, None, value + + if ( + isinstance(lhs, qasm3_ast.IndexExpression) + and isinstance(lhs.collection, qasm3_ast.Identifier) + and isinstance(lhs.index, list) + and len(lhs.index) == 1 + and isinstance(lhs.index[0], qasm3_ast.IntegerLiteral) + ): + return lhs.collection.name, lhs.index[0].value, value + + raise ValidationError( + f"Branch condition '{_qasm3_repr(condition)}' is not supported in QASM 2.0, " + "which only allows 'if (creg == int)'" + ) + + +def _flatten_branch( + statement: qasm3_ast.BranchingStatement, creg_sizes: dict[str, int] +) -> tuple[str, int, list[qasm3_ast.Statement]]: + """Reduce a branching statement to the ``if (creg == int) `` form of QASM 2. + + Unrolling rewrites ``if (c == 2)`` into a chain of nested single-bit tests, one per + bit of ``c``; QASM 2 has no bit indexing in conditions and no nested ``if``, so the + chain is walked back into the whole-register comparison it came from. + """ + bit_values: dict[int, bool] = {} + reg_name = None + reg_value = None + current = statement + + while True: + if current.else_block: + raise ValidationError( + "'else' blocks are not supported in QASM 2.0, which only allows " + "'if (creg == int) '" + ) + + name, bit_index, value = _parse_branch_condition(current.condition) + if reg_name is not None and name != reg_name: + raise ValidationError( + f"Nested branches on different registers ('{reg_name}' and '{name}') are not " + "supported in QASM 2.0, which has no nested 'if' statements" + ) + reg_name = name + + if bit_index is None: + reg_value = value + else: + bit_values[bit_index] = bool(value) + + body = current.if_block + # unrolling nests one branch per bit, so keep walking while the body is + # nothing but the next branch in that chain + if len(body) == 1 and isinstance(body[0], qasm3_ast.BranchingStatement): + current = body[0] + continue + break + + assert reg_name is not None + if any(isinstance(stmt, qasm3_ast.BranchingStatement) for stmt in body): + raise ValidationError( + f"Nested 'if' statements inside the body of a branch on '{reg_name}' are not " + "supported in QASM 2.0" + ) + + if bit_values: + if reg_value is not None: + raise ValidationError( + f"Branch on '{reg_name}' mixes whole-register and single-bit comparisons, " + "which is not supported in QASM 2.0" + ) + size = creg_sizes.get(reg_name) + if size is None: + raise ValidationError( + f"Missing declaration for classical register '{reg_name}' used in a branch" + ) + if set(bit_values) != set(range(size)): + missing = sorted(set(range(size)) - set(bit_values)) + raise ValidationError( + f"Branch on '{reg_name}' constrains only bits {sorted(bit_values)} of a " + f"{size}-bit register (bits {missing} are unconstrained); QASM 2.0 can only " + "compare a classical register against an integer" + ) + # unrolling ravels the comparison value MSB-first, so invert that ordering here + reg_value = sum(1 << (size - 1 - index) for index, set_ in bit_values.items() if set_) + + assert reg_value is not None + + # a multi-statement body is emitted as one guarded statement each, which re-tests the + # register before every statement; that is only faithful while the body leaves it alone + if len(body) > 1 and any(_writes_to_register(stmt, reg_name) for stmt in body): + raise ValidationError( + f"Branch on '{reg_name}' has a body that writes to '{reg_name}' itself. QASM 2.0 " + "guards a single statement per 'if', so the multi-statement body cannot be emitted " + "without re-evaluating the condition mid-body and changing the program's meaning" + ) + + return reg_name, reg_value, body + + +def _writes_to_register(statement: qasm3_ast.Statement, reg_name: str) -> bool: + """Whether ``statement`` assigns to any bit of the classical register ``reg_name``.""" + if isinstance(statement, qasm3_ast.QuantumMeasurementStatement): + target = statement.target + if isinstance(target, qasm3_ast.IndexedIdentifier): + return target.name.name == reg_name + if isinstance(target, qasm3_ast.Identifier): + return target.name == reg_name + return False + + +class Qasm2Printer(Printer): + """``openqasm3`` printer that emits OpenQASM 2.0 branching syntax. + + OpenQASM 2.0 has no braced blocks: a conditional is ``if (creg == int) `` + with exactly one statement and no ``else``. The base printer always emits the QASM 3 + braced form, which downstream QASM 2 parsers reject. + """ + + def __init__(self, *args, creg_sizes: dict[str, int] | None = None, **kwargs): + super().__init__(*args, **kwargs) + self._creg_sizes = creg_sizes or {} + + def visit_BranchingStatement( # pylint: disable=invalid-name + self, node: qasm3_ast.BranchingStatement, context: PrinterState + ) -> None: + reg_name, reg_value, body = _flatten_branch(node, self._creg_sizes) + + # a single QASM 2 conditional guards a single statement, so a body that + # unrolled into several statements becomes one guarded statement each + for statement in body: + self._start_line(context) + self.stream.write(f"if ({reg_name} == {reg_value}) ") + context.skip_next_indent = True + self.visit(statement, context) + + class Qasm2Module(QasmModule): """ A module representing an openqasm2 quantum program. @@ -74,8 +259,9 @@ def _qasm_ast_to_str(self, qasm_ast): """Convert the qasm AST to a string""" # set the version to 2.0 qasm_ast.version = "2.0" - raw_qasm = dumps(qasm_ast, old_measurement=True) - return self._format_declarations(raw_qasm) + stream = io.StringIO() + Qasm2Printer(stream, old_measurement=True, creg_sizes=_creg_sizes(qasm_ast)).visit(qasm_ast) + return self._format_declarations(stream.getvalue()) def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module: """Convert the module to openqasm3 format From 65984ac14b8e743a870afd8af9759cf8adeb910f Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 31 Jul 2026 13:52:07 -0500 Subject: [PATCH 3/6] test: cover conflicting chain constraints and inexpressible branches Review feedback on #338. The chain walk in _flatten_branch collapses nested branches into one comparison, but nothing checks that the constraints it collects can coexist. Three shapes are silently reduced to whichever constraint is walked last, emitting a valid-looking QASM 2 program that triggers on different values than the source: if(m==1) if(m==0) x q[0]; -> if (m == 0) x q[0]; if(m[0]==1) if(m[0]==0) x q[0]; -> if (m == 0) x q[0]; if(m[0]==2) x q[0]; -> if (m == 1) x q[0]; The first drops the outer condition entirely, so the PR's claim that nested ifs raise is false for a chain of them. Also adds coverage for the four rejection paths the PR does introduce but never exercised -- else blocks, cross-register nesting, partial-register conditions and non-equality operators -- which passed already and now stay pinned. --- tests/qasm2/test_branching.py | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/qasm2/test_branching.py b/tests/qasm2/test_branching.py index 4ae3424..e0d3bb1 100644 --- a/tests/qasm2/test_branching.py +++ b/tests/qasm2/test_branching.py @@ -138,3 +138,53 @@ def test_branch_body_writing_tested_register_raises(): module.unroll() with pytest.raises(ValidationError, match="writes to 'm' itself"): dumps(module) + + +@pytest.mark.parametrize( + "operation, match", + [ + # two whole-register comparisons in a chain: the outer constraint has nowhere + # to go in the single comparison QASM 2 allows + ("if(m==1) if(m==0) x q[0];", "nests another whole-register comparison"), + # the same bit constrained twice, ditto + ("if(m[0]==1) if(m[0]==0) x q[0];", "tests bit 0 more than once"), + # a bit compared against something that is not a bit value + ("if(m[0]==2) x q[0];", "against 2"), + ], +) +def test_conflicting_chain_constraints_raise(operation, match): + """Test that a chain carrying constraints the collapse cannot represent is rejected + rather than silently reduced to whichever constraint happens to be walked last""" + qasm2_string = f"""OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[1]; + measure q[0] -> m[0]; + {operation} + """ + with pytest.raises(ValidationError, match=match): + dumps(loads(qasm2_string)) + + +@pytest.mark.parametrize( + "operation, match", + [ + ("if(m==1) x q[0]; else x q[1];", "'else' blocks are not supported"), + ("if(m==1) if(c==1) x q[0];", "different registers"), + ("if(m[0]==1) x q[0];", "unconstrained"), + ("if(m>=1) x q[0];", "only allows 'if \\(creg == int\\)'"), + ], +) +def test_inexpressible_branches_raise(operation, match): + """Test that each branch shape QASM 2 has no syntax for is rejected. Emitting these + would drop a branch or silently change which values trigger the body.""" + qasm2_string = f"""OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[2]; + creg c[1]; + measure q[0] -> m[0]; + {operation} + """ + with pytest.raises(ValidationError, match=match): + dumps(loads(qasm2_string)) From 14858df610821de8561e720c2003403503c2722e Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 31 Jul 2026 13:53:37 -0500 Subject: [PATCH 4/6] fix: reject chain constraints the collapse cannot represent Review feedback on #338. The chain walk collected each nested branch's constraint but never checked they could coexist, so three shapes were silently reduced to whichever constraint happened to be walked last and emitted as a valid-looking QASM 2 program triggering on different values: if(m==1) if(m==0) x q[0]; -> if (m == 0) x q[0]; if(m[0]==1) if(m[0]==0) x q[0]; -> if (m == 0) x q[0]; if(m[0]==2) x q[0]; -> if (m == 1) x q[0]; The first dropped the outer condition outright, so the guard that was supposed to reject nested ifs never fired for a chain of them -- the walk consumed the nesting before the check could see it. Each is now rejected where the constraint is folded in. The legitimate case the collapse exists for, a per-bit chain from unrolling, is unaffected: those carry distinct bit indices and boolean values. Constraint folding is extracted to _add_chain_constraint to keep _flatten_branch within the branch limit. --- CHANGELOG.md | 2 +- src/pyqasm/modules/qasm2.py | 41 ++++++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1fed4c..97b5bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Types of changes: ### Removed ### Fixed -- Fixed the OpenQASM 2 serializer emitting OpenQASM 3 syntax for classical conditionals while still declaring `OPENQASM 2.0`. `if(m==1) x q[1];` round-tripped as a braced `if (m == 1) { x q[1]; }` block, which QASM 2 parsers reject, so valid feed-forward programs were corrupted in-place. Unrolling made it worse: `unroll()` ravels a register comparison into a nested chain of per-bit tests (`if (m[0] == true) { if (m[1] == false) { ... } }`), which additionally uses creg indexing and a `true` literal that QASM 2 has no syntax for. Conditionals are now emitted as `if (creg == int) `, with the per-bit chain collapsed back into the whole-register comparison it came from and a body that unrolled into several statements expanded to one guarded statement each. Branches QASM 2 genuinely cannot express — `else` blocks, nested `if`s, partial-register conditions, or a multi-statement body that writes to the register under test — now raise a `ValidationError` instead of emitting invalid output. ([#337](https://github.com/qBraid/pyqasm/issues/337)) +- Fixed the OpenQASM 2 serializer emitting OpenQASM 3 syntax for classical conditionals while still declaring `OPENQASM 2.0`. `if(m==1) x q[1];` round-tripped as a braced `if (m == 1) { x q[1]; }` block, which QASM 2 parsers reject, so valid feed-forward programs were corrupted in-place. Unrolling made it worse: `unroll()` ravels a register comparison into a nested chain of per-bit tests (`if (m[0] == true) { if (m[1] == false) { ... } }`), which additionally uses creg indexing and a `true` literal that QASM 2 has no syntax for. Conditionals are now emitted as `if (creg == int) `, with the per-bit chain collapsed back into the whole-register comparison it came from and a body that unrolled into several statements expanded to one guarded statement each. Branches QASM 2 genuinely cannot express — `else` blocks, nested `if`s, conditions spanning registers or constraining only part of one, non-equality operators, a bit compared against a non-bit value, or a multi-statement body that writes to the register under test — now raise a `ValidationError` instead of emitting output that is invalid or triggers on different values than the source. ([#337](https://github.com/qBraid/pyqasm/issues/337)) - Fixed `remove_idle_qubits()` raising `KeyError` when the unrolled AST contains operand nodes shared across multiple statements (e.g. the `crz` decomposition) and an idle lower-indexed qubit shifts the register indices. `_remap_qubits` now remaps each operand node exactly once instead of once per statement that references it. ([#332](https://github.com/qBraid/pyqasm/pull/332)) - Fixed `box` duration validation summing `delay` durations across all qubits instead of tracking each qubit's timeline. Delays on disjoint qubits run in parallel, so `box[300ns] { delay[200ns] q[0]; delay[200ns] q[1]; }` was rejected while the identical schedule written as a broadcast delay (`delay[200ns] q;`) was accepted. Delays are now accumulated per qubit and the box is validated against the busiest single timeline; the error message names the offending qubit. Nested boxes now also contribute their declared duration to the enclosing box's timelines (previously the accumulator was reset when an inner box closed, dropping all inner delay accounting). ([#330](https://github.com/qBraid/pyqasm/pull/330)) - Fixed `reset` on a physical qubit rewriting the operand to the internal pulse register, e.g. `reset $2;` unrolled to `reset __PYQASM_QUBITS__[2];`. That names a register the program never declares, so the unrolled output did not round-trip through `dumps()`/`loads()`, and the qubit was never registered (a program whose only operation was `reset $3;` reported `num_qubits == 0`). Physical qubits are now kept as-is in plain QASM programs, matching how gate and measurement operands already treat them; the rename is still applied for OpenPulse programs, where the pulse visitor expects it. ([#325](https://github.com/qBraid/pyqasm/pull/325)) diff --git a/src/pyqasm/modules/qasm2.py b/src/pyqasm/modules/qasm2.py index a2d22fc..407f334 100644 --- a/src/pyqasm/modules/qasm2.py +++ b/src/pyqasm/modules/qasm2.py @@ -92,6 +92,41 @@ def _parse_branch_condition(condition: qasm3_ast.Expression) -> tuple[str, int | ) +def _add_chain_constraint( + reg_name: str, + bit_index: int | None, + value: int, + reg_value: int | None, + bit_values: dict[int, bool], +) -> int | None: + """Fold one link of a branch chain into the constraints collected so far. + + Every link's constraint has to survive into the single comparison QASM 2 allows, + so a link that contradicts or duplicates an earlier one has nowhere to go and must + not be quietly dropped by the collapse in :func:`_flatten_branch`. + """ + if bit_index is None: + if reg_value is not None: + raise ValidationError( + f"Branch on '{reg_name}' nests another whole-register comparison, " + "which is not supported in QASM 2.0" + ) + return value + + if bit_index in bit_values: + raise ValidationError( + f"Branch on '{reg_name}' tests bit {bit_index} more than once, " + "which is not supported in QASM 2.0" + ) + if value not in (0, 1): + raise ValidationError( + f"Branch on '{reg_name}' compares bit {bit_index} against {value}; " + "a single bit can only be compared against 0 or 1" + ) + bit_values[bit_index] = bool(value) + return reg_value + + def _flatten_branch( statement: qasm3_ast.BranchingStatement, creg_sizes: dict[str, int] ) -> tuple[str, int, list[qasm3_ast.Statement]]: @@ -120,11 +155,7 @@ def _flatten_branch( "supported in QASM 2.0, which has no nested 'if' statements" ) reg_name = name - - if bit_index is None: - reg_value = value - else: - bit_values[bit_index] = bool(value) + reg_value = _add_chain_constraint(reg_name, bit_index, value, reg_value, bit_values) body = current.if_block # unrolling nests one branch per bit, so keep walking while the body is From 2c153eae5c4f3e60b65a69b9c32fc0e009702255 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Wed, 5 Aug 2026 08:07:28 -0500 Subject: [PATCH 5/6] review: validate branch shapes at source, preserve register-level measurement - Qasm2Module._filter_statements now rejects 'else', nested 'if' and non 'creg == int' conditions, so validate() reports them instead of leaving serialization to raise on a program already reported as valid (M1) - the diagnostic names the operator the user wrote, which is only possible before unrolling ravels it into a chain of equality tests (L1) - a branch guarding 'measure qreg -> creg' is reassembled from the per-bit statements unrolling expanded it into, so it is emitted as written instead of rejected; removes the unroll-dependent asymmetry (M2) - _qasm_ast_to_str documents that it can raise ValidationError, and the coupling to openqasm3 printer internals is noted where it is used, with brace assertions extended across the emission tests (L2) --- src/pyqasm/modules/qasm2.py | 169 +++++++++++++++++++++++++++++++--- tests/qasm2/test_branching.py | 73 +++++++++++++-- 2 files changed, 222 insertions(+), 20 deletions(-) diff --git a/src/pyqasm/modules/qasm2.py b/src/pyqasm/modules/qasm2.py index 407f334..8189dab 100644 --- a/src/pyqasm/modules/qasm2.py +++ b/src/pyqasm/modules/qasm2.py @@ -24,7 +24,7 @@ from openqasm3.ast import Include, Program from openqasm3.printer import Printer, PrinterState, dumps -from pyqasm.exceptions import ValidationError +from pyqasm.exceptions import ValidationError, raise_qasm3_error from pyqasm.modules.base import QasmModule from pyqasm.modules.qasm3 import Qasm3Module @@ -51,6 +51,82 @@ def _creg_sizes(program: Program) -> dict[str, int]: return sizes +def _qreg_sizes(program: Program) -> dict[str, int]: + """Map each quantum register in the program to its declared size.""" + sizes = {} + for statement in program.statements: + if isinstance(statement, qasm3_ast.QubitDeclaration): + size = statement.size + sizes[statement.qubit.name] = ( + size.value if isinstance(size, qasm3_ast.IntegerLiteral) else 1 + ) + return sizes + + +def _single_index(operand: qasm3_ast.QASMNode) -> tuple[str, int] | None: + """Decompose ``reg[i]`` into ``(reg, i)``, or ``None`` for any other operand shape.""" + if ( + isinstance(operand, qasm3_ast.IndexedIdentifier) + and len(operand.indices) == 1 + and isinstance(operand.indices[0], list) + and len(operand.indices[0]) == 1 + and isinstance(operand.indices[0][0], qasm3_ast.IntegerLiteral) + ): + return operand.name.name, operand.indices[0][0].value + return None + + +def _collapse_broadcast_measurement( + body: list[qasm3_ast.Statement], creg_sizes: dict[str, int], qreg_sizes: dict[str, int] +) -> qasm3_ast.QuantumMeasurementStatement | None: + """Rebuild ``measure qreg -> creg`` from the per-bit statements unrolling expanded it into. + + A register-level measurement is one QASM 2.0 statement, so a branch guarding it is + expressible as written. Emitting the expansion instead would need one ``if`` per bit, + re-testing the register the body writes; see the hazard check in :func:`_flatten_branch`. + Returns ``None`` unless the body is exactly a full, in-order broadcast. + """ + pairs = [] + for statement in body: + if not isinstance(statement, qasm3_ast.QuantumMeasurementStatement): + return None + qubit = _single_index(statement.measure.qubit) + target = _single_index(statement.target) + if qubit is None or target is None: + return None + pairs.append((qubit, target)) + + (qreg, _), (creg, _) = pairs[0] + size = len(pairs) + if qreg_sizes.get(qreg) != size or creg_sizes.get(creg) != size: + return None + if pairs != [((qreg, index), (creg, index)) for index in range(size)]: + return None + + return qasm3_ast.QuantumMeasurementStatement( + measure=qasm3_ast.QuantumMeasurement(qubit=qasm3_ast.Identifier(name=qreg)), + target=qasm3_ast.Identifier(name=creg), + ) + + +def _unsupported_condition_message(condition: qasm3_ast.Expression) -> str: + """Describe why ``condition`` has no QASM 2.0 form, naming the operator when there is one. + + Only reachable before unrolling: the unroller ravels ``m >= 1`` into a chain of + equality tests, so by then the operator the user wrote is gone. + """ + operator = ( + f", which uses '{condition.op.name}'," + if isinstance(condition, qasm3_ast.BinaryExpression) + and condition.op != qasm3_ast.BinaryOperator["=="] + else "" + ) + return ( + f"Branch condition '{_qasm3_repr(condition)}'{operator} is not supported in QASM 2.0, " + "which only allows 'if (creg == int)'" + ) + + def _parse_branch_condition(condition: qasm3_ast.Expression) -> tuple[str, int | None, int]: """Decompose a branch condition into ``(register name, bit index, value)``. @@ -61,10 +137,7 @@ def _parse_branch_condition(condition: qasm3_ast.Expression) -> tuple[str, int | not isinstance(condition, qasm3_ast.BinaryExpression) or condition.op != qasm3_ast.BinaryOperator["=="] ): - raise ValidationError( - f"Branch condition '{_qasm3_repr(condition)}' is not supported in QASM 2.0, " - "which only allows 'if (creg == int)'" - ) + raise ValidationError(_unsupported_condition_message(condition)) lhs, rhs = condition.lhs, condition.rhs if not isinstance(rhs, (qasm3_ast.IntegerLiteral, qasm3_ast.BooleanLiteral)): @@ -128,7 +201,9 @@ def _add_chain_constraint( def _flatten_branch( - statement: qasm3_ast.BranchingStatement, creg_sizes: dict[str, int] + statement: qasm3_ast.BranchingStatement, + creg_sizes: dict[str, int], + qreg_sizes: dict[str, int], ) -> tuple[str, int, list[qasm3_ast.Statement]]: """Reduce a branching statement to the ``if (creg == int) `` form of QASM 2. @@ -195,13 +270,21 @@ def _flatten_branch( assert reg_value is not None + if len(body) > 1: + # a register-level measurement survives unrolling as one statement per bit; put it + # back together so the branch is emitted as the single statement it was written as + collapsed = _collapse_broadcast_measurement(body, creg_sizes, qreg_sizes) + if collapsed is not None: + body = [collapsed] + # a multi-statement body is emitted as one guarded statement each, which re-tests the # register before every statement; that is only faithful while the body leaves it alone if len(body) > 1 and any(_writes_to_register(stmt, reg_name) for stmt in body): raise ValidationError( - f"Branch on '{reg_name}' has a body that writes to '{reg_name}' itself. QASM 2.0 " - "guards a single statement per 'if', so the multi-statement body cannot be emitted " - "without re-evaluating the condition mid-body and changing the program's meaning" + f"Branch on '{reg_name}' has a body of {len(body)} statements, at least one of " + f"which writes to '{reg_name}' -- the register the branch tests. QASM 2.0 guards a " + "single statement per 'if', so emitting one guard per statement would re-evaluate " + "the condition mid-body and change the program's meaning" ) return reg_name, reg_value, body @@ -226,18 +309,28 @@ class Qasm2Printer(Printer): braced form, which downstream QASM 2 parsers reject. """ - def __init__(self, *args, creg_sizes: dict[str, int] | None = None, **kwargs): + def __init__( + self, + *args, + creg_sizes: dict[str, int] | None = None, + qreg_sizes: dict[str, int] | None = None, + **kwargs, + ): super().__init__(*args, **kwargs) self._creg_sizes = creg_sizes or {} + self._qreg_sizes = qreg_sizes or {} def visit_BranchingStatement( # pylint: disable=invalid-name self, node: qasm3_ast.BranchingStatement, context: PrinterState ) -> None: - reg_name, reg_value, body = _flatten_branch(node, self._creg_sizes) + reg_name, reg_value, body = _flatten_branch(node, self._creg_sizes, self._qreg_sizes) # a single QASM 2 conditional guards a single statement, so a body that # unrolled into several statements becomes one guarded statement each for statement in body: + # `_start_line` and `skip_next_indent` are openqasm3 printer internals, so an + # upstream change could alter emission silently; the brace assertions in + # tests/qasm2/test_branching.py are what turn that into a test failure self._start_line(context) self.stream.write(f"if ({reg_name} == {reg_value}) ") context.skip_next_indent = True @@ -276,8 +369,47 @@ def _filter_statements(self): stmt_type = type(stmt) if stmt_type not in self._whitelist_statements: raise ValidationError(f"Statement of type {stmt_type} not supported in QASM 2.0") + if isinstance(stmt, qasm3_ast.BranchingStatement): + self._filter_branch(stmt) # TODO: add more filtering here if needed + def _filter_branch(self, statement: qasm3_ast.BranchingStatement) -> None: + """Reject the conditional shapes QASM 2.0 has no syntax for, before unrolling. + + These are properties of the source program, so they are caught here rather than + at serialization: unrolling rewrites a register comparison into a chain of + per-bit tests, and by then the operator and nesting the user wrote are gone. + :class:`Qasm2Printer` re-checks what survives into the unrolled AST, since + callers may serialize a module they never validated. + """ + if statement.else_block: + raise_qasm3_error( + "'else' blocks are not supported in QASM 2.0, which only allows " + "'if (creg == int) '", + error_node=statement, + span=statement.span, + ) + for inner_stmt in statement.if_block: + if isinstance(inner_stmt, qasm3_ast.BranchingStatement): + raise_qasm3_error( + "Nested 'if' statements are not supported in QASM 2.0, which guards a " + "single statement per conditional", + error_node=inner_stmt, + span=inner_stmt.span, + ) + condition = statement.condition + if ( + not isinstance(condition, qasm3_ast.BinaryExpression) + or condition.op != qasm3_ast.BinaryOperator["=="] + or not isinstance(condition.lhs, qasm3_ast.Identifier) + or not isinstance(condition.rhs, qasm3_ast.IntegerLiteral) + ): + raise_qasm3_error( + _unsupported_condition_message(condition), + error_node=statement, + span=condition.span, + ) + def _format_declarations(self, qasm_str): """Format the unrolled qasm for declarations in openqasm 2.0 format""" for declaration_type, replacement_type in [("qubit", "qreg"), ("bit", "creg")]: @@ -287,11 +419,22 @@ def _format_declarations(self, qasm_str): return qasm_str def _qasm_ast_to_str(self, qasm_ast): - """Convert the qasm AST to a string""" + """Convert the qasm AST to a string + + Raises: + ValidationError: If the program contains a conditional QASM 2.0 has no syntax + for. :meth:`validate` rejects these shapes as written, but a caller may + serialize a module it never validated, or one whose AST it built directly. + """ # set the version to 2.0 qasm_ast.version = "2.0" stream = io.StringIO() - Qasm2Printer(stream, old_measurement=True, creg_sizes=_creg_sizes(qasm_ast)).visit(qasm_ast) + Qasm2Printer( + stream, + old_measurement=True, + creg_sizes=_creg_sizes(qasm_ast), + qreg_sizes=_qreg_sizes(qasm_ast), + ).visit(qasm_ast) return self._format_declarations(stream.getvalue()) def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module: diff --git a/tests/qasm2/test_branching.py b/tests/qasm2/test_branching.py index e0d3bb1..95bf4c7 100644 --- a/tests/qasm2/test_branching.py +++ b/tests/qasm2/test_branching.py @@ -47,7 +47,9 @@ def test_branch_emits_qasm2_syntax(): if (m == 1) x q[1]; measure q -> c; """ - check_unrolled_qasm(dumps(loads(qasm2_string)), expected_qasm2_string) + unrolled = dumps(loads(qasm2_string)) + assert "{" not in unrolled and "}" not in unrolled + check_unrolled_qasm(unrolled, expected_qasm2_string) def test_branch_body_expands_to_one_conditional_per_statement(): @@ -70,7 +72,9 @@ def test_branch_body_expands_to_one_conditional_per_statement(): """ module = loads(qasm2_string) module.unroll() - check_unrolled_qasm(dumps(module), expected_qasm2_string) + unrolled = dumps(module) + assert "{" not in unrolled and "}" not in unrolled + check_unrolled_qasm(unrolled, expected_qasm2_string) @pytest.mark.parametrize("value", [0, 1, 2, 3]) @@ -95,7 +99,9 @@ def test_multibit_branch_survives_unrolling(value): """ module = loads(qasm2_string) module.unroll() - check_unrolled_qasm(dumps(module), expected_qasm2_string) + unrolled = dumps(module) + assert "{" not in unrolled and "}" not in unrolled + check_unrolled_qasm(unrolled, expected_qasm2_string) @pytest.mark.parametrize( @@ -124,9 +130,9 @@ def test_branch_body_statement_types(operation, expected): assert expected in unrolled -def test_branch_body_writing_tested_register_raises(): - """Test that a multi-statement body assigning to the register under test is rejected - rather than emitted with the condition re-evaluated mid-body""" +def test_branch_on_register_level_measurement_round_trips(): + """Test that a branch guarding a whole-register measurement is emitted as written, + whether or not unrolling expanded it into one statement per bit""" qasm2_string = """OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; @@ -134,9 +140,33 @@ def test_branch_body_writing_tested_register_raises(): measure q[0] -> m[0]; if(m==1) measure q -> m; """ + expected_qasm2_string = """OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[2]; + measure q[0] -> m[0]; + if (m == 1) measure q -> m; + """ + module = loads(qasm2_string) + module.unroll() + check_unrolled_qasm(dumps(module), expected_qasm2_string) + # the non-unrolled path never expanded the measurement, so the two must agree + check_unrolled_qasm(dumps(loads(qasm2_string)), expected_qasm2_string) + + +def test_branch_body_writing_tested_register_raises(): + """Test that a genuinely multi-statement body assigning to the register under test is + rejected rather than emitted with the condition re-evaluated mid-body""" + qasm2_string = """OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[2]; + measure q[0] -> m[0]; + if(m==1) { x q[0]; measure q[0] -> m[0]; } + """ module = loads(qasm2_string) module.unroll() - with pytest.raises(ValidationError, match="writes to 'm' itself"): + with pytest.raises(ValidationError, match="writes to 'm' -- the register the branch tests"): dumps(module) @@ -188,3 +218,32 @@ def test_inexpressible_branches_raise(operation, match): """ with pytest.raises(ValidationError, match=match): dumps(loads(qasm2_string)) + + +@pytest.mark.parametrize( + "operation, match", + [ + ("if(m==1) x q[0]; else x q[1];", "'else' blocks are not supported"), + ("if(m==1) if(c==1) x q[0];", "Nested 'if' statements are not supported"), + ("if(m==1) if(m==0) x q[0];", "Nested 'if' statements are not supported"), + ("if(m[0]==1) x q[0];", "only allows 'if \\(creg == int\\)'"), + # the operator has to be named here: unrolling ravels '>=' into a chain of + # equality tests, so by serialization time there is nothing left to name + ("if(m>=1) x q[0];", "which uses '>='"), + ("if(m<2) x q[0];", "which uses '<'"), + ], +) +def test_inexpressible_branches_rejected_by_validate(operation, match): + """Test that branch shapes with no QASM 2 form are reported by validate(), not left + for serialization to raise on a program already reported as valid""" + qasm2_string = f"""OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg m[2]; + creg c[1]; + measure q[0] -> m[0]; + {operation} + """ + module = loads(qasm2_string) + with pytest.raises(ValidationError, match=match): + module.validate() From 08957a05caf15700ef5946d716d411ef5136cf87 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Wed, 5 Aug 2026 08:13:16 -0500 Subject: [PATCH 6/6] fix: satisfy mypy in the QASM 2 branch printer - annotate 'body' before the chain walk so the collapse assignment is not flagged used-before-def - widen _single_index to accept the Optional operand types on QuantumMeasurementStatement --- src/pyqasm/modules/qasm2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pyqasm/modules/qasm2.py b/src/pyqasm/modules/qasm2.py index 8189dab..c04c681 100644 --- a/src/pyqasm/modules/qasm2.py +++ b/src/pyqasm/modules/qasm2.py @@ -63,7 +63,7 @@ def _qreg_sizes(program: Program) -> dict[str, int]: return sizes -def _single_index(operand: qasm3_ast.QASMNode) -> tuple[str, int] | None: +def _single_index(operand: qasm3_ast.QASMNode | None) -> tuple[str, int] | None: """Decompose ``reg[i]`` into ``(reg, i)``, or ``None`` for any other operand shape.""" if ( isinstance(operand, qasm3_ast.IndexedIdentifier) @@ -212,6 +212,7 @@ def _flatten_branch( chain is walked back into the whole-register comparison it came from. """ bit_values: dict[int, bool] = {} + body: list[qasm3_ast.Statement] = [] reg_name = None reg_value = None current = statement