diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3e00b..5dde402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/src/pyqasm/elements.py b/src/pyqasm/elements.py index a63e044..b75c740 100644 --- a/src/pyqasm/elements.py +++ b/src/pyqasm/elements.py @@ -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): """ diff --git a/src/pyqasm/pulse/utils.py b/src/pyqasm/pulse/utils.py index 2c74103..3469c0c 100644 --- a/src/pyqasm/pulse/utils.py +++ b/src/pyqasm/pulse/utils.py @@ -22,6 +22,7 @@ import openqasm3.ast as qasm3_ast +from pyqasm.elements import INTERNAL_QUBIT_REGISTER from pyqasm.exceptions import raise_qasm3_error @@ -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] @@ -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)]], ) ], diff --git a/src/pyqasm/transformer.py b/src/pyqasm/transformer.py index 8ab2916..65f6f3c 100644 --- a/src/pyqasm/transformer.py +++ b/src/pyqasm/transformer.py @@ -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 @@ -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] @@ -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): @@ -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: @@ -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: @@ -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: @@ -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 diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index a9a565f..9bc6d1c 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -34,6 +34,7 @@ from pyqasm.analyzer import Qasm3Analyzer from pyqasm.elements import ( + INTERNAL_QUBIT_REGISTER, Capture, ClbitDepthNode, Context, @@ -42,6 +43,7 @@ QubitDepthNode, Variable, Waveform, + is_internal_qubit_register, ) from pyqasm.exceptions import ( BreakSignal, @@ -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) @@ -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 @@ -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: @@ -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. + """ + 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. @@ -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 diff --git a/tests/qasm3/test_measurement.py b/tests/qasm3/test_measurement.py index 89f5872..aba814a 100644 --- a/tests/qasm3/test_measurement.py +++ b/tests/qasm3/test_measurement.py @@ -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", [ diff --git a/tests/qasm3/test_reset.py b/tests/qasm3/test_reset.py index 263ce09..60f6b86 100644 --- a/tests/qasm3/test_reset.py +++ b/tests/qasm3/test_reset.py @@ -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) + + +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)