Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment thread
TheGupta2012 marked this conversation as resolved.
Outdated
- 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.
Comment thread
TheGupta2012 marked this conversation as resolved.
Outdated
- 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))
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
69 changes: 60 additions & 9 deletions qbraid_qir/qasm3/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"""

import logging
import re
from typing import Any, Callable, List, Optional, Union

import openqasm3.ast as qasm3_ast
Expand All @@ -42,6 +43,23 @@
logger = logging.getLogger(__name__)


_PHYSICAL_QUBIT_RE = re.compile(r"^\$(\d+)$")


def _physical_qubit_index(node: Any) -> Optional[int]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the comment below, better to move the parsing semantics to pyqasm

"""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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -206,18 +231,36 @@ 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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
bit_list = [operation.measure.qubit] if qubits else [operation.target]
else:
bit_list = (
operation.qubits if isinstance(operation.qubits, list) else [operation.qubits]
)

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
Expand Down Expand Up @@ -275,9 +318,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()
Expand Down Expand Up @@ -325,7 +371,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:
Expand Down
160 changes: 160 additions & 0 deletions tests/qasm3_qir/converter/test_physical_qubits.py
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should add the check for the single qubit op as well in the final qasm

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add check for reset as well

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