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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ Types of changes:
- Added support for the `c3x` (3-controlled X) and `rc3x`/`rcccx` (relative-phase 3-controlled X) gates, decomposed into basis gates following qiskit's `C3XGate`/`RC3XGate` definitions. Also extended the `ctrl @` modifier chain so that 3- and 4-control stacks on `x` (e.g. `ctrl @ ctrl @ ctrl @ x`, `ctrl(4) @ x`) resolve to `c3x`/`c4x`. ([#320](https://github.com/qBraid/pyqasm/pull/320))

### Improved / Modified
- Consolidated the hardcoded `"__PYQASM_QUBITS__"` string literals scattered across `visitor.py`, `transformer.py` and `pulse/utils.py` into a single `INTERNAL_QUBIT_REGISTER` constant in `elements.py`, alongside an `is_internal_qubit_register()` helper that is now the one place the internal register is recognised. ([#325](https://github.com/qBraid/pyqasm/pull/325))

### Deprecated

### Removed

### Fixed
- 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))
- Fixed `measure` and `reset` on a user register whose name merely starts with the reserved internal register name (e.g. `__PYQASM_QUBITS__foo`) being mistaken for the internal register itself. Such statements were short-circuited out of unrolling and emitted verbatim, so `c = measure __PYQASM_QUBITS__foo;` was never expanded per qubit and `reset __PYQASM_QUBITS__foo;` silently reset nothing. The register is now matched on its exact name, or on the name followed by an index or slice. ([#325](https://github.com/qBraid/pyqasm/pull/325))
- Fixed the `c4x` (4-controlled X) gate, which previously raised `TypeError: c4x_gate() takes 4 positional arguments but 5 were given` because it was declared with four parameters for a five-qubit gate. It is now implemented via qiskit's structured `rc3x`/`c3sx`/`cphaseshift` decomposition. ([#320](https://github.com/qBraid/pyqasm/pull/320))
- Added `inv @` (inverse modifier) support for the multi-controlled-X family: `inv @ c3x` / `inv @ c4x` resolve to the (self-inverse) gate, and `inv @ rc3x` / `inv @ rcccx` resolve to the correct relative-phase dagger. These previously raised `Unsupported / undeclared QASM operation`. ([#320](https://github.com/qBraid/pyqasm/pull/320))
- Fixed the `ctrl @` modifier not resolving gate aliases: `ctrl @ toffoli` / `ctrl @ ccnot` (aliases of `ccx`) and `ctrl @ cnot` / `ctrl @ CX` (aliases of `cx`) now escalate controls identically to their canonical gate instead of raising `Unsupported controlled QASM operation`. ([#320](https://github.com/qBraid/pyqasm/pull/320))
Expand Down
23 changes: 23 additions & 0 deletions src/pyqasm/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,29 @@

import numpy as np

INTERNAL_QUBIT_REGISTER = "__PYQASM_QUBITS__"
"""Reserved register that qubits are consolidated onto, and that physical qubits ("$n")
are rewritten to for OpenPulse programs."""


def is_internal_qubit_register(qubit_name: str) -> bool:
"""Check whether an identifier refers to the internal qubit register.

The register appears either as the bare name, or followed by an index or a slice
("__PYQASM_QUBITS__[2]", "__PYQASM_QUBITS__[0:2]"). Matching on the prefix alone
would also match a user register that merely starts with the reserved name, e.g.
"__PYQASM_QUBITS__foo".

Args:
qubit_name (str): The identifier name to check.

Returns:
bool: True if the identifier refers to the internal qubit register.
"""
return qubit_name == INTERNAL_QUBIT_REGISTER or qubit_name.startswith(
f"{INTERNAL_QUBIT_REGISTER}["
)


class InversionOp(Enum):
"""
Expand Down
5 changes: 3 additions & 2 deletions src/pyqasm/pulse/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import openqasm3.ast as qasm3_ast

from pyqasm.elements import INTERNAL_QUBIT_REGISTER
from pyqasm.exceptions import raise_qasm3_error


Expand Down Expand Up @@ -91,7 +92,7 @@ def process_qubits_for_openpulse_gate( # pylint: disable=too-many-arguments
)
_qubit_set.add(int(qubit_id[1:]))
operation.qubits[i] = qasm3_ast.IndexedIdentifier(
name=qasm3_ast.Identifier("__PYQASM_QUBITS__"),
name=qasm3_ast.Identifier(INTERNAL_QUBIT_REGISTER),
indices=[[qasm3_ast.IntegerLiteral(int(qubit_id[1:]))]],
)
stmts = [operation]
Expand All @@ -101,7 +102,7 @@ def process_qubits_for_openpulse_gate( # pylint: disable=too-many-arguments
name=qasm3_ast.Identifier(gate_op),
qubits=[
qasm3_ast.IndexedIdentifier(
name=qasm3_ast.Identifier("__PYQASM_QUBITS__"),
name=qasm3_ast.Identifier(INTERNAL_QUBIT_REGISTER),
indices=[[qasm3_ast.IntegerLiteral(j)]],
)
],
Expand Down
18 changes: 9 additions & 9 deletions src/pyqasm/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
UnaryOperator,
)

from pyqasm.elements import Variable
from pyqasm.elements import INTERNAL_QUBIT_REGISTER, Variable
from pyqasm.exceptions import raise_qasm3_error
from pyqasm.expressions import Qasm3ExprEvaluator
from pyqasm.maps.expressions import VARIABLE_TYPE_MAP
Expand Down Expand Up @@ -496,11 +496,11 @@ def _get_pyqasm_device_qubit_index(
global_qreg_size_map,
)
if _start == 0:
_qubit_id.name = f"__PYQASM_QUBITS__[:{_end+1}]"
_qubit_id.name = f"{INTERNAL_QUBIT_REGISTER}[:{_end+1}]"
elif _end == device_qubits - 1:
_qubit_id.name = f"__PYQASM_QUBITS__[{_start}:]"
_qubit_id.name = f"{INTERNAL_QUBIT_REGISTER}[{_start}:]"
else:
_qubit_id.name = f"__PYQASM_QUBITS__[{_start}:{_end+1}]"
_qubit_id.name = f"{INTERNAL_QUBIT_REGISTER}[{_start}:{_end+1}]"
else:
_qubit_str = cast(str, _qubit_id.name) # type: ignore[union-attr]
_qubit_ind = cast(list, _qubit_id.indices) # type: ignore[union-attr]
Expand All @@ -513,7 +513,7 @@ def _get_pyqasm_device_qubit_index(
global_qreg_size_map,
)
ind.value = pyqasm_ind
_qubit_str.name = "__PYQASM_QUBITS__"
_qubit_str.name = INTERNAL_QUBIT_REGISTER

if isinstance(unrolled_stmts, list): # pylint: disable=too-many-nested-blocks
if isinstance(unrolled_stmts[0], QuantumMeasurementStatement):
Expand All @@ -531,7 +531,7 @@ def _get_pyqasm_device_qubit_index(
global_qreg_size_map,
)
ind.value = _pyqasm_val
_qubit_id.name = "__PYQASM_QUBITS__"
_qubit_id.name = INTERNAL_QUBIT_REGISTER

if isinstance(unrolled_stmts[0], QuantumReset):
for stmt in unrolled_stmts:
Expand All @@ -543,7 +543,7 @@ def _get_pyqasm_device_qubit_index(
_qubit_str, ind.value, qubit_register_offsets, global_qreg_size_map
)
ind.value = _pyqasm_val
stmt.qubits.name.name = "__PYQASM_QUBITS__" # type: ignore[union-attr]
stmt.qubits.name.name = INTERNAL_QUBIT_REGISTER # type: ignore[union-attr]

if isinstance(unrolled_stmts[0], QuantumBarrier):
for stmt in unrolled_stmts:
Expand All @@ -561,7 +561,7 @@ def _get_pyqasm_device_qubit_index(
global_qreg_size_map,
)
ind_val.value = pyqasm_val
_qubit_ind_id.name.name = "__PYQASM_QUBITS__"
_qubit_ind_id.name.name = INTERNAL_QUBIT_REGISTER

if isinstance(unrolled_stmts[0], QuantumGate):
for stmt in unrolled_stmts:
Expand All @@ -575,7 +575,7 @@ def _get_pyqasm_device_qubit_index(
)
stmt_qubits.append(
IndexedIdentifier(
Identifier("__PYQASM_QUBITS__"), [[IntegerLiteral(pyqasm_val)]]
Identifier(INTERNAL_QUBIT_REGISTER), [[IntegerLiteral(pyqasm_val)]]
)
)
stmt.qubits = stmt_qubits
Expand Down
54 changes: 39 additions & 15 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

from pyqasm.analyzer import Qasm3Analyzer
from pyqasm.elements import (
INTERNAL_QUBIT_REGISTER,
Capture,
ClbitDepthNode,
Context,
Expand All @@ -42,6 +43,7 @@
QubitDepthNode,
Variable,
Waveform,
is_internal_qubit_register,
)
from pyqasm.exceptions import (
BreakSignal,
Expand Down Expand Up @@ -495,13 +497,13 @@ def _qubit_register_consolidation(

global_scope = self._scope_manager.get_global_scope()
for var, val in global_scope.items():
if var == "__PYQASM_QUBITS__":
if var == INTERNAL_QUBIT_REGISTER:
raise_qasm3_error(
"Variable '__PYQASM_QUBITS__' is already defined",
f"Variable '{INTERNAL_QUBIT_REGISTER}' is already defined",
span=val.span,
)

pyqasm_reg_id = qasm3_ast.Identifier("__PYQASM_QUBITS__")
pyqasm_reg_id = qasm3_ast.Identifier(INTERNAL_QUBIT_REGISTER)
pyqasm_reg_size = qasm3_ast.IntegerLiteral(self._module._device_qubits) # type: ignore
pyqasm_reg_stmt = qasm3_ast.QubitDeclaration(pyqasm_reg_id, pyqasm_reg_size)

Expand Down Expand Up @@ -574,7 +576,7 @@ def _visit_measurement( # pylint: disable=too-many-locals,too-many-branches,too
# OpenPulse program: rename to the internal virtual register used by the
# pulse visitor, and validate the index is in range.
is_pulse_gate = True
statement.measure.qubit.name = f"__PYQASM_QUBITS__[{source.name[1:]}]"
statement.measure.qubit.name = f"{INTERNAL_QUBIT_REGISTER}[{source.name[1:]}]"
if (
self._total_pulse_qubits <= 0
and sum(self._global_qreg_size_map.values()) == 0
Expand All @@ -588,7 +590,7 @@ def _visit_measurement( # pylint: disable=too-many-locals,too-many-branches,too
# Plain QASM program: keep the physical qubit identifier as-is.
is_pulse_gate = True
self._register_physical_qubit(source.name)
elif source.name.startswith("__PYQASM_QUBITS__"):
elif is_internal_qubit_register(source.name):
is_pulse_gate = True
statement.measure.qubit.name = source.name
if self._total_pulse_qubits <= 0 and sum(self._global_qreg_size_map.values()) == 0:
Expand Down Expand Up @@ -695,6 +697,36 @@ def _visit_measurement( # pylint: disable=too-many-locals,too-many-branches,too

return unrolled_measurements

def _resolve_unindexed_reset_qubit(self, statement: qasm3_ast.QuantumReset) -> bool:
"""Resolve a reset whose operand is a bare ``Identifier`` rather than a
register slot: a physical qubit ("$n") or the internal pulse register.

Args:
statement (qasm3_ast.QuantumReset): The reset statement whose operand
is being resolved. Renamed in place for OpenPulse programs.

Returns:
bool: True if the operand was resolved and the statement needs no
further unrolling.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not isinstance(statement.qubits, qasm3_ast.Identifier):
return False

qubit_name = statement.qubits.name
if qubit_name.startswith("$") and qubit_name[1:].isdigit():
if self._openpulse_grammar_declared:
# OpenPulse program: rename to the internal virtual register used by the
# pulse visitor.
statement.qubits.name = f"{INTERNAL_QUBIT_REGISTER}[{qubit_name[1:]}]"
else:
# Plain QASM program: keep the physical qubit identifier as-is, the same
# as gate and measurement operands do, so the statement still serialises
# as "reset $2;" and the qubit is counted.
self._register_physical_qubit(qubit_name)
return True

return is_internal_qubit_register(qubit_name)

def _visit_reset(self, statement: qasm3_ast.QuantumReset) -> list[qasm3_ast.QuantumReset]:
"""Visit a reset statement element.

Expand All @@ -705,16 +737,8 @@ def _visit_reset(self, statement: qasm3_ast.QuantumReset) -> list[qasm3_ast.Quan
None
"""
logger.debug("Visiting reset statement '%s'", str(statement))
if isinstance(statement.qubits, qasm3_ast.Identifier):
is_pulse_gate = False
if statement.qubits.name.startswith("$") and statement.qubits.name[1:].isdigit():
is_pulse_gate = True
statement.qubits.name = f"__PYQASM_QUBITS__[{statement.qubits.name[1:]}]"
elif statement.qubits.name.startswith("__PYQASM_QUBITS__"):
is_pulse_gate = True
statement.qubits.name = statement.qubits.name
if is_pulse_gate:
return [statement]
if self._resolve_unindexed_reset_qubit(statement):
return [statement]

if len(self._function_qreg_size_map) > 0: # atleast in SOME function scope
# since we may have multiple function scopes, we need to transform the qubits
Expand Down
31 changes: 31 additions & 0 deletions tests/qasm3/test_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,37 @@ def test_standalone_measurement():
check_unrolled_qasm(dumps(module), expected_qasm)


def test_measure_on_register_named_like_the_internal_one_is_unrolled():
"""A user register whose name merely starts with the reserved internal register
name is a normal register and must be unrolled.

The internal register is matched on its exact name, or on the name followed by an
index or slice. Matching the bare prefix instead also swallowed registers like
"__PYQASM_QUBITS__foo", short-circuiting them out of unrolling so the measurement
was emitted verbatim rather than expanded per qubit.
"""
qasm3_string = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] __PYQASM_QUBITS__foo;
bit[2] c;
c = measure __PYQASM_QUBITS__foo;
"""

expected_qasm = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] __PYQASM_QUBITS__foo;
bit[2] c;
c[0] = measure __PYQASM_QUBITS__foo[0];
c[1] = measure __PYQASM_QUBITS__foo[1];
"""

module = loads(qasm3_string)
module.unroll()
check_unrolled_qasm(dumps(module), expected_qasm)


@pytest.mark.parametrize(
"qasm3_code,error_message,line_num,col_num,err_line",
[
Expand Down
78 changes: 78 additions & 0 deletions tests/qasm3/test_reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,81 @@ def test_incorrect_resets(caplog):

assert "Error at line 8, column 4" in caplog.text
assert "reset q1[4]" in caplog.text


def test_reset_physical_qubit_preserves_identifier():
"""Reset on a physical qubit keeps the "$n" spelling.

Gate and measurement operands already keep physical qubits as-is; reset used to
rewrite them to the internal pulse register ("__PYQASM_QUBITS__[2]"), which names
a register the program never declares and does not round-trip through dumps().
"""
qasm3_string = """
OPENQASM 3.0;
include "stdgates.inc";
bit[1] c;
h $2;
reset $2;
c[0] = measure $2;
"""
module = loads(qasm3_string)
module.unroll()

expected = """OPENQASM 3.0;
include "stdgates.inc";
bit[1] c;
h $2;
reset $2;
c[0] = measure $2;
"""
check_unrolled_qasm(dumps(module), expected)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def test_reset_physical_qubit_is_counted():
"""A physical qubit touched only by reset is still registered, so num_qubits
reflects it (the early return used to skip registration entirely)."""
qasm3_string = """
OPENQASM 3.0;
include "stdgates.inc";
reset $3;
"""
module = loads(qasm3_string)
module.unroll()

assert module.num_qubits == 4 # "$3" is hardware qubit 3, so 4 qubits are addressed


def test_reset_physical_qubit_unrolled_output_is_reloadable():
"""The unrolled program must itself be valid OpenQASM 3 that pyqasm can reload."""
module = loads('OPENQASM 3.0;\ninclude "stdgates.inc";\nreset $0;\n')
module.unroll()

reloaded = loads(dumps(module))
reloaded.validate()


def test_reset_on_register_named_like_the_internal_one_is_unrolled():
"""A user register whose name merely starts with the reserved internal register
name is a normal register and must be unrolled.

The internal register is matched on its exact name, or on the name followed by an
index or slice. Matching the bare prefix instead also swallowed registers like
"__PYQASM_QUBITS__foo", short-circuiting them out of unrolling so that
"reset __PYQASM_QUBITS__foo;" silently reset nothing.
"""
qasm3_string = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] __PYQASM_QUBITS__foo;
reset __PYQASM_QUBITS__foo;
"""
module = loads(qasm3_string)
module.unroll()

expected = """OPENQASM 3.0;
include "stdgates.inc";
qubit[2] __PYQASM_QUBITS__foo;
reset __PYQASM_QUBITS__foo[0];
reset __PYQASM_QUBITS__foo[1];
"""
check_unrolled_qasm(dumps(module), expected)
Loading