Skip to content

Commit 84e4746

Browse files
authored
Add physical qubit support for plain QASM 3 programs (#291)
* Add physical qubit support for plain QASM 3 programs * Update CHANGELOG with PR #291 * fix: format
1 parent 4c201d8 commit 84e4746

6 files changed

Lines changed: 223 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Types of changes:
2626
### Removed
2727

2828
### Fixed
29+
- 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))
2930
- Updated CI to use `macos-15-intel` image due to deprecation of `macos-13` image. ([#283](https://github.com/qBraid/pyqasm/pull/283))
3031

3132
### Dependencies

src/pyqasm/analyzer.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -251,21 +251,26 @@ def extract_qasm_version(qasm: str) -> float: # type: ignore[return]
251251
raise_qasm3_error("Could not determine the OpenQASM version.", err_type=QasmParsingError)
252252

253253
@staticmethod
254-
def extract_duplicate_qubit(qubit_list: list[IndexedIdentifier]):
254+
def extract_duplicate_qubit(qubit_list: list[IndexedIdentifier | Identifier]):
255255
"""
256256
Extracts the duplicate qubit from a list of qubits.
257257
258258
Args:
259-
qubit_list (list[IndexedIdentifier]): The list of qubits.
259+
qubit_list (list[IndexedIdentifier | Identifier]): The list of qubits.
260260
261261
Returns:
262262
tuple(string, int): The duplicate qubit name and id.
263263
"""
264264
qubit_set = set()
265265
for qubit in qubit_list:
266-
assert isinstance(qubit, IndexedIdentifier)
267-
qubit_name = qubit.name.name
268-
qubit_id = qubit.indices[0][0].value # type: ignore
266+
if isinstance(qubit, Identifier):
267+
# Physical qubit: name is "$n", identity is the name itself.
268+
qubit_name = qubit.name
269+
qubit_id = int(qubit.name[1:])
270+
else:
271+
assert isinstance(qubit, IndexedIdentifier)
272+
qubit_name = qubit.name.name
273+
qubit_id = qubit.indices[0][0].value # type: ignore
269274
if (qubit_name, qubit_id) in qubit_set:
270275
return (qubit_name, qubit_id)
271276
qubit_set.add((qubit_name, qubit_id))

src/pyqasm/transformer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,14 @@ def get_qubits_from_range_definition(
166166

167167
@staticmethod
168168
def transform_gate_qubits(
169-
gate_op: QuantumGate | QuantumPhase, qubit_map: dict[str, IndexedIdentifier]
169+
gate_op: QuantumGate | QuantumPhase,
170+
qubit_map: dict[str, IndexedIdentifier | Identifier],
170171
) -> None:
171172
"""Transform the qubits of a gate operation with a qubit map.
172173
173174
Args:
174175
gate_op (QuantumGate): The gate operation to transform.
175-
qubit_map (dict[str, IndexedIdentifier]): The qubit map to use for transformation.
176+
qubit_map: Maps qubits to their transformed identifiers.
176177
177178
Returns:
178179
None

src/pyqasm/visitor.py

Lines changed: 104 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from collections import OrderedDict, deque
2727
from functools import partial
2828
from io import StringIO
29-
from typing import Any, Callable, Optional, Sequence, cast
29+
from typing import Any, Callable, Optional, Sequence, Union, cast
3030

3131
import numpy as np
3232
import openqasm3.ast as qasm3_ast
@@ -271,22 +271,21 @@ def _visit_quantum_register(
271271
return []
272272
return [register]
273273

274-
# pylint: disable-next=too-many-locals,too-many-branches
274+
# pylint: disable-next=too-many-locals,too-many-branches,too-many-statements
275275
def _get_op_bits(
276276
self,
277277
operation: Any,
278278
qubits: bool = True,
279279
function_qubit_sizes: Optional[dict[str, int]] = None,
280-
) -> list[qasm3_ast.IndexedIdentifier]:
280+
) -> list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]]:
281281
"""Get the quantum / classical bits for the operation.
282-
283282
Args:
284283
operation (Any): The operation to get qubits for.
285284
qubits (bool): Whether the bits are quantum bits or classical bits. Defaults to True.
286285
Returns:
287-
list[qasm3_ast.IndexedIdentifier] : The bits for the operation.
286+
The quantum or classical bits for the operation.
288287
"""
289-
openqasm_bits = []
288+
openqasm_bits: list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]] = []
290289
bit_list = []
291290

292291
if isinstance(operation, qasm3_ast.QuantumMeasurementStatement):
@@ -316,6 +315,20 @@ def _get_op_bits(
316315
else:
317316
reg_name = bit.name
318317

318+
if qubits and reg_name.startswith("$"):
319+
# Physical qubit reference (e.g. $0, $1).
320+
if not reg_name[1:].isdigit():
321+
raise_qasm3_error(
322+
f"Invalid physical qubit identifier '{reg_name}': "
323+
f"expected a non-negative integer index after '$'",
324+
error_node=operation,
325+
span=operation.span,
326+
)
327+
self._register_physical_qubit(reg_name)
328+
# Keep as an Identifier so it serialises as "$0" rather than "$0[0]".
329+
openqasm_bits.append(qasm3_ast.Identifier(reg_name))
330+
continue
331+
319332
max_register_size = 0
320333
reg_var = self._scope_manager.get_from_visible_scope(reg_name)
321334
if reg_var is None:
@@ -374,7 +387,6 @@ def _get_op_bits(
374387
)
375388
for bit_id in bit_ids
376389
]
377-
378390
openqasm_bits.extend(new_bits)
379391

380392
return openqasm_bits
@@ -558,17 +570,33 @@ def _visit_measurement( # pylint: disable=too-many-locals,too-many-branches,too
558570
if isinstance(source, qasm3_ast.Identifier):
559571
is_pulse_gate = False
560572
if source.name.startswith("$") and source.name[1:].isdigit():
561-
is_pulse_gate = True
562-
statement.measure.qubit.name = f"__PYQASM_QUBITS__[{source.name[1:]}]"
573+
if self._openpulse_grammar_declared:
574+
# OpenPulse program: rename to the internal virtual register used by the
575+
# pulse visitor, and validate the index is in range.
576+
is_pulse_gate = True
577+
statement.measure.qubit.name = f"__PYQASM_QUBITS__[{source.name[1:]}]"
578+
if (
579+
self._total_pulse_qubits <= 0
580+
and sum(self._global_qreg_size_map.values()) == 0
581+
):
582+
raise_qasm3_error(
583+
"Invalid no of qubits in pulse level measurement",
584+
error_node=statement,
585+
span=statement.span,
586+
)
587+
else:
588+
# Plain QASM program: keep the physical qubit identifier as-is.
589+
is_pulse_gate = True
590+
self._register_physical_qubit(source.name)
563591
elif source.name.startswith("__PYQASM_QUBITS__"):
564592
is_pulse_gate = True
565593
statement.measure.qubit.name = source.name
566-
if self._total_pulse_qubits <= 0 and sum(self._global_qreg_size_map.values()) == 0:
567-
raise_qasm3_error(
568-
"Invalid no of qubits in pulse level measurement",
569-
error_node=statement,
570-
span=statement.span,
571-
)
594+
if self._total_pulse_qubits <= 0 and sum(self._global_qreg_size_map.values()) == 0:
595+
raise_qasm3_error(
596+
"Invalid no of qubits in pulse level measurement",
597+
error_node=statement,
598+
span=statement.span,
599+
)
572600
if is_pulse_gate:
573601
return [statement]
574602
# # TODO: handle in-function measurements
@@ -748,12 +776,20 @@ def _visit_barrier( # pylint: disable=too-many-locals, too-many-branches
748776
for op_qubit in barrier.qubits:
749777
if isinstance(op_qubit, qasm3_ast.Identifier):
750778
if op_qubit.name.startswith("$") and op_qubit.name[1:].isdigit():
751-
if int(op_qubit.name[1:]) >= self._total_pulse_qubits:
752-
raise_qasm3_error(
753-
f"Invalid pulse qubit index `{op_qubit.name}` on barrier",
754-
error_node=barrier,
755-
span=barrier.span,
756-
)
779+
phys_idx = int(op_qubit.name[1:])
780+
# In an OpenPulse program all physical qubits are declared up-front via
781+
# defcal; validate that the index is within the known range.
782+
# In a plain QASM program there are no such declarations, so any
783+
# non-negative index is valid.
784+
if self._openpulse_grammar_declared:
785+
if phys_idx >= self._total_pulse_qubits:
786+
raise_qasm3_error(
787+
f"Invalid pulse qubit index `{op_qubit.name}` on barrier",
788+
error_node=barrier,
789+
span=barrier.span,
790+
)
791+
else:
792+
self._register_physical_qubit(op_qubit.name)
757793
valid_open_pulse_qubits = True
758794
if valid_open_pulse_qubits:
759795
return [barrier]
@@ -868,7 +904,7 @@ def _visit_gate_definition(self, definition: qasm3_ast.QuantumGateDefinition) ->
868904

869905
def _unroll_multiple_target_qubits(
870906
self, operation: qasm3_ast.QuantumGate, gate_qubit_count: int
871-
) -> list[list[qasm3_ast.IndexedIdentifier]]:
907+
) -> list[list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]]]:
872908
"""Unroll the complete list of all qubits that the given operation is applied to.
873909
E.g. this maps 'cx q[0], q[1], q[2], q[3]' to [[q[0], q[1]], [q[2], q[3]]]
874910
@@ -895,7 +931,7 @@ def _unroll_multiple_target_qubits(
895931
def _broadcast_gate_operation(
896932
self,
897933
gate_function: Callable,
898-
all_targets: list[list[qasm3_ast.IndexedIdentifier]],
934+
all_targets: list[list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]]],
899935
ctrls: Optional[list[qasm3_ast.IndexedIdentifier]] = None,
900936
) -> list[qasm3_ast.QuantumGate]:
901937
"""Broadcasts the application of a gate onto multiple sets of target qubits.
@@ -917,9 +953,42 @@ def _broadcast_gate_operation(
917953
result.extend(gate_function(*ctrls, *targets))
918954
return result
919955

956+
def _register_physical_qubit(self, name: str) -> int:
957+
"""Register a physical qubit ``$n`` for depth / count tracking if not already known.
958+
959+
Args:
960+
name: The physical qubit identifier string (e.g. ``"$0"``).
961+
962+
Returns:
963+
The physical qubit index.
964+
"""
965+
phys_idx = int(name[1:])
966+
if (name, phys_idx) not in self._module._qubit_depths:
967+
self._module._qubit_depths[(name, phys_idx)] = QubitDepthNode(name, phys_idx)
968+
self._total_pulse_qubits = max(self._total_pulse_qubits, phys_idx + 1)
969+
self._module.num_qubits = max(self._module.num_qubits, phys_idx + 1)
970+
return phys_idx
971+
972+
@staticmethod
973+
def _get_qubit_name_and_id(
974+
qubit: qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier,
975+
) -> tuple[str, int]:
976+
"""Return (register_name, qubit_index) for virtual or physical qubits.
977+
978+
Physical qubits are represented as ``Identifier("$n")`` and carry their
979+
index in the name itself. Virtual qubits are ``IndexedIdentifier`` with
980+
an explicit index in ``.indices``.
981+
"""
982+
if isinstance(qubit, qasm3_ast.Identifier):
983+
# Physical qubit: name is "$n", index is n.
984+
return qubit.name, int(qubit.name[1:])
985+
assert isinstance(qubit.indices[0], list)
986+
qubit_id = Qasm3ExprEvaluator.evaluate_expression(qubit.indices[0][0])[0] # type: ignore
987+
return qubit.name.name, qubit_id
988+
920989
def _update_qubit_depth_for_gate(
921990
self,
922-
all_targets: list[list[qasm3_ast.IndexedIdentifier]],
991+
all_targets: list[list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]]],
923992
ctrls: list[qasm3_ast.IndexedIdentifier],
924993
):
925994
"""Updates the depth of the circuit after applying a broadcasted gate.
@@ -934,18 +1003,14 @@ def _update_qubit_depth_for_gate(
9341003
for qubit_subset in all_targets:
9351004
max_involved_depth = 0
9361005
for qubit in qubit_subset + ctrls:
937-
assert isinstance(qubit.indices[0], list)
938-
_qid_ = qubit.indices[0][0]
939-
qubit_id = Qasm3ExprEvaluator.evaluate_expression(_qid_)[0] # type: ignore
940-
qubit_node = self._module._qubit_depths[(qubit.name.name, qubit_id)]
1006+
qubit_name, qubit_id = self._get_qubit_name_and_id(qubit)
1007+
qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
9411008
qubit_node.num_gates += 1
9421009
max_involved_depth = max(max_involved_depth, qubit_node.depth + 1)
9431010

9441011
for qubit in qubit_subset + ctrls:
945-
assert isinstance(qubit.indices[0], list)
946-
_qid_ = qubit.indices[0][0]
947-
qubit_id = Qasm3ExprEvaluator.evaluate_expression(_qid_)[0] # type: ignore
948-
qubit_node = self._module._qubit_depths[(qubit.name.name, qubit_id)]
1012+
qubit_name, qubit_id = self._get_qubit_name_and_id(qubit)
1013+
qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
9491014
qubit_node.depth = max_involved_depth
9501015

9511016
# pylint: disable=too-many-branches, too-many-locals
@@ -1043,12 +1108,8 @@ def _visit_basic_gate_operation(
10431108
# get qreg in branching operations
10441109
for qubit_subset in unrolled_targets + [ctrls]:
10451110
for qubit in qubit_subset:
1046-
assert isinstance(qubit.indices, list) and len(qubit.indices) > 0
1047-
assert isinstance(qubit.indices[0], list) and len(qubit.indices[0]) > 0
1048-
qubit_idx = Qasm3ExprEvaluator.evaluate_expression(qubit.indices[0][0])[
1049-
0
1050-
]
1051-
self._is_branch_qubits.add((qubit.name.name, qubit_idx))
1111+
qubit_name, qubit_idx = QasmVisitor._get_qubit_name_and_id(qubit)
1112+
self._is_branch_qubits.add((qubit_name, qubit_idx))
10521113

10531114
# check for duplicate bits
10541115
for final_gate in result:
@@ -1096,9 +1157,9 @@ def _visit_custom_gate_operation(
10961157
ctrls = []
10971158
gate_name: str = operation.name.name
10981159
gate_definition: qasm3_ast.QuantumGateDefinition = self._custom_gates[gate_name]
1099-
op_qubits: list[qasm3_ast.IndexedIdentifier] = self._get_op_bits(
1100-
operation, qubits=True
1101-
) # type: ignore [assignment]
1160+
op_qubits: list[Union[qasm3_ast.IndexedIdentifier, qasm3_ast.Identifier]] = (
1161+
self._get_op_bits(operation, qubits=True)
1162+
)
11021163

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

11721231
self._scope_manager.pop_scope()
11731232
self._scope_manager.restore_context()

tests/qasm3/resources/gates.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,70 @@ def test_fixture():
480480
"gate custom_gate(a, b) p, q {",
481481
),
482482
}
483+
484+
485+
# ── Physical-qubit tests ────────────────────────────────────────────────────
486+
487+
PHYSICAL_QUBIT_VALID_TESTS = {
488+
"basic_gates": (
489+
"""
490+
OPENQASM 3.0;
491+
include "stdgates.inc";
492+
x $0;
493+
h $1;
494+
cx $0, $1;
495+
""",
496+
2,
497+
0,
498+
),
499+
"custom_gate": (
500+
"""
501+
OPENQASM 3.0;
502+
include "stdgates.inc";
503+
gate prx(_gate_p_0, _gate_p_1) _gate_q_0 {
504+
rz(0) _gate_q_0;
505+
rx(pi/2) _gate_q_0;
506+
rz(0) _gate_q_0;
507+
}
508+
bit[2] meas;
509+
prx(pi/2, 0) $0;
510+
prx(pi/2, 0) $0;
511+
prx(pi/2, 0) $0;
512+
barrier $0, $1;
513+
meas[0] = measure $0;
514+
meas[1] = measure $1;
515+
""",
516+
2,
517+
2,
518+
),
519+
"barrier_only": (
520+
"""
521+
OPENQASM 3.0;
522+
include "stdgates.inc";
523+
barrier $0, $1, $2;
524+
""",
525+
3,
526+
0,
527+
),
528+
"measure_physical": (
529+
"""
530+
OPENQASM 3.0;
531+
include "stdgates.inc";
532+
bit[1] c;
533+
h $0;
534+
c[0] = measure $0;
535+
""",
536+
1,
537+
1,
538+
),
539+
"non_contiguous_indices": (
540+
"""
541+
OPENQASM 3.0;
542+
include "stdgates.inc";
543+
x $0;
544+
x $3;
545+
""",
546+
4,
547+
0,
548+
),
549+
}

0 commit comments

Comments
 (0)