From 963077d583a291ca9ef26bb2a026c8d11b635961 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Sun, 12 Jul 2026 09:27:01 -0500 Subject: [PATCH 1/3] fix: preserve physical qubits in reset statements _visit_reset rewrote a physical qubit operand to the internal pulse register unconditionally, so "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. Gate and measurement operands already branch on the OpenPulse grammar flag and keep "$n" as-is for plain QASM programs. Reset now does the same, applying the rename only for OpenPulse programs, where the pulse visitor expects it. --- CHANGELOG.md | 1 + src/pyqasm/visitor.py | 38 +++++++++++++++++++++-------- tests/qasm3/test_reset.py | 51 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 511e2b8..919c24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Types of changes: ### 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. - 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/visitor.py b/src/pyqasm/visitor.py index a9a565f..7edbac5 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -695,6 +695,32 @@ 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. + + 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"__PYQASM_QUBITS__[{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 qubit_name.startswith("__PYQASM_QUBITS__") + def _visit_reset(self, statement: qasm3_ast.QuantumReset) -> list[qasm3_ast.QuantumReset]: """Visit a reset statement element. @@ -705,16 +731,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_reset.py b/tests/qasm3/test_reset.py index 263ce09..58fd499 100644 --- a/tests/qasm3/test_reset.py +++ b/tests/qasm3/test_reset.py @@ -118,3 +118,54 @@ 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() From 96efcf4b4e529ec14737b3131ff172ff27a03167 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Sun, 12 Jul 2026 11:36:13 -0500 Subject: [PATCH 2/3] fix: match the internal qubit register exactly, not by prefix Raised in review. The reset path treated any identifier starting with "__PYQASM_QUBITS__" as the internal pulse register, which also swallowed a user register that merely starts with that name: qubit[2] __PYQASM_QUBITS__foo; reset __PYQASM_QUBITS__foo; // silently reset nothing The statement was short-circuited out of unrolling and emitted verbatim, where a normally-named register expands to one reset per qubit. Match the exact name, or the name followed by an index or a slice, which are the forms the transformer actually generates ("__PYQASM_QUBITS__", "__PYQASM_QUBITS__[2]", "__PYQASM_QUBITS__[0:2]"). Matching only the indexed form, as first suggested, would silently drop the bare and slice forms. Also documents the helper's parameter. --- src/pyqasm/visitor.py | 16 +++++++++++++++- tests/qasm3/test_reset.py | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index 7edbac5..be20aa3 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -79,6 +79,9 @@ from pyqasm.validator import Qasm3Validator logger = logging.getLogger(__name__) + +# Reserved register that physical qubits are consolidated onto for OpenPulse programs. +_INTERNAL_QUBIT_REGISTER = "__PYQASM_QUBITS__" logger.propagate = False @@ -699,6 +702,10 @@ def _resolve_unindexed_reset_qubit(self, statement: qasm3_ast.QuantumReset) -> b """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. @@ -719,7 +726,14 @@ def _resolve_unindexed_reset_qubit(self, statement: qasm3_ast.QuantumReset) -> b self._register_physical_qubit(qubit_name) return True - return qubit_name.startswith("__PYQASM_QUBITS__") + # The internal register is the bare name, or the name followed by an index or a + # slice ("__PYQASM_QUBITS__[2]", "__PYQASM_QUBITS__[0:2]"). Matching on the prefix + # alone would also swallow a user register that merely starts with it, e.g. + # "__PYQASM_QUBITS__foo", short-circuiting it out of the unrolling below so its + # qubits would never actually be reset. + return qubit_name == _INTERNAL_QUBIT_REGISTER or qubit_name.startswith( + f"{_INTERNAL_QUBIT_REGISTER}[" + ) def _visit_reset(self, statement: qasm3_ast.QuantumReset) -> list[qasm3_ast.QuantumReset]: """Visit a reset statement element. diff --git a/tests/qasm3/test_reset.py b/tests/qasm3/test_reset.py index 58fd499..60f6b86 100644 --- a/tests/qasm3/test_reset.py +++ b/tests/qasm3/test_reset.py @@ -169,3 +169,30 @@ def test_reset_physical_qubit_unrolled_output_is_reloadable(): 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) From d9e98761c0b32a1c597810e914560d4ec14ae35d Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Tue, 14 Jul 2026 09:44:48 -0500 Subject: [PATCH 3/3] refactor: centralize the internal qubit register name and its detection The "__PYQASM_QUBITS__" literal was hardcoded across visitor.py, transformer.py and pulse/utils.py. Move it to elements.py as INTERNAL_QUBIT_REGISTER, next to an is_internal_qubit_register() helper that becomes the single place the register is recognized (exact name, or name followed by an index or slice). Applying the helper to the measurement handler fixes the same lookalike-prefix bug already fixed for reset: startswith("__PYQASM_QUBITS__") also matched user registers such as "__PYQASM_QUBITS__foo", short-circuiting them out of unrolling so that "c = measure __PYQASM_QUBITS__foo;" was emitted verbatim instead of expanded per qubit. Regression test added. --- CHANGELOG.md | 4 +++- src/pyqasm/elements.py | 23 +++++++++++++++++++++++ src/pyqasm/pulse/utils.py | 5 +++-- src/pyqasm/transformer.py | 18 +++++++++--------- src/pyqasm/visitor.py | 26 +++++++++----------------- tests/qasm3/test_measurement.py | 31 +++++++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 709cc3a..5dde402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,13 +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. +- 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 be20aa3..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, @@ -79,9 +81,6 @@ from pyqasm.validator import Qasm3Validator logger = logging.getLogger(__name__) - -# Reserved register that physical qubits are consolidated onto for OpenPulse programs. -_INTERNAL_QUBIT_REGISTER = "__PYQASM_QUBITS__" logger.propagate = False @@ -498,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) @@ -577,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 @@ -591,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: @@ -718,7 +717,7 @@ def _resolve_unindexed_reset_qubit(self, statement: qasm3_ast.QuantumReset) -> b if self._openpulse_grammar_declared: # OpenPulse program: rename to the internal virtual register used by the # pulse visitor. - statement.qubits.name = f"__PYQASM_QUBITS__[{qubit_name[1:]}]" + 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 @@ -726,14 +725,7 @@ def _resolve_unindexed_reset_qubit(self, statement: qasm3_ast.QuantumReset) -> b self._register_physical_qubit(qubit_name) return True - # The internal register is the bare name, or the name followed by an index or a - # slice ("__PYQASM_QUBITS__[2]", "__PYQASM_QUBITS__[0:2]"). Matching on the prefix - # alone would also swallow a user register that merely starts with it, e.g. - # "__PYQASM_QUBITS__foo", short-circuiting it out of the unrolling below so its - # qubits would never actually be reset. - return qubit_name == _INTERNAL_QUBIT_REGISTER or qubit_name.startswith( - f"{_INTERNAL_QUBIT_REGISTER}[" - ) + return is_internal_qubit_register(qubit_name) def _visit_reset(self, statement: qasm3_ast.QuantumReset) -> list[qasm3_ast.QuantumReset]: """Visit a reset statement element. 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", [