From 71e9b3489b1c1795afc2e6b2b9d2d4085aefc8a2 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Sun, 12 Jul 2026 09:27:32 -0500 Subject: [PATCH 1/2] fix: support physical qubits in QASM3 to QIR conversion qasm3_to_qir raised a bare AssertionError (empty message) for programs addressing physical qubits, e.g. "h $0;". These are valid OpenQASM 3 and are what Qiskit emits for backend-transpiled circuits, but they unroll to plain Identifier nodes rather than IndexedIdentifier, which _get_op_bits assumed. Physical qubits now lower to the QIR qubit of the same index ($3 is qubit 3). The entry point declares enough qubits to cover the highest index used: pyqasm reports num_qubits == 0 for these programs, and a program touching only $7 still needs 8 qubits for $7 to be a valid QIR qubit id. Full-barrier detection has the same problem -- it summed declared register sizes, which is 0 with no registers, so every barrier looked like an unsupported subset barrier. Unsupported operands and target-less measurements now raise Qasm3ConversionError with a message rather than an empty AssertionError. Requires pyqasm >= 1.0.4, the first release that preserves physical qubits in reset statements (qBraid/pyqasm#325). --- CHANGELOG.md | 3 + pyproject.toml | 2 +- qbraid_qir/qasm3/visitor.py | 96 +++++++++-- .../converter/test_physical_qubits.py | 152 ++++++++++++++++++ 4 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 tests/qasm3_qir/converter/test_physical_qubits.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c31e82..b3c5088a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,11 @@ Types of changes: ### 🐛 Bug Fixes +- Fixed `qasm3_to_qir` raising a bare `AssertionError` (with an empty message) for programs that address physical qubits, e.g. `h $0;`. Physical qubits are valid OpenQASM 3 and are what Qiskit emits when a circuit is transpiled against a backend (`qasm3.dumps(transpile(circuit, backend))`), but they survive unrolling as plain `Identifier` nodes rather than `IndexedIdentifier`, which the visitor assumed. They now lower to the QIR qubit of the same index (`$3` is qubit 3), and the entry point declares enough qubits to cover the highest index used. Operands the visitor cannot lower now raise `Qasm3ConversionError` with a message instead of an empty `AssertionError`. + ### ⬇️ Dependency Updates +- Updated `pyqasm` requirement from `>=0.4.0,<1.1.0` to `>=1.0.4,<1.1.0`, which is the first release that preserves physical qubits in `reset` statements rather than rewriting them to the internal pulse register. - Updated `autoqasm` requirement from `>=0.1.0` to `>=0.2.0` ([#278](https://github.com/qBraid/qbraid-qir/pull/278)) - Updated `qbraid` requirement from `>=0.11.0,<0.12.0` to `>=0.11.1,<0.12.0` ([#279](https://github.com/qBraid/qbraid-qir/pull/279)) - Updated `sphinx` requirement from `>=7.3.7,<=8.3.0` to `>=8.1.3,<=8.3.0` ([#282](https://github.com/qBraid/qbraid-qir/pull/282)) diff --git a/pyproject.toml b/pyproject.toml index b4a391bd..bb7b3e20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Discord = "https://discord.gg/TPBU2sa8Et" [project.optional-dependencies] cirq = ["cirq-core>=1.3.0,<1.6.0"] -qasm3 = ["pyqasm>=0.4.0,<1.1.0", "numpy"] +qasm3 = ["pyqasm>=1.0.4,<1.1.0", "numpy"] qiskit = ["qiskit>=2.0,<3.0"] squin = ["kirin-toolchain>=0.17.33", "bloqade-circuit>=0.9.1"] diff --git a/qbraid_qir/qasm3/visitor.py b/qbraid_qir/qasm3/visitor.py index 65c333c6..8fea5ba5 100644 --- a/qbraid_qir/qasm3/visitor.py +++ b/qbraid_qir/qasm3/visitor.py @@ -23,12 +23,14 @@ """ import logging +import re from typing import Any, Callable, List, Optional, Union import openqasm3.ast as qasm3_ast import pyqir import pyqir.rt from openqasm3.ast import UnaryOperator +from openqasm3.visitor import QASMVisitor from pyqir import qis from qbraid_qir._pyqir_compat import pointer_id, qubit_pointer_type @@ -42,6 +44,53 @@ logger = logging.getLogger(__name__) +_PHYSICAL_QUBIT_RE = re.compile(r"^\$(\d+)$") + + +def _physical_qubit_index(node: Any) -> Optional[int]: + """Return the hardware index of a physical qubit reference, else None. + + Physical qubits are written "$n" in OpenQASM 3 and survive unrolling as a plain + ``Identifier`` (they have no declaring register), unlike virtual qubits which + become ``IndexedIdentifier``. The index is carried in the name: "$3" is qubit 3. + """ + if not isinstance(node, qasm3_ast.Identifier): + return None + + match = _PHYSICAL_QUBIT_RE.match(node.name) + return int(match.group(1)) if match else None + + +class _HighestPhysicalQubit(QASMVisitor): + """Finds the largest physical qubit index addressed anywhere in a program.""" + + def __init__(self) -> None: + self.highest = -1 + + # Name and signature are fixed by openqasm3's QASMVisitor dispatch. + def visit_Identifier( # pylint: disable=invalid-name,unused-argument + self, node: qasm3_ast.Identifier, context: Any = None + ) -> None: + index = _physical_qubit_index(node) + if index is not None: + self.highest = max(self.highest, index) + + +def _required_qubits(module: QasmQIRModule) -> int: + """Number of qubits the entry point must declare. + + A program that addresses physical qubits declares no qubit register, so pyqasm + reports ``num_qubits == 0`` for it after unrolling. Physical indices are absolute + hardware addresses, so the entry point has to cover the highest one used: a program + touching only "$7" still needs 8 qubits for "$7" to be a valid QIR qubit id. + """ + finder = _HighestPhysicalQubit() + for statement in module.qasm_program.unrolled_ast.statements: + finder.visit(statement) + + return max(module.num_qubits, finder.highest + 1) + + class QasmQIRVisitor(QIRVisitor): """A profile-aware visitor for converting OpenQASM 3 programs to QIR. @@ -81,6 +130,7 @@ def __init__( self._global_creg_size_map: dict[str, int] = {} self._custom_gates: dict[str, qasm3_ast.QuantumGateDefinition] = {} self._barrier_qubits: set[pyqir.Constant] = set() + self._required_qubit_count: int = 0 # Configuration self._initialize_runtime: bool = initialize_runtime @@ -129,10 +179,12 @@ def visit_qasm3_module(self, module: QasmQIRModule) -> None: # Set qir_profiles based on the profile being used qir_profiles = "adaptive" if self._profile.name == "AdaptiveExecution" else "base" + self._required_qubit_count = _required_qubits(module) + entry = pyqir.entry_point( self._llvm_module, module.name, - qasm3_module.num_qubits, + self._required_qubit_count, qasm3_module.num_clbits, qir_profiles=qir_profiles, ) @@ -206,9 +258,14 @@ def _get_op_bits(self, operation: Any, qubits: bool = True) -> list[pyqir.Consta Unionlist[pyqir.Constant] : The bits for the operation. """ qir_bits = [] - bit_list = [] + bit_list: list[Any] = [] if isinstance(operation, qasm3_ast.QuantumMeasurementStatement): - assert operation.target is not None + if operation.target is None: + raise_qasm3_error( + "Measurement result must be assigned to a classical bit, " + "e.g. 'c[0] = measure q[0];'", + span=operation.span, + ) bit_list = [operation.measure.qubit] if qubits else [operation.target] else: bit_list = ( @@ -216,8 +273,21 @@ def _get_op_bits(self, operation: Any, qubits: bool = True) -> list[pyqir.Consta ) for bit in bit_list: - # as we have unrolled qasm3, we can assume that the bit is an IndexedIdentifier - assert isinstance(bit, qasm3_ast.IndexedIdentifier) + # Physical qubits ("$n") are not backed by a declared register: they carry + # their hardware index in the identifier itself, so "$3" is qubit 3. Qiskit + # emits these when a circuit is transpiled against a backend. + physical_id = _physical_qubit_index(bit) if qubits else None + if physical_id is not None: + qir_bits.append(pyqir.qubit(self._llvm_module.context, physical_id)) + continue + + # Everything else is register-backed and, post-unroll, indexed. + if not isinstance(bit, qasm3_ast.IndexedIdentifier): + kind = "qubit" if qubits else "classical bit" + raise_qasm3_error( + f"Unsupported {kind} operand of type {type(bit).__name__}", + span=getattr(bit, "span", None), + ) reg_name = bit.name.name assert isinstance(bit.indices, list) and len(bit.indices) == 1 @@ -275,9 +345,12 @@ def _visit_measurement(self, statement: qasm3_ast.QuantumMeasurementStatement) - """ logger.debug("Visiting measurement statement '%s'", str(statement)) - source = statement.measure.qubit - target = statement.target - assert source and target + if statement.target is None: + raise_qasm3_error( + "Measurement result must be assigned to a classical bit, " + "e.g. 'c[0] = measure q[0];'", + span=statement.span, + ) source_ids = self._get_op_bits(statement, qubits=True) target_ids = self._get_op_bits(statement, qubits=False) measurement_func = self._profile.get_measurement_function() @@ -325,7 +398,12 @@ def _barrier_applicable(self) -> bool: if self._profile.restrictions.subset_barriers_allowed: return True - total_qubit_count = sum(self._global_qreg_size_map.values()) + # Physical qubits belong to no declared register, so the register size map is + # empty for those programs and the entry point's qubit count is the only + # measure of how many qubits a full barrier has to cover. + total_qubit_count = max( + sum(self._global_qreg_size_map.values()), self._required_qubit_count + ) return len(self._barrier_qubits) == total_qubit_count def _check_and_apply_barrier(self) -> None: diff --git a/tests/qasm3_qir/converter/test_physical_qubits.py b/tests/qasm3_qir/converter/test_physical_qubits.py new file mode 100644 index 00000000..4676c7fe --- /dev/null +++ b/tests/qasm3_qir/converter/test_physical_qubits.py @@ -0,0 +1,152 @@ +# 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 QASM3 programs that address physical (hardware) +qubits, e.g. "$0", rather than declaring a qubit register. + +Physical qubits are valid OpenQASM 3 and are what Qiskit emits when a circuit is +transpiled against a backend (``qasm3.dumps(transpile(circuit, backend))``). They +carry their index in the identifier itself, so "$3" is hardware qubit 3 and maps +directly onto QIR qubit 3. + +""" + +import openqasm3.ast as qasm3_ast +import pytest + +from qbraid_qir.qasm3 import qasm3_to_qir +from qbraid_qir.qasm3.exceptions import Qasm3ConversionError +from qbraid_qir.qasm3.visitor import QasmQIRVisitor +from tests.qir_utils import ( + check_attributes, + check_measure_op, + check_single_qubit_gate_op, + check_two_qubit_gate_op, +) + + +def test_physical_qubit_gates_and_measurement(): + """A bell pair on physical qubits lowers to QIR addressing qubits 0 and 1.""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + bit[2] meas; + h $0; + cx $0, $1; + meas[0] = measure $0; + meas[1] = measure $1; + """ + result = qasm3_to_qir(qasm3_string) + generated_qir = str(result).splitlines() + + check_attributes(generated_qir, 2, 2) + check_single_qubit_gate_op(generated_qir, 1, [0], "h") + check_two_qubit_gate_op(generated_qir, 1, [[0, 1]], "cx") + check_measure_op(generated_qir, 2, [0, 1], [0, 1]) + + +def test_physical_qubit_index_is_preserved(): + """Physical qubit indices are hardware addresses, so "$3" stays qubit 3 and + the entry point must declare enough qubits to cover the highest index used.""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + bit[2] meas; + h $3; + cx $3, $7; + meas[0] = measure $3; + meas[1] = measure $7; + """ + result = qasm3_to_qir(qasm3_string) + generated_qir = str(result).splitlines() + + # Highest index is 7, so the entry point requires 8 qubits. + check_attributes(generated_qir, 8, 2) + check_single_qubit_gate_op(generated_qir, 1, [3], "h") + check_two_qubit_gate_op(generated_qir, 1, [[3, 7]], "cx") + check_measure_op(generated_qir, 2, [3, 7], [0, 1]) + + +def test_qiskit_style_transpiled_program(): + """The shape Qiskit emits after transpiling against a backend: physical + qubits, a barrier, and register-indexed measurements.""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + bit[3] meas; + rz(pi / 2) $0; + sx $0; + cz $0, $1; + cz $1, $2; + barrier $0, $1, $2; + meas[0] = measure $0; + meas[1] = measure $1; + meas[2] = measure $2; + """ + result = qasm3_to_qir(qasm3_string) + generated_qir = str(result).splitlines() + + check_attributes(generated_qir, 3, 3) + check_two_qubit_gate_op(generated_qir, 2, [[0, 1], [1, 2]], "cz") + check_measure_op(generated_qir, 3, [0, 1, 2], [0, 1, 2]) + + +def test_physical_qubit_reset(): + """Reset on a physical qubit targets the same hardware index.""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + bit[1] meas; + h $2; + reset $2; + meas[0] = measure $2; + """ + result = qasm3_to_qir(qasm3_string) + generated_qir = str(result).splitlines() + + check_attributes(generated_qir, 3, 1) + check_single_qubit_gate_op(generated_qir, 1, [2], "h") + check_measure_op(generated_qir, 1, [2], [0]) + + +def test_measurement_without_target_raises_conversion_error(): + """'measure q;' parses but has no classical target to record into. It must + report that, not raise a bare AssertionError with an empty message.""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + bit[2] c; + h q[0]; + measure q; + """ + with pytest.raises(Qasm3ConversionError, match="must be assigned to a classical bit"): + qasm3_to_qir(qasm3_string) + + +def test_unsupported_operand_raises_conversion_error(): + """An operand the visitor cannot lower raises a Qasm3ConversionError naming + the problem, never a bare AssertionError with an empty message.""" + visitor = QasmQIRVisitor() + operation = qasm3_ast.QuantumGate( + modifiers=[], + name=qasm3_ast.Identifier(name="h"), + arguments=[], + # Neither an IndexedIdentifier (virtual) nor a "$n" Identifier (physical). + qubits=[qasm3_ast.Identifier(name="q")], + ) + + with pytest.raises(Qasm3ConversionError, match="Unsupported qubit operand"): + visitor._get_op_bits(operation) # pylint: disable=protected-access From d7ac111f488e3914e743712eb1f85a69b826b641 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Tue, 14 Jul 2026 10:17:08 -0500 Subject: [PATCH 2/2] refactor: take the physical qubit count from pyqasm instead of re-deriving it pyqasm already registers physical qubits during unrolling and folds them into num_qubits (indices are absolute hardware addresses, so a program touching only "$7" reports 8 qubits). Walking the unrolled AST to recompute the highest index duplicated semantic processing that belongs in pyqasm, so drop _HighestPhysicalQubit / _required_qubits and read qasm3_module.num_qubits. Also extend the physical-qubit tests: assert the single-qubit ops in the Qiskit-style transpiled program (rz, and the h-s-h that "sx" decomposes to, since QIR has no native sx) and assert the reset call in the reset test. --- qbraid_qir/qasm3/visitor.py | 37 +++---------------- .../converter/test_physical_qubits.py | 8 ++++ 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/qbraid_qir/qasm3/visitor.py b/qbraid_qir/qasm3/visitor.py index 8fea5ba5..651139e4 100644 --- a/qbraid_qir/qasm3/visitor.py +++ b/qbraid_qir/qasm3/visitor.py @@ -30,7 +30,6 @@ import pyqir import pyqir.rt from openqasm3.ast import UnaryOperator -from openqasm3.visitor import QASMVisitor from pyqir import qis from qbraid_qir._pyqir_compat import pointer_id, qubit_pointer_type @@ -61,36 +60,6 @@ def _physical_qubit_index(node: Any) -> Optional[int]: return int(match.group(1)) if match else None -class _HighestPhysicalQubit(QASMVisitor): - """Finds the largest physical qubit index addressed anywhere in a program.""" - - def __init__(self) -> None: - self.highest = -1 - - # Name and signature are fixed by openqasm3's QASMVisitor dispatch. - def visit_Identifier( # pylint: disable=invalid-name,unused-argument - self, node: qasm3_ast.Identifier, context: Any = None - ) -> None: - index = _physical_qubit_index(node) - if index is not None: - self.highest = max(self.highest, index) - - -def _required_qubits(module: QasmQIRModule) -> int: - """Number of qubits the entry point must declare. - - A program that addresses physical qubits declares no qubit register, so pyqasm - reports ``num_qubits == 0`` for it after unrolling. Physical indices are absolute - hardware addresses, so the entry point has to cover the highest one used: a program - touching only "$7" still needs 8 qubits for "$7" to be a valid QIR qubit id. - """ - finder = _HighestPhysicalQubit() - for statement in module.qasm_program.unrolled_ast.statements: - finder.visit(statement) - - return max(module.num_qubits, finder.highest + 1) - - class QasmQIRVisitor(QIRVisitor): """A profile-aware visitor for converting OpenQASM 3 programs to QIR. @@ -179,7 +148,11 @@ def visit_qasm3_module(self, module: QasmQIRModule) -> None: # Set qir_profiles based on the profile being used qir_profiles = "adaptive" if self._profile.name == "AdaptiveExecution" else "base" - self._required_qubit_count = _required_qubits(module) + # pyqasm registers physical qubits during unrolling and folds them into + # num_qubits: indices are absolute hardware addresses, so a program touching + # only "$7" reports 8 qubits, which is what the entry point has to declare for + # "$7" to be a valid QIR qubit id. + self._required_qubit_count = qasm3_module.num_qubits entry = pyqir.entry_point( self._llvm_module, diff --git a/tests/qasm3_qir/converter/test_physical_qubits.py b/tests/qasm3_qir/converter/test_physical_qubits.py index 4676c7fe..a3f311fa 100644 --- a/tests/qasm3_qir/converter/test_physical_qubits.py +++ b/tests/qasm3_qir/converter/test_physical_qubits.py @@ -32,7 +32,9 @@ from tests.qir_utils import ( check_attributes, check_measure_op, + check_resets, check_single_qubit_gate_op, + check_single_qubit_rotation_op, check_two_qubit_gate_op, ) @@ -99,6 +101,11 @@ def test_qiskit_style_transpiled_program(): generated_qir = str(result).splitlines() check_attributes(generated_qir, 3, 3) + # pyqir emits pi/2 as its IEEE-754 hex form rather than a decimal literal. + check_single_qubit_rotation_op(generated_qir, 1, [0], ["0x3FF921FB54442D18"], "rz") + # QIR has no native "sx", so pyqasm decomposes it to h - s - h, all on qubit 0. + check_single_qubit_gate_op(generated_qir, 2, [0, 0], "h") + check_single_qubit_gate_op(generated_qir, 1, [0], "s") check_two_qubit_gate_op(generated_qir, 2, [[0, 1], [1, 2]], "cz") check_measure_op(generated_qir, 3, [0, 1, 2], [0, 1, 2]) @@ -118,6 +125,7 @@ def test_physical_qubit_reset(): check_attributes(generated_qir, 3, 1) check_single_qubit_gate_op(generated_qir, 1, [2], "h") + check_resets(generated_qir, 1, [2]) check_measure_op(generated_qir, 1, [2], [0])