Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
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
96 changes: 87 additions & 9 deletions qbraid_qir/qasm3/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +44,53 @@
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 _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:

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.

This will mean that we are going over the complete AST just for qubit count calculation which seems wasteful. Is it possible to derive this count from the qasm module object? Or keep a property max_physical_qubit_addr in the qasm module object? Should eliminate the need for a recalculation here

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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -206,18 +258,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,
)
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 +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()
Expand Down Expand Up @@ -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:
Expand Down
152 changes: 152 additions & 0 deletions tests/qasm3_qir/converter/test_physical_qubits.py
Original file line number Diff line number Diff line change
@@ -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;

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)
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_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