From 630f2ce9a908b4622c43b04d20d30e94104bbaa3 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Thu, 13 Aug 2026 14:57:21 -0500 Subject: [PATCH 1/5] fix(qasm3): record output only for results a measurement wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output recording iterated over qubits, so it emitted result_record_output for results no mz ever produced. Two distinct symptoms, same cause: A circuit with no measurement emitted zero mz instructions yet recorded one result per qubit, giving a module that declares required_num_results=0 while reading back N results. That is spec-invalid QIR, and LLVM's verifier does not catch it because the violation is at QIR profile level. qir-runner reports whatever uninitialised state it finds as a measurement outcome, so such programs returned confident wrong counts instead of failing: a Bell state submitted from the circuit composer came back as {"01": 1000} and a GHZ state as {"010": 1000}, both marked COMPLETED. When a program declared more classical bits than it measured, recording followed the qubit count and read the wrong registers. Given qubit[4] q; bit[4] c; bit[4] c0; h q; measure q -> c0; mz writes results 4-7, but recording emitted 0-3 — reporting the untouched `c` register and discarding the actual measurement. The complex_if reference .ll files encoded that output, so they are regenerated here. Track the result ids mz writes and record exactly those, in both the base profile and the adaptive profile's fallback branch. Only the qasm3 frontend routes through profiles/core.py; the qiskit and cirq visitors have their own record_output and were already correct. The custom-op validators in qir_utils asserted required_num_results=0 and two result_record_output calls in the same test, pinning the invalid combination; their fixtures declare no classical register, so the expected recording is now empty. --- CHANGELOG.md | 1 + qbraid_qir/profiles/core.py | 18 +++++-- qbraid_qir/qasm3/visitor.py | 10 ++++ tests/qasm3_qir/converter/test_measurement.py | 54 +++++++++++++++++++ .../fixtures/resources/complex_if_opaque.ll | 8 +-- .../fixtures/resources/complex_if_typed.ll | 8 +-- tests/qir_utils.py | 8 +-- 7 files changed, 89 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a84e6c..fa489657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Types of changes: ### 🐛 Bug Fixes +- Fixed `qasm3_to_qir` recording output for results no measurement ever wrote. Output recording iterated over qubits, so a circuit with no measurement emitted zero `mz` instructions yet still recorded one result per qubit, producing a module that declared `required_num_results=0` while reading back N results. `qir-runner` reports whatever uninitialised state it finds there as a measurement outcome, so these programs returned confident, deterministic, wrong counts rather than failing: a Bell state submitted from the circuit composer came back as `{"01": 1000}` and a GHZ state as `{"010": 1000}`. The same defect misdirected recording when a program declared more classical bits than it measured — given `bit[4] c; bit[4] c0; measure q -> c0;`, recording followed the qubit count and reported the untouched `c` register (results 0-3) while discarding the actual measurement (results 4-7). Recording is now driven by the result ids an `mz` actually wrote, for both the base and adaptive profiles. - 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..fdf23c80 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)) @@ -203,8 +210,11 @@ def record_output_method(self, visitor: QIRVisitor, module: QIRModule) -> None: 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): + # Fallback to simple sequential recording, restricted to measured + # results for the same reason as BaseProfile. This branch is taken + # when there is no classical register, so iterating qubits here + # recorded results no measurement ever produced. + 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, null_ptr) diff --git a/qbraid_qir/qasm3/visitor.py b/qbraid_qir/qasm3/visitor.py index 5914da84..34ffd291 100644 --- a/qbraid_qir/qasm3/visitor.py +++ b/qbraid_qir/qasm3/visitor.py @@ -106,6 +106,12 @@ def __init__( self._record_output: bool = record_output self._emit_barrier_calls: bool = emit_barrier_calls + # 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. + self._measured_results: set[int] = set() + # Profile-specific attributes if self._profile.should_track_qubit_measurement(): self._measured_qubits: dict[int, bool] = {} @@ -333,6 +339,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/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(), ] From 2a6645e29104bf195fffa76bcd75cba7478ac292 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 14 Aug 2026 12:15:45 -0500 Subject: [PATCH 2/5] docs: link the changelog entry to PR #294 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa489657..34d242c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Types of changes: ### 🐛 Bug Fixes -- Fixed `qasm3_to_qir` recording output for results no measurement ever wrote. Output recording iterated over qubits, so a circuit with no measurement emitted zero `mz` instructions yet still recorded one result per qubit, producing a module that declared `required_num_results=0` while reading back N results. `qir-runner` reports whatever uninitialised state it finds there as a measurement outcome, so these programs returned confident, deterministic, wrong counts rather than failing: a Bell state submitted from the circuit composer came back as `{"01": 1000}` and a GHZ state as `{"010": 1000}`. The same defect misdirected recording when a program declared more classical bits than it measured — given `bit[4] c; bit[4] c0; measure q -> c0;`, recording followed the qubit count and reported the untouched `c` register (results 0-3) while discarding the actual measurement (results 4-7). Recording is now driven by the result ids an `mz` actually wrote, for both the base and adaptive profiles. +- Fixed `qasm3_to_qir` recording output for results no measurement ever wrote. Output recording iterated over qubits, so a circuit with no measurement emitted zero `mz` instructions yet still recorded one result per qubit, producing a module that declared `required_num_results=0` while reading back N results. `qir-runner` reports whatever uninitialised state it finds there as a measurement outcome, so these programs returned confident, deterministic, wrong counts rather than failing: a Bell state submitted from the circuit composer came back as `{"01": 1000}` and a GHZ state as `{"010": 1000}`. The same defect misdirected recording when a program declared more classical bits than it measured — given `bit[4] c; bit[4] c0; measure q -> c0;`, recording followed the qubit count and reported the untouched `c` register (results 0-3) while discarding the actual measurement (results 4-7). Recording is now driven by the result ids an `mz` actually 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)) From c7e7e3c220f7c7b0633d414c2da3811ce06c93b9 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 14 Aug 2026 12:24:01 -0500 Subject: [PATCH 3/5] fix(qir): declare _measured_results on the QIRVisitor base class mypy rejected the profile methods reading visitor._measured_results, since the attribute was only set on the qasm3 subclass while profiles/core.py is typed against the QIRVisitor base. Declaring it alongside the other attributes the profiles reach for (_record_output, _clbit_labels, _global_creg_size_map) is where it belongs, and makes the subclass's own initialisation redundant. --- qbraid_qir/qasm3/visitor.py | 6 ------ qbraid_qir/visitor.py | 5 +++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/qbraid_qir/qasm3/visitor.py b/qbraid_qir/qasm3/visitor.py index 34ffd291..6dc67c22 100644 --- a/qbraid_qir/qasm3/visitor.py +++ b/qbraid_qir/qasm3/visitor.py @@ -106,12 +106,6 @@ def __init__( self._record_output: bool = record_output self._emit_barrier_calls: bool = emit_barrier_calls - # 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. - self._measured_results: set[int] = set() - # Profile-specific attributes if self._profile.should_track_qubit_measurement(): self._measured_qubits: dict[int, bool] = {} 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() From abae87ce9b2dc91abbb51facb62bd8cbdca8d3e9 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 14 Aug 2026 12:30:05 -0500 Subject: [PATCH 4/5] fix(qir): drop the adaptive profile's unreachable recording fallback The fallback ran only when no classical register was declared, and a measurement needs a classical target, so _measured_results was always empty there and the loop body was dead. Recording nothing is the correct behaviour for that case and is now stated in the docstring instead of expressed as an empty loop. Adds the adaptive-profile test that covers it. Also trims the changelog entry. --- CHANGELOG.md | 2 +- qbraid_qir/profiles/core.py | 53 ++++++++++------------ tests/qasm3_qir/converter/test_adaptive.py | 23 ++++++++++ 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d242c9..863e6252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Types of changes: ### 🐛 Bug Fixes -- Fixed `qasm3_to_qir` recording output for results no measurement ever wrote. Output recording iterated over qubits, so a circuit with no measurement emitted zero `mz` instructions yet still recorded one result per qubit, producing a module that declared `required_num_results=0` while reading back N results. `qir-runner` reports whatever uninitialised state it finds there as a measurement outcome, so these programs returned confident, deterministic, wrong counts rather than failing: a Bell state submitted from the circuit composer came back as `{"01": 1000}` and a GHZ state as `{"010": 1000}`. The same defect misdirected recording when a program declared more classical bits than it measured — given `bit[4] c; bit[4] c0; measure q -> c0;`, recording followed the qubit count and reported the untouched `c` register (results 0-3) while discarding the actual measurement (results 4-7). Recording is now driven by the result ids an `mz` actually wrote, for both the base and adaptive profiles. ([#294](https://github.com/qBraid/qbraid-qir/pull/294)) +- 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 fdf23c80..f5310068 100644 --- a/qbraid_qir/profiles/core.py +++ b/qbraid_qir/profiles/core.py @@ -181,7 +181,14 @@ 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 nothing when no classical register was declared: a measurement + needs a classical target, so such a program measured nothing and every + result is still uninitialised. The previous fallback recorded one result + per qubit there, which made the runtime report that uninitialised state + as a measurement outcome. + """ if not visitor._record_output: return assert visitor._llvm_module is not None @@ -190,33 +197,23 @@ 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, restricted to measured - # results for the same reason as BaseProfile. This branch is taken - # when there is no classical register, so iterating qubits here - # recorded results no measurement ever produced. - 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, null_ptr) + # 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) class ProfileRegistry: diff --git a/tests/qasm3_qir/converter/test_adaptive.py b/tests/qasm3_qir/converter/test_adaptive.py index 6c95b17c..fccdfef9 100644 --- a/tests/qasm3_qir/converter/test_adaptive.py +++ b/tests/qasm3_qir/converter/test_adaptive.py @@ -413,3 +413,26 @@ 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() + + recorded = [line for line in generated_qir if "record_output" in line and "call" in line] + assert recorded == [] From e455b950f51a0a5059f125be44e34537182d955e Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Wed, 26 Aug 2026 08:06:21 -0500 Subject: [PATCH 5/5] fix(qir): apply the measured-results filter to the adaptive profile too The base profile was changed to record only results a measurement wrote. The adaptive profile was not, so the bug this PR names survived on the path that declares a classical register. `_clbit_labels` is populated when a register is *declared*, so a bit that was never measured still has a label and the grouping loop recorded it anyway. A program declaring `bit[3] c` and measuring `c[0]` emitted three result records under `array_record_output(i64 3)`: two uninitialised slots the runtime reports as outcomes, and an array whose length promises elements that never follow. Declaring a register and measuring nothing recorded the whole register, where the base profile correctly records nothing. Filtering on `_measured_results` brings adaptive in line with base -- 1 of 3 measured now records 1, none measured records 0, all measured is unchanged. Registers with no measured bits are skipped rather than emitted as empty arrays, and the array length counts what is actually recorded. Two regression tests cover the partially-measured register and the unused-register case; both fail against the previous implementation. The existing no-measurement test also now asserts required_num_results and the absence of read_result calls, rather than only the absence of record_output. Reported by Argus Eye on #294. --- qbraid_qir/profiles/core.py | 41 ++++++++++------- tests/qasm3_qir/converter/test_adaptive.py | 53 ++++++++++++++++++++++ 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/qbraid_qir/profiles/core.py b/qbraid_qir/profiles/core.py index f5310068..dc7b2fe9 100644 --- a/qbraid_qir/profiles/core.py +++ b/qbraid_qir/profiles/core.py @@ -183,11 +183,14 @@ def allow_qubit_use_after_measurement(self) -> bool: def record_output_method(self, visitor: QIRVisitor, module: QIRModule) -> None: """Adaptive profile output recording - preserves register structure. - Records nothing when no classical register was declared: a measurement - needs a classical target, so such a program measured nothing and every - result is still uninitialised. The previous fallback recorded one result - per qubit there, which made the runtime report that uninitialised state - as a measurement outcome. + 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 @@ -199,21 +202,27 @@ def record_output_method(self, visitor: QIRVisitor, module: QIRModule) -> None: # 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 + # 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), reg_size), + pyqir.const(pyqir.IntType(visitor._llvm_module.context, 64), len(measured_ids)), 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) + 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/tests/qasm3_qir/converter/test_adaptive.py b/tests/qasm3_qir/converter/test_adaptive.py index fccdfef9..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, @@ -434,5 +435,57 @@ def test_adaptive_no_measurement_records_no_results(): 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)