diff --git a/CHANGELOG.md b/CHANGELOG.md index 5944e26b..495730ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Types of changes: ### 🐛 Bug Fixes +- Fixed `qasm3_to_qir` recording output for results no measurement ever wrote. Recording iterated over qubits, so an unmeasured circuit declared `required_num_results=0` yet read back one result per qubit, and `qir-runner` reported that uninitialised state as a measurement outcome — a Bell state came back as `{"01": 1000}` instead of failing. It also read the wrong registers when a program declared more classical bits than it measured: given `bit[4] c; bit[4] c0; measure q -> c0;`, it recorded the untouched `c` (results 0-3) and discarded the measurement (results 4-7). Recording now follows the result ids an `mz` wrote, for both the base and adaptive profiles. ([#294](https://github.com/qBraid/qbraid-qir/pull/294)) - Fixed `qasm3_to_qir` raising a bare `AssertionError` (with an empty message) for programs that address physical qubits, e.g. `h $0;`. Physical qubits are valid OpenQASM 3 and are what Qiskit emits when a circuit is transpiled against a backend (`qasm3.dumps(transpile(circuit, backend))`), but they survive unrolling as plain `Identifier` nodes rather than `IndexedIdentifier`, which the visitor assumed. They now lower to the QIR qubit of the same index (`$3` is qubit 3), and the entry point declares enough qubits to cover the highest index used. Operands the visitor cannot lower now raise `Qasm3ConversionError` with a message instead of an empty `AssertionError`. ([#290](https://github.com/qBraid/qbraid-qir/pull/290)) - Fixed the cudaq→squin tests (`test_bell_state`, `test_ghz_state`) failing under the typed-pointer pyqir (0.11.x) CI leg. cudaq 0.15+ emits opaque-pointer QIR (QIR 2.0) that only pyqir 0.12+ can parse, so these tests are now gated on `pyqir_uses_opaque_pointers()`. ([#292](https://github.com/qBraid/qbraid-qir/pull/292)) diff --git a/qbraid_qir/profiles/core.py b/qbraid_qir/profiles/core.py index 323f8ca3..dc7b2fe9 100644 --- a/qbraid_qir/profiles/core.py +++ b/qbraid_qir/profiles/core.py @@ -126,13 +126,20 @@ def allow_qubit_use_after_measurement(self) -> bool: return False def record_output_method(self, visitor: QIRVisitor, module: QIRModule) -> None: - """Basic output recording - simple sequential recording.""" + """Basic output recording - simple sequential recording. + + Records only results a measurement actually wrote. Recording one per + *qubit* instead emitted reads of results the program never measured: a + circuit with no measurement declares ``required_num_results=0`` yet + recorded one result per qubit, and qir-runner reported the uninitialised + state it found there as if it were a measurement outcome. + """ if visitor._record_output is False: return assert visitor._llvm_module is not None assert visitor._builder is not None i8p = pyqir.PointerType(pyqir.IntType(visitor._llvm_module.context, 8)) - for i in range(module.num_qubits): + for i in sorted(visitor._measured_results): result_ref = pyqir.result(visitor._llvm_module.context, i) pyqir.rt.result_record_output(visitor._builder, result_ref, pyqir.Constant.null(i8p)) @@ -174,7 +181,17 @@ def allow_qubit_use_after_measurement(self) -> bool: return True def record_output_method(self, visitor: QIRVisitor, module: QIRModule) -> None: - """Adaptive profile output recording - preserves register structure.""" + """Adaptive profile output recording - preserves register structure. + + Records only results a measurement actually wrote, which is what + ``_clbit_labels`` alone cannot tell us: it is populated when a register is + *declared*, so a bit that was never measured still has a label. Recording it + makes the runtime report an uninitialised result as a measurement outcome. + + A register with no measured bits is skipped entirely rather than recorded as + an empty array, and the array length counts the bits actually recorded -- + emitting the declared width would promise elements that never follow. + """ if not visitor._record_output: return assert visitor._llvm_module is not None @@ -183,30 +200,29 @@ def record_output_method(self, visitor: QIRVisitor, module: QIRModule) -> None: null_ptr = pyqir.Constant.null(i8p) recorded_ids = set() - # If we have register structure information, use it - if hasattr(visitor, "_global_creg_size_map") and visitor._global_creg_size_map: - # Record output grouped by register to preserve structure - for reg_name, reg_size in visitor._global_creg_size_map.items(): - # Record array for each register - pyqir.rt.array_record_output( - visitor._builder, - pyqir.const(pyqir.IntType(visitor._llvm_module.context, 64), reg_size), - null_ptr, - ) - # Record individual results within the register (inverted order) - for i in range(reg_size - 1, -1, -1): - bit_label = f"{reg_name}_{i}" - if bit_label in visitor._clbit_labels: - bit_id = visitor._clbit_labels[bit_label] - if bit_id not in recorded_ids: - result_ref = pyqir.result(visitor._llvm_module.context, bit_id) - pyqir.rt.result_record_output(visitor._builder, result_ref, null_ptr) - recorded_ids.add(bit_id) - else: - # Fallback to simple sequential recording - for i in range(module.num_qubits): - result_ref = pyqir.result(visitor._llvm_module.context, i) + # Record output grouped by register to preserve structure + for reg_name, reg_size in visitor._global_creg_size_map.items(): + # Collect first (inverted order), so the array length matches what follows. + measured_ids = [] + for i in range(reg_size - 1, -1, -1): + bit_id = visitor._clbit_labels.get(f"{reg_name}_{i}") + if bit_id is None or bit_id in recorded_ids: + continue + if bit_id in visitor._measured_results: + measured_ids.append(bit_id) + + if not measured_ids: + continue + + pyqir.rt.array_record_output( + visitor._builder, + pyqir.const(pyqir.IntType(visitor._llvm_module.context, 64), len(measured_ids)), + null_ptr, + ) + for bit_id in measured_ids: + result_ref = pyqir.result(visitor._llvm_module.context, bit_id) pyqir.rt.result_record_output(visitor._builder, result_ref, null_ptr) + recorded_ids.add(bit_id) class ProfileRegistry: diff --git a/qbraid_qir/qasm3/visitor.py b/qbraid_qir/qasm3/visitor.py index 5914da84..6dc67c22 100644 --- a/qbraid_qir/qasm3/visitor.py +++ b/qbraid_qir/qasm3/visitor.py @@ -333,6 +333,10 @@ def _visit_measurement(self, statement: qasm3_ast.QuantumMeasurementStatement) - measurement_func(self._builder, src_id, tgt_id) + result_id = pointer_id(tgt_id) + if result_id is not None: + self._measured_results.add(result_id) + def _visit_reset(self, statement: qasm3_ast.QuantumReset) -> None: """Visit a reset statement element. diff --git a/qbraid_qir/visitor.py b/qbraid_qir/visitor.py index 768d5ace..ca0d4cda 100644 --- a/qbraid_qir/visitor.py +++ b/qbraid_qir/visitor.py @@ -32,3 +32,8 @@ def __init__(self) -> None: self._builder: Optional[pyqir.Builder] = None self._clbit_labels: dict[str, int] = {} self._global_creg_size_map: dict[str, int] = {} + # Result ids actually written by a measurement. Output recording is driven by + # this rather than by a qubit or clbit count: a %Result only holds a value once + # mz writes it, so recording an id absent from this set reads uninitialised + # runtime state. Subclasses that emit measurements must populate it. + self._measured_results: set[int] = set() diff --git a/tests/qasm3_qir/converter/test_adaptive.py b/tests/qasm3_qir/converter/test_adaptive.py index 6c95b17c..af47b6f4 100644 --- a/tests/qasm3_qir/converter/test_adaptive.py +++ b/tests/qasm3_qir/converter/test_adaptive.py @@ -18,6 +18,7 @@ from qbraid_qir.qasm3 import qasm3_to_qir from tests.qir_utils import ( + array_record_output_string, check_adaptive_gate_set, check_adaptive_profile_compliance, check_attributes, @@ -413,3 +414,78 @@ def test_read_result_functionality(): generated_qir = str(result).splitlines() check_read_result_calls(generated_qir, 1, [0]) + + +def test_adaptive_no_measurement_records_no_results(): + """No classical register means nothing was measured, so nothing is recorded. + + The adaptive profile previously fell back to recording one result per qubit + when no register structure was present, which made the runtime report + uninitialised results as measurement outcomes. + """ + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + + qubit[2] q; + + h q[0]; + cx q[0], q[1]; + """ + + generated_qir = str(qasm3_to_qir(qasm3_string, profile="adaptive")).splitlines() + + # Absence of record_output calls is not enough on its own: the module must also + # declare that it needs no result slots, and must not read any back. + check_attributes(generated_qir, 2, 0) + check_read_result_calls(generated_qir, 0, []) + recorded = [line for line in generated_qir if "record_output" in line and "call" in line] + assert recorded == [] + + +def test_adaptive_records_only_measured_bits_of_a_register(): + """A declared-but-unmeasured bit is never recorded, and the array length matches. + + ``_clbit_labels`` is populated when a register is declared, so it cannot answer + "was this measured?". Grouping on it alone recorded every declared bit: this + program measured one bit of three and emitted three result records under an + ``array_record_output(i64 3)``, handing the runtime two uninitialised slots and + an array whose length promised elements that never arrived. + """ + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + + qubit[3] q; + bit[3] c; + + h q[0]; + c[0] = measure q[0]; + """ + + generated_qir = str(qasm3_to_qir(qasm3_string, profile="adaptive")).splitlines() + + assert array_record_output_string(1) in "\n".join(generated_qir) + recorded = [line for line in generated_qir if "result_record_output" in line and "call" in line] + assert len(recorded) == 1 + + +def test_adaptive_skips_a_register_with_no_measured_bits(): + """A register nothing wrote to is omitted, not recorded as an empty array.""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + + qubit[2] q; + bit[1] used; + bit[2] unused; + + h q[0]; + used[0] = measure q[0]; + """ + + generated_qir = str(qasm3_to_qir(qasm3_string, profile="adaptive")).splitlines() + + arrays = [line for line in generated_qir if "array_record_output" in line and "call" in line] + assert len(arrays) == 1 + assert array_record_output_string(1) in "\n".join(generated_qir) diff --git a/tests/qasm3_qir/converter/test_measurement.py b/tests/qasm3_qir/converter/test_measurement.py index 555849d9..c1220596 100644 --- a/tests/qasm3_qir/converter/test_measurement.py +++ b/tests/qasm3_qir/converter/test_measurement.py @@ -49,3 +49,57 @@ def test_measure(): bit_list = [0, 1, 0, 1, 2, 1, 1, 0] check_measure_op(generated_qir, 8, qubit_list, bit_list) + + +def _recorded_results(generated_qir: list[str]) -> list[str]: + return [line for line in generated_qir if "result_record_output" in line and "call" in line] + + +def test_no_measurement_records_no_results(): + """A circuit with no measurement must record nothing. + + Recording one result per qubit here produced a module declaring + required_num_results=0 while reading back two results, and qir-runner + reported that uninitialised state as a measurement outcome — a Bell state + submitted from the circuit composer came back as {"01": 1000}. + """ + qasm3_string = """ + OPENQASM 3; + include "stdgates.inc"; + + qubit[2] q; + + h q[0]; + cx q[0], q[1]; + """ + + generated_qir = str(qasm3_to_qir(qasm3_string)).splitlines() + check_attributes(generated_qir, 2, 0) + assert _recorded_results(generated_qir) == [] + + +def test_records_only_measured_results(): + """Recording follows the results mz wrote, not the declared bit count. + + Here `c` is declared but never measured and `c0` holds the measurement, so + the recorded results must be c0's (4-7). Iterating qubits instead recorded + 0-3, reporting the untouched `c` register and discarding the real outcome. + """ + qasm3_string = """ + OPENQASM 3; + include "stdgates.inc"; + + qubit[4] q; + bit[4] c; + bit[4] c0; + + h q; + measure q -> c0; + """ + + generated_qir = str(qasm3_to_qir(qasm3_string)).splitlines() + check_attributes(generated_qir, 4, 8) + recorded = _recorded_results(generated_qir) + assert len(recorded) == 4 + for index, line in zip([4, 5, 6, 7], recorded): + assert f"i64 {index} to" in line, f"expected result {index}, got: {line.strip()}" diff --git a/tests/qasm3_qir/fixtures/resources/complex_if_opaque.ll b/tests/qasm3_qir/fixtures/resources/complex_if_opaque.ll index 26e6a544..c1911fb0 100644 --- a/tests/qasm3_qir/fixtures/resources/complex_if_opaque.ll +++ b/tests/qasm3_qir/fixtures/resources/complex_if_opaque.ll @@ -51,10 +51,10 @@ else5: ; preds = %continue br label %continue6 continue6: ; preds = %else5, %then4 - call void @__quantum__rt__result_record_output(ptr null, ptr null) - call void @__quantum__rt__result_record_output(ptr inttoptr (i64 1 to ptr), ptr null) - call void @__quantum__rt__result_record_output(ptr inttoptr (i64 2 to ptr), ptr null) - call void @__quantum__rt__result_record_output(ptr inttoptr (i64 3 to ptr), ptr null) + call void @__quantum__rt__result_record_output(ptr inttoptr (i64 4 to ptr), ptr null) + call void @__quantum__rt__result_record_output(ptr inttoptr (i64 5 to ptr), ptr null) + call void @__quantum__rt__result_record_output(ptr inttoptr (i64 6 to ptr), ptr null) + call void @__quantum__rt__result_record_output(ptr inttoptr (i64 7 to ptr), ptr null) ret void } diff --git a/tests/qasm3_qir/fixtures/resources/complex_if_typed.ll b/tests/qasm3_qir/fixtures/resources/complex_if_typed.ll index 2c8ac57a..1f2d3104 100644 --- a/tests/qasm3_qir/fixtures/resources/complex_if_typed.ll +++ b/tests/qasm3_qir/fixtures/resources/complex_if_typed.ll @@ -54,10 +54,10 @@ else5: ; preds = %continue br label %continue6 continue6: ; preds = %else5, %then4 - call void @__quantum__rt__result_record_output(%Result* null, i8* null) - call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 1 to %Result*), i8* null) - call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 2 to %Result*), i8* null) - call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 3 to %Result*), i8* null) + call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 4 to %Result*), i8* null) + call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 5 to %Result*), i8* null) + call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 6 to %Result*), i8* null) + call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 7 to %Result*), i8* null) ret void } diff --git a/tests/qir_utils.py b/tests/qir_utils.py index 6802eb67..20db68aa 100644 --- a/tests/qir_utils.py +++ b/tests/qir_utils.py @@ -406,14 +406,14 @@ def check_three_qubit_gate_op( def _validate_simple_custom_op(entry_body: list[str]): + # No result recording: the fixture declares no classical register, so the + # entry point declares required_num_results=0 and there is nothing to read. custom_op_lines = [ initialize_call_string(), single_op_call_string("h", 0), single_op_call_string("z", 1), rotation_call_string("rx", 1.1, 0), double_op_call_string("cnot", 0, 1), - result_record_output_string(0), - result_record_output_string(1), return_string(), ] @@ -431,8 +431,6 @@ def _validate_nested_custom_op(entry_body: list[str]): double_op_call_string("cnot", 0, 1), rotation_call_string("rx", 4.8, 1), rotation_call_string("ry", 5, 1), - result_record_output_string(0), - result_record_output_string(1), return_string(), ] @@ -450,8 +448,6 @@ def _validate_complex_custom_op(entry_body: list[str]): rotation_call_string("ry", 0.1, 0), rotation_call_string("rz", 0.2, 0), double_op_call_string("cnot", 0, 1), - result_record_output_string(0), - result_record_output_string(1), return_string(), ]