Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Types of changes:
### Removed

### Fixed
- Added support for physical qubit identifiers (`$0`, `$1`, …) in plain QASM 3 programs, including gates, barriers, measurements, and duplicate-qubit detection. ([#291](https://github.com/qBraid/pyqasm/pull/291))
- Updated CI to use `macos-15-intel` image due to deprecation of `macos-13` image. ([#283](https://github.com/qBraid/pyqasm/pull/283))

### Dependencies
Expand Down
15 changes: 10 additions & 5 deletions src/pyqasm/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,21 +251,26 @@ def extract_qasm_version(qasm: str) -> float: # type: ignore[return]
raise_qasm3_error("Could not determine the OpenQASM version.", err_type=QasmParsingError)

@staticmethod
def extract_duplicate_qubit(qubit_list: list[IndexedIdentifier]):
def extract_duplicate_qubit(qubit_list: list[IndexedIdentifier | Identifier]):
"""
Extracts the duplicate qubit from a list of qubits.

Args:
qubit_list (list[IndexedIdentifier]): The list of qubits.
qubit_list (list[IndexedIdentifier | Identifier]): The list of qubits.

Returns:
tuple(string, int): The duplicate qubit name and id.
"""
qubit_set = set()
for qubit in qubit_list:
assert isinstance(qubit, IndexedIdentifier)
qubit_name = qubit.name.name
qubit_id = qubit.indices[0][0].value # type: ignore
if isinstance(qubit, Identifier):
# Physical qubit: name is "$n", identity is the name itself.
qubit_name = qubit.name
qubit_id = int(qubit.name[1:])
else:
assert isinstance(qubit, IndexedIdentifier)
qubit_name = qubit.name.name
qubit_id = qubit.indices[0][0].value # type: ignore
if (qubit_name, qubit_id) in qubit_set:
return (qubit_name, qubit_id)
qubit_set.add((qubit_name, qubit_id))
Expand Down
5 changes: 3 additions & 2 deletions src/pyqasm/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,14 @@ def get_qubits_from_range_definition(

@staticmethod
def transform_gate_qubits(
gate_op: QuantumGate | QuantumPhase, qubit_map: dict[str, IndexedIdentifier]
gate_op: QuantumGate | QuantumPhase,
qubit_map: dict[str, IndexedIdentifier | Identifier],
) -> None:
"""Transform the qubits of a gate operation with a qubit map.

Args:
gate_op (QuantumGate): The gate operation to transform.
qubit_map (dict[str, IndexedIdentifier]): The qubit map to use for transformation.
qubit_map: Maps qubits to their transformed identifiers.

Returns:
None
Expand Down
149 changes: 104 additions & 45 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from collections import OrderedDict, deque
from functools import partial
from io import StringIO
from typing import Any, Callable, Optional, Sequence, cast
from typing import Any, Callable, Optional, Sequence, Union, cast

import numpy as np
import openqasm3.ast as qasm3_ast
Expand Down Expand Up @@ -271,22 +271,21 @@ def _visit_quantum_register(
return []
return [register]

# pylint: disable-next=too-many-locals,too-many-branches
# pylint: disable-next=too-many-locals,too-many-branches,too-many-statements
def _get_op_bits(
self,
operation: Any,
qubits: bool = True,
function_qubit_sizes: Optional[dict[str, int]] = None,
) -> list[qasm3_ast.IndexedIdentifier]:
) -> list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]]:
"""Get the quantum / classical bits for the operation.

Args:
operation (Any): The operation to get qubits for.
qubits (bool): Whether the bits are quantum bits or classical bits. Defaults to True.
Returns:
list[qasm3_ast.IndexedIdentifier] : The bits for the operation.
The quantum or classical bits for the operation.
"""
openqasm_bits = []
openqasm_bits: list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]] = []
bit_list = []

if isinstance(operation, qasm3_ast.QuantumMeasurementStatement):
Expand Down Expand Up @@ -316,6 +315,20 @@ def _get_op_bits(
else:
reg_name = bit.name

if qubits and reg_name.startswith("$"):
# Physical qubit reference (e.g. $0, $1).
if not reg_name[1:].isdigit():
raise_qasm3_error(
f"Invalid physical qubit identifier '{reg_name}': "
f"expected a non-negative integer index after '$'",
error_node=operation,
span=operation.span,
)
self._register_physical_qubit(reg_name)
# Keep as an Identifier so it serialises as "$0" rather than "$0[0]".
openqasm_bits.append(qasm3_ast.Identifier(reg_name))
continue

max_register_size = 0
reg_var = self._scope_manager.get_from_visible_scope(reg_name)
if reg_var is None:
Expand Down Expand Up @@ -374,7 +387,6 @@ def _get_op_bits(
)
for bit_id in bit_ids
]

openqasm_bits.extend(new_bits)

return openqasm_bits
Expand Down Expand Up @@ -558,17 +570,33 @@ def _visit_measurement( # pylint: disable=too-many-locals,too-many-branches,too
if isinstance(source, qasm3_ast.Identifier):
is_pulse_gate = False
if source.name.startswith("$") and source.name[1:].isdigit():
is_pulse_gate = True
statement.measure.qubit.name = f"__PYQASM_QUBITS__[{source.name[1:]}]"
if self._openpulse_grammar_declared:
# 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:]}]"
if (
self._total_pulse_qubits <= 0
and sum(self._global_qreg_size_map.values()) == 0
):
raise_qasm3_error(
"Invalid no of qubits in pulse level measurement",
error_node=statement,
span=statement.span,
)
else:
# 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__"):
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:
raise_qasm3_error(
"Invalid no of qubits in pulse level measurement",
error_node=statement,
span=statement.span,
)
if self._total_pulse_qubits <= 0 and sum(self._global_qreg_size_map.values()) == 0:
raise_qasm3_error(
"Invalid no of qubits in pulse level measurement",
error_node=statement,
span=statement.span,
)
if is_pulse_gate:
return [statement]
# # TODO: handle in-function measurements
Expand Down Expand Up @@ -748,12 +776,20 @@ def _visit_barrier( # pylint: disable=too-many-locals, too-many-branches
for op_qubit in barrier.qubits:
if isinstance(op_qubit, qasm3_ast.Identifier):
if op_qubit.name.startswith("$") and op_qubit.name[1:].isdigit():
if int(op_qubit.name[1:]) >= self._total_pulse_qubits:
raise_qasm3_error(
f"Invalid pulse qubit index `{op_qubit.name}` on barrier",
error_node=barrier,
span=barrier.span,
)
phys_idx = int(op_qubit.name[1:])
# In an OpenPulse program all physical qubits are declared up-front via
# defcal; validate that the index is within the known range.
# In a plain QASM program there are no such declarations, so any
# non-negative index is valid.
if self._openpulse_grammar_declared:
if phys_idx >= self._total_pulse_qubits:
raise_qasm3_error(
f"Invalid pulse qubit index `{op_qubit.name}` on barrier",
error_node=barrier,
span=barrier.span,
)
else:
self._register_physical_qubit(op_qubit.name)
valid_open_pulse_qubits = True
if valid_open_pulse_qubits:
return [barrier]
Expand Down Expand Up @@ -868,7 +904,7 @@ def _visit_gate_definition(self, definition: qasm3_ast.QuantumGateDefinition) ->

def _unroll_multiple_target_qubits(
self, operation: qasm3_ast.QuantumGate, gate_qubit_count: int
) -> list[list[qasm3_ast.IndexedIdentifier]]:
) -> list[list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]]]:
"""Unroll the complete list of all qubits that the given operation is applied to.
E.g. this maps 'cx q[0], q[1], q[2], q[3]' to [[q[0], q[1]], [q[2], q[3]]]

Expand All @@ -895,7 +931,7 @@ def _unroll_multiple_target_qubits(
def _broadcast_gate_operation(
self,
gate_function: Callable,
all_targets: list[list[qasm3_ast.IndexedIdentifier]],
all_targets: list[list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]]],
ctrls: Optional[list[qasm3_ast.IndexedIdentifier]] = None,
) -> list[qasm3_ast.QuantumGate]:
"""Broadcasts the application of a gate onto multiple sets of target qubits.
Expand All @@ -917,9 +953,42 @@ def _broadcast_gate_operation(
result.extend(gate_function(*ctrls, *targets))
return result

def _register_physical_qubit(self, name: str) -> int:
"""Register a physical qubit ``$n`` for depth / count tracking if not already known.

Args:
name: The physical qubit identifier string (e.g. ``"$0"``).

Returns:
The physical qubit index.
"""
phys_idx = int(name[1:])
if (name, phys_idx) not in self._module._qubit_depths:
self._module._qubit_depths[(name, phys_idx)] = QubitDepthNode(name, phys_idx)
self._total_pulse_qubits = max(self._total_pulse_qubits, phys_idx + 1)
self._module.num_qubits = max(self._module.num_qubits, phys_idx + 1)
return phys_idx

@staticmethod
def _get_qubit_name_and_id(
qubit: qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier,
) -> tuple[str, int]:
"""Return (register_name, qubit_index) for virtual or physical qubits.

Physical qubits are represented as ``Identifier("$n")`` and carry their
index in the name itself. Virtual qubits are ``IndexedIdentifier`` with
an explicit index in ``.indices``.
"""
if isinstance(qubit, qasm3_ast.Identifier):
# Physical qubit: name is "$n", index is n.
return qubit.name, int(qubit.name[1:])
assert isinstance(qubit.indices[0], list)
qubit_id = Qasm3ExprEvaluator.evaluate_expression(qubit.indices[0][0])[0] # type: ignore
return qubit.name.name, qubit_id

def _update_qubit_depth_for_gate(
self,
all_targets: list[list[qasm3_ast.IndexedIdentifier]],
all_targets: list[list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]]],
ctrls: list[qasm3_ast.IndexedIdentifier],
):
"""Updates the depth of the circuit after applying a broadcasted gate.
Expand All @@ -934,18 +1003,14 @@ def _update_qubit_depth_for_gate(
for qubit_subset in all_targets:
max_involved_depth = 0
for qubit in qubit_subset + ctrls:
assert isinstance(qubit.indices[0], list)
_qid_ = qubit.indices[0][0]
qubit_id = Qasm3ExprEvaluator.evaluate_expression(_qid_)[0] # type: ignore
qubit_node = self._module._qubit_depths[(qubit.name.name, qubit_id)]
qubit_name, qubit_id = self._get_qubit_name_and_id(qubit)
qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
qubit_node.num_gates += 1
max_involved_depth = max(max_involved_depth, qubit_node.depth + 1)

for qubit in qubit_subset + ctrls:
assert isinstance(qubit.indices[0], list)
_qid_ = qubit.indices[0][0]
qubit_id = Qasm3ExprEvaluator.evaluate_expression(_qid_)[0] # type: ignore
qubit_node = self._module._qubit_depths[(qubit.name.name, qubit_id)]
qubit_name, qubit_id = self._get_qubit_name_and_id(qubit)
qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
qubit_node.depth = max_involved_depth

# pylint: disable=too-many-branches, too-many-locals
Expand Down Expand Up @@ -1043,12 +1108,8 @@ def _visit_basic_gate_operation(
# get qreg in branching operations
for qubit_subset in unrolled_targets + [ctrls]:
for qubit in qubit_subset:
assert isinstance(qubit.indices, list) and len(qubit.indices) > 0
assert isinstance(qubit.indices[0], list) and len(qubit.indices[0]) > 0
qubit_idx = Qasm3ExprEvaluator.evaluate_expression(qubit.indices[0][0])[
0
]
self._is_branch_qubits.add((qubit.name.name, qubit_idx))
qubit_name, qubit_idx = QasmVisitor._get_qubit_name_and_id(qubit)
self._is_branch_qubits.add((qubit_name, qubit_idx))

# check for duplicate bits
for final_gate in result:
Expand Down Expand Up @@ -1096,9 +1157,9 @@ def _visit_custom_gate_operation(
ctrls = []
gate_name: str = operation.name.name
gate_definition: qasm3_ast.QuantumGateDefinition = self._custom_gates[gate_name]
op_qubits: list[qasm3_ast.IndexedIdentifier] = self._get_op_bits(
operation, qubits=True
) # type: ignore [assignment]
op_qubits: list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]] = (
self._get_op_bits(operation, qubits=True)
)

Qasm3Validator.validate_gate_call(operation, gate_definition, len(op_qubits))
# we need this because the gates applied inside a gate definition use the
Expand Down Expand Up @@ -1164,10 +1225,8 @@ def _visit_custom_gate_operation(
# get qubit registers in branching operations
for qubit_subset in [op_qubits] + [ctrls]:
for qubit in qubit_subset:
assert isinstance(qubit.indices, list) and len(qubit.indices) > 0
assert isinstance(qubit.indices[0], list) and len(qubit.indices[0]) > 0
qubit_idx = Qasm3ExprEvaluator.evaluate_expression(qubit.indices[0][0])[0]
self._is_branch_qubits.add((qubit.name.name, qubit_idx))
qubit_name, qubit_idx = QasmVisitor._get_qubit_name_and_id(qubit)
self._is_branch_qubits.add((qubit_name, qubit_idx))

self._scope_manager.pop_scope()
self._scope_manager.restore_context()
Expand Down
67 changes: 67 additions & 0 deletions tests/qasm3/resources/gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,70 @@ def test_fixture():
"gate custom_gate(a, b) p, q {",
),
}


# ── Physical-qubit tests ────────────────────────────────────────────────────

PHYSICAL_QUBIT_VALID_TESTS = {
"basic_gates": (
"""
OPENQASM 3.0;
include "stdgates.inc";
x $0;
h $1;
cx $0, $1;
""",
2,
0,
),
"custom_gate": (
"""
OPENQASM 3.0;
include "stdgates.inc";
gate prx(_gate_p_0, _gate_p_1) _gate_q_0 {
rz(0) _gate_q_0;
rx(pi/2) _gate_q_0;
rz(0) _gate_q_0;
}
bit[2] meas;
prx(pi/2, 0) $0;
prx(pi/2, 0) $0;
prx(pi/2, 0) $0;
barrier $0, $1;
meas[0] = measure $0;
meas[1] = measure $1;
""",
2,
2,
),
"barrier_only": (
"""
OPENQASM 3.0;
include "stdgates.inc";
barrier $0, $1, $2;
""",
3,
0,
),
"measure_physical": (
"""
OPENQASM 3.0;
include "stdgates.inc";
bit[1] c;
h $0;
c[0] = measure $0;
""",
1,
1,
),
"non_contiguous_indices": (
"""
OPENQASM 3.0;
include "stdgates.inc";
x $0;
x $3;
""",
4,
0,
),
}
Loading