Repository: qBraid/pyqasm

Issue:
Assertion error on valid-standalone measurement
In OpenQASM grammar, it seems that it might not be required that measurement statements make reference to a classical (target) qubit.

The [openqasm3.ast.QuantumMeasurementStatement](https://github.com/openqasm/openqasm/blob/bb923eb9a84fdffe1ba6fc3c20d0b47a131523d9/source/openqasm/openqasm3/ast.py#L582-L590) is documented as:

> Stand-alone statement of a quantum measurement, *potentially* assigning the result to a classical variable.

and specifies field `target` of type `Optional[Union[IndexedIdentifier, Identifier]]`.

This makes me think that specifying a target (classical) bit might not be semantically necessary.

If this is true, the following should be valid:

```openqasm
OPENQASM 3.0;
include "stdgates.inc";

qubit[3] q;
bit[3] b;

h q[0];
cx q[0], q[1];
measure q;
```



However, in `pyqasm.unroll()`, it raises an `AssertionError`:

https://github.com/qBraid/pyqasm/blob/0fd2956c729c637df6669a7d4a83e3d6725e36b8/src/pyqasm/visitor.py#L441-L443

Hints from issue comments:
@arulandu: Yeah, this example is valid. Working on it

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e00bcb6..9920a25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ Types of changes:
 ## Unreleased
 
 ### Added
+- Added support for standalone measurements that do not store the result in a classical register ([#141](https://github.com/qBraid/pyqasm/pull/141)).
 
 ### Improved / Modified
 - Re-wrote the `QasmAnalyzer.extract_qasm_version` method so that it extracts the program version just by looking at the [first non-comment line](https://github.com/openqasm/openqasm/blob/bb923eb9a84fdffe1ba6fc3c20d0b47a131523d9/source/language/comments.rst#version-string), instead of parsing the entire program ([#140](https://github.com/qBraid/pyqasm/pull/140)).
diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py
index 8da003e..ea3c4b1 100644
--- a/src/pyqasm/visitor.py
+++ b/src/pyqasm/visitor.py
@@ -343,8 +343,11 @@ def _get_op_bits(
         original_size_map = reg_size_map
 
         if isinstance(operation, qasm3_ast.QuantumMeasurementStatement):
-            assert operation.target is not None
-            bit_list = [operation.measure.qubit] if qubits else [operation.target]
+            if qubits:
+                bit_list = [operation.measure.qubit]
+            else:
+                assert operation.target is not None
+                bit_list = [operation.target]
         elif isinstance(operation, qasm3_ast.QuantumPhase) and operation.qubits is None:
             for reg_name, reg_size in reg_size_map.items():
                 bit_list.append(
@@ -440,7 +443,6 @@ def _visit_measurement(  # pylint: disable=too-many-locals
 
         source = statement.measure.qubit
         target = statement.target
-        assert source and target
 
         # # TODO: handle in-function measurements
         source_name: str = (
@@ -453,51 +455,67 @@ def _visit_measurement(  # pylint: disable=too-many-locals
                 span=statement.span,
             )
 
-        target_name: str = (
-            target.name if isinstance(target, qasm3_ast.Identifier) else target.name.name
-        )
-        if target_name not in self._global_creg_size_map:
-            raise_qasm3_error(
-                f"Missing register declaration for {target_name} in measurement "
-                f"operation {statement}",
-                span=statement.span,
-            )
-
         source_ids = self._get_op_bits(
             statement, reg_size_map=self._global_qreg_size_map, qubits=True
         )
-        target_ids = self._get_op_bits(
-            statement, reg_size_map=self._global_creg_size_map, qubits=False
-        )
 
-        if len(source_ids) != len(target_ids):
-            raise_qasm3_error(
-                f"Register sizes of {source_name} and {target_name} do not match "
-                "for measurement operation",
-                span=statement.span,
-            )
         unrolled_measurements = []
-        for src_id, tgt_id in zip(source_ids, target_ids):
-            unrolled_measure = qasm3_ast.QuantumMeasurementStatement(
-                measure=qasm3_ast.QuantumMeasurement(qubit=src_id), target=tgt_id
+
+        if not target:
+            for src_id in source_ids:
+                unrolled_measurements.append(
+                    qasm3_ast.QuantumMeasurementStatement(
+                        measure=qasm3_ast.QuantumMeasurement(qubit=src_id), target=None
+                    )
+                )
+                src_name, src_id = src_id.name.name, src_id.indices[0][0].value  # type: ignore
+                qubit_node = self._module._qubit_depths[(src_name, src_id)]
+                qubit_node.depth += 1
+                qubit_node.num_measurements += 1
+        else:
+            target_name: str = (
+                target.name if isinstance(target, qasm3_ast.Identifier) else target.name.name
             )
-            src_name, src_id = src_id.name.name, src_id.indices[0][0].value  # type: ignore
-            tgt_name, tgt_id = tgt_id.name.name, tgt_id.indices[0][0].value  # type: ignore
+            if target_name not in self._global_creg_size_map:
+                raise_qasm3_error(
+                    f"Missing register declaration for {target_name} in measurement "
+                    f"operation {statement}",
+                    span=statement.span,
+                )
 
-            qubit_node, clbit_node = (
-                self._module._qubit_depths[(src_name, src_id)],
-                self._module._clbit_depths[(tgt_name, tgt_id)],
+            target_ids = self._get_op_bits(
+                statement, reg_size_map=self._global_creg_size_map, qubits=False
             )
-            qubit_node.depth += 1
-            qubit_node.num_measurements += 1
 
-            clbit_node.depth += 1
-            clbit_node.num_measurements += 1
+            if len(source_ids) != len(target_ids):
+                raise_qasm3_error(
+                    f"Register sizes of {source_name} and {target_name} do not match "
+                    "for measurement operation",
+                    span=statement.span,
+                )
+
+            for src_id, tgt_id in zip(source_ids, target_ids):
+                unrolled_measure = qasm3_ast.QuantumMeasurementStatement(
+                    measure=qasm3_ast.QuantumMeasurement(qubit=src_id),
+                    target=tgt_id if target else None,
+                )
+                src_name, src_id = src_id.name.name, src_id.indices[0][0].value  # type: ignore
+                tgt_name, tgt_id = tgt_id.name.name, tgt_id.indices[0][0].value  # type: ignore
+
+                qubit_node, clbit_node = (
+                    self._module._qubit_depths[(src_name, src_id)],
+                    self._module._clbit_depths[(tgt_name, tgt_id)],
+                )
+                qubit_node.depth += 1
+                qubit_node.num_measurements += 1
+
+                clbit_node.depth += 1
+                clbit_node.num_measurements += 1
 
-            qubit_node.depth = max(qubit_node.depth, clbit_node.depth)
-            clbit_node.depth = max(qubit_node.depth, clbit_node.depth)
+                qubit_node.depth = max(qubit_node.depth, clbit_node.depth)
+                clbit_node.depth = max(qubit_node.depth, clbit_node.depth)
 
-            unrolled_measurements.append(unrolled_measure)
+                unrolled_measurements.append(unrolled_measure)
 
         if self._check_only:
             return []


Test patch:
diff --git a/tests/qasm3/test_measurement.py b/tests/qasm3/test_measurement.py
index 54bee92..b72c81f 100644
--- a/tests/qasm3/test_measurement.py
+++ b/tests/qasm3/test_measurement.py
@@ -160,7 +160,28 @@ def test_init_measure():
 
     module = loads(qasm3_string)
     module.unroll()
-    print(dumps(module))
+    check_unrolled_qasm(dumps(module), expected_qasm)
+
+
+def test_standalone_measurement():
+    qasm3_string = """
+    OPENQASM 3.0;
+    qubit[2] q;
+    h q;
+    measure q;
+    """
+
+    expected_qasm = """
+    OPENQASM 3.0;
+    qubit[2] q;
+    h q[0];
+    h q[1];
+    measure q[0];
+    measure q[1];
+    """
+
+    module = loads(qasm3_string)
+    module.unroll()
     check_unrolled_qasm(dumps(module), expected_qasm)