Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
68 changes: 42 additions & 26 deletions qbraid_qir/profiles/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions qbraid_qir/qasm3/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions qbraid_qir/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
76 changes: 76 additions & 0 deletions tests/qasm3_qir/converter/test_adaptive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Comment thread
argus-eye[bot] marked this conversation as resolved.
# 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)
54 changes: 54 additions & 0 deletions tests/qasm3_qir/converter/test_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}"
8 changes: 4 additions & 4 deletions tests/qasm3_qir/fixtures/resources/complex_if_opaque.ll
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
8 changes: 4 additions & 4 deletions tests/qasm3_qir/fixtures/resources/complex_if_typed.ll
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
8 changes: 2 additions & 6 deletions tests/qir_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]

Expand All @@ -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(),
]

Expand All @@ -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(),
]

Expand Down