diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ed69f..2635aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/pyqasm/analyzer.py b/src/pyqasm/analyzer.py index 74539ff..8bb1f71 100644 --- a/src/pyqasm/analyzer.py +++ b/src/pyqasm/analyzer.py @@ -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)) diff --git a/src/pyqasm/transformer.py b/src/pyqasm/transformer.py index 9dbbfa2..385c2b6 100644 --- a/src/pyqasm/transformer.py +++ b/src/pyqasm/transformer.py @@ -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 diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index 0ad4cd6..c18e49c 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -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 @@ -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): @@ -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: @@ -374,7 +387,6 @@ def _get_op_bits( ) for bit_id in bit_ids ] - openqasm_bits.extend(new_bits) return openqasm_bits @@ -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 @@ -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] @@ -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]]] @@ -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. @@ -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. @@ -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 @@ -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: @@ -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 @@ -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() diff --git a/tests/qasm3/resources/gates.py b/tests/qasm3/resources/gates.py index cce5375..b66f8b2 100644 --- a/tests/qasm3/resources/gates.py +++ b/tests/qasm3/resources/gates.py @@ -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, + ), +} diff --git a/tests/qasm3/test_gates.py b/tests/qasm3/test_gates.py index 6dae4bd..9fd9d89 100644 --- a/tests/qasm3/test_gates.py +++ b/tests/qasm3/test_gates.py @@ -23,6 +23,7 @@ from pyqasm.exceptions import ValidationError from tests.qasm3.resources.gates import ( CUSTOM_GATE_INCORRECT_TESTS, + PHYSICAL_QUBIT_VALID_TESTS, SINGLE_QUBIT_GATE_INCORRECT_TESTS, custom_op_tests, double_op_tests, @@ -655,3 +656,40 @@ def test_incorrect_single_qubit_gates(test_name, caplog): assert f"Error at line {line_num}, column {col_num}" in caplog.text assert line in caplog.text + + +# ── Physical-qubit tests ──────────────────────────────────────────────────── + + +@pytest.mark.parametrize("test_name", PHYSICAL_QUBIT_VALID_TESTS.keys()) +def test_physical_qubit_validate(test_name): + qasm_input, expected_qubits, expected_clbits = PHYSICAL_QUBIT_VALID_TESTS[test_name] + result = loads(qasm_input) + result.validate() + assert result.num_qubits == expected_qubits + assert result.num_clbits == expected_clbits + + +def test_physical_qubit_unroll(): + """Physical qubits inside a custom gate are expanded without errors.""" + qasm_input = """ + OPENQASM 3.0; + include "stdgates.inc"; + gate my_rx(p) q { rx(p) q; } + my_rx(0.5) $0; + """ + result = loads(qasm_input) + result.unroll() + assert result.num_qubits == 1 + assert result.num_clbits == 0 + + +def test_physical_qubit_duplicate_detection(): + """Duplicate physical qubits in a two-qubit gate are caught.""" + qasm_input = """ + OPENQASM 3.0; + include "stdgates.inc"; + cx $0, $0; + """ + with pytest.raises(ValidationError, match=r"Duplicate qubit"): + loads(qasm_input).validate()