Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) <statement>`, 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))
Expand Down
223 changes: 220 additions & 3 deletions src/pyqasm/modules/qasm2.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,234 @@
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 _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]]:
"""Reduce a branching statement to the ``if (creg == int) <statement>`` 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) <statement>'"
)

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
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
# 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) <statement>``
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.
Expand Down Expand Up @@ -74,8 +290,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
Expand Down
Loading
Loading