diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e1bd7b..28a84e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,12 @@ 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`. ([#290](https://github.com/qBraid/qbraid-qir/pull/290)) - Fixed the cudaq→squin tests (`test_bell_state`, `test_ghz_state`) failing under the typed-pointer pyqir (0.11.x) CI leg. cudaq 0.15+ emits opaque-pointer QIR (QIR 2.0) that only pyqir 0.12+ can parse, so these tests are now gated on `pyqir_uses_opaque_pointers()`. ([#292](https://github.com/qBraid/qbraid-qir/pull/292)) ### ⬇️ 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. ([#290](https://github.com/qBraid/qbraid-qir/pull/290)) - 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..5914da84 100644 --- a/qbraid_qir/qasm3/visitor.py +++ b/qbraid_qir/qasm3/visitor.py @@ -23,6 +23,7 @@ """ import logging +import re from typing import Any, Callable, List, Optional, Union import openqasm3.ast as qasm3_ast @@ -42,6 +43,23 @@ 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 QasmQIRVisitor(QIRVisitor): """A profile-aware visitor for converting OpenQASM 3 programs to QIR. @@ -81,6 +99,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 +148,16 @@ 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" + # 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, module.name, - qasm3_module.num_qubits, + self._required_qubit_count, qasm3_module.num_clbits, qir_profiles=qir_profiles, ) @@ -206,9 +231,10 @@ 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 + # _visit_measurement is the sole handler for measurement statements and has + # already rejected a missing target, so operation.target is non-None here. bit_list = [operation.measure.qubit] if qubits else [operation.target] else: bit_list = ( @@ -216,8 +242,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 +314,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 +367,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..a3f311fa --- /dev/null +++ b/tests/qasm3_qir/converter/test_physical_qubits.py @@ -0,0 +1,160 @@ +# 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_resets, + check_single_qubit_gate_op, + check_single_qubit_rotation_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) + # 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]) + + +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_resets(generated_qir, 1, [2]) + 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