From e0da7309a2ac10499419ee7768fcc170810bdc7a Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 13:32:55 +0200 Subject: [PATCH 01/18] Fixes OpenQASM circuit measurements and makes the exporter more composable This commit fixes the export of circuit measurements to OpenQASM and makes the exporter more composable. Exporting circuit measurements is tested with a round-trip using TeamGraphix/graphix-qasm-parser#16. --- CHANGELOG.md | 6 ++ graphix/qasm3_exporter.py | 92 ++++++++++--------- noxfile.py | 2 +- tests/test_qasm3_exporter.py | 19 +--- .../test_qasm3_exporter_to_graphix_parser.py | 9 +- tests/test_qasm3_exporter_to_qiskit.py | 1 + 6 files changed, 66 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9f77565..00eec8a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- #594: Fixed exporting circuit measurements to OpenQASM. + ## [0.4] - 2026-08-18 ### Added diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 19d152f64..6cc05d7df 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -15,7 +15,7 @@ from graphix.states import BasicStates, State if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Callable, Iterable, Iterator from graphix import Circuit, Pattern from graphix.command import CommandType @@ -69,7 +69,7 @@ def circuit_to_qasm3_lines(circuit: Circuit, *, transpile: bool = True) -> Itera if any(instr.kind == InstructionKind.M for instr in circuit.instruction): yield f"bit[{circuit.width}] b;" for instr in circuit.instruction: - yield f"{instruction_to_qasm3(instr)};" + yield from instruction_to_qasm3(instr) def qasm3_qubit(index: int) -> str: @@ -81,9 +81,9 @@ def qasm3_gate_call(gate: str, operands: Iterable[str], args: Iterable[str] | No """Return the OpenQASM3 gate call.""" operands_str = ", ".join(operands) if args is None: - return f"{gate} {operands_str}" + return f"{gate} {operands_str};" args_str = ", ".join(args) - return f"{gate}({args_str}) {operands_str}" + return f"{gate}({args_str}) {operands_str};" def angle_to_qasm3(angle: ParameterizedAngle) -> str: @@ -93,7 +93,7 @@ def angle_to_qasm3(angle: ParameterizedAngle) -> str: return angle_to_str(angle, output=OutputFormat.ASCII, multiplication_sign=True) -def instruction_to_qasm3(instruction: InstructionType) -> str: +def instruction_to_qasm3(instruction: InstructionType) -> Iterable[str]: """Get the OpenQASM3 representation of a single circuit instruction. Parameters @@ -117,10 +117,10 @@ def instruction_to_qasm3(instruction: InstructionType) -> str: raise ValueError( "OpenQASM3 only supports measurements on Z axis. Use `Circuit.transpile_measurements_to_z_axis` to rewrite measurements on X and Y axes, or setting `transpile=True`." ) - return f"b[{instruction.target}] = measure q[{instruction.target}]" + yield f"b[{instruction.target}] = measure q[{instruction.target}];" case InstructionKind.RX | InstructionKind.RY | InstructionKind.RZ: angle = angle_to_qasm3(instruction.angle) - return qasm3_gate_call( + yield qasm3_gate_call( instruction.kind.name.lower(), args=[angle], operands=[qasm3_qubit(instruction.target)] ) case InstructionKind.J: @@ -128,22 +128,22 @@ def instruction_to_qasm3(instruction: InstructionType) -> str: "J gates must be decomposed before QASM3 export using `Circuit.transpile_j_to_rzh`, or setting `transpile=True`." ) case InstructionKind.H | InstructionKind.S | InstructionKind.X | InstructionKind.Y | InstructionKind.Z: - return qasm3_gate_call(instruction.kind.name.lower(), [qasm3_qubit(instruction.target)]) + yield qasm3_gate_call(instruction.kind.name.lower(), [qasm3_qubit(instruction.target)]) case InstructionKind.I: - return qasm3_gate_call("id", [qasm3_qubit(instruction.target)]) + yield qasm3_gate_call("id", [qasm3_qubit(instruction.target)]) case InstructionKind.CNOT: - return qasm3_gate_call("cx", [qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)]) + yield qasm3_gate_call("cx", [qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)]) case InstructionKind.SWAP: - return qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) + yield qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) case InstructionKind.CZ: - return qasm3_gate_call("cz", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) + yield qasm3_gate_call("cz", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) case InstructionKind.RZZ: angle = angle_to_qasm3(instruction.angle) - return qasm3_gate_call( + yield qasm3_gate_call( "crz", args=[angle], operands=[qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)] ) case InstructionKind.CCX: - return qasm3_gate_call( + yield qasm3_gate_call( "ccx", [ qasm3_qubit(instruction.controls[0]), @@ -175,7 +175,7 @@ def pattern_to_qasm3(pattern: Pattern, input_state: dict[int, State] | State = B input_state : dict[int, State] | State, default BasicStates.PLUS The initial state for each input node. Only |0⟩ or |+⟩ states are supported. """ - return "".join(pattern_to_qasm3_lines(pattern, input_state=input_state)) + return "\n".join(pattern_to_qasm3_lines(pattern, input_state=input_state)) def pattern_to_qasm3_lines(pattern: Pattern, input_state: dict[int, State] | State = BasicStates.PLUS) -> Iterator[str]: @@ -183,15 +183,15 @@ def pattern_to_qasm3_lines(pattern: Pattern, input_state: dict[int, State] | Sta See :func:`pattern_to_qasm3`. """ - yield f"// generated by graphix {version}\n" - yield "OPENQASM 3;\n" - yield 'include "stdgates.inc";\n' - yield "\n" + yield f"// generated by graphix {version}" + yield "OPENQASM 3;" + yield 'include "stdgates.inc";' + yield "" for node in pattern.input_nodes: - yield f"qubit q{node};\n" + yield f"qubit q{node};" state = input_state if isinstance(input_state, State) else input_state[node] yield from state_to_qasm3_lines(node, state) - yield "\n" + yield "" for cmd in pattern: yield from command_to_qasm3_lines(cmd) @@ -210,20 +210,20 @@ def command_to_qasm3_lines(cmd: CommandType) -> Iterator[str]: translated pattern commands in OpenQASM 3.0 language """ - yield f"// {cmd}\n" + yield f"// {cmd}" match cmd.kind: case CommandKind.N: - yield f"qubit q{cmd.node};\n" + yield f"qubit q{cmd.node};" yield from state_to_qasm3_lines(cmd.node, cmd.state) case CommandKind.E: n0, n1 = cmd.nodes - yield f"cz q{n0}, q{n1};\n" + yield f"cz q{n0}, q{n1};" case CommandKind.M: - yield from domain_to_qasm3_lines(cmd.s_domain, f"x q{cmd.node}") - yield from domain_to_qasm3_lines(cmd.t_domain, f"z q{cmd.node}") + yield from domain_to_qasm3_lines(cmd.s_domain, (f"x q{cmd.node};",), _pattern_node_to_qasm3) + yield from domain_to_qasm3_lines(cmd.t_domain, (f"z q{cmd.node};",), _pattern_node_to_qasm3) bloch = cmd.measurement.to_bloch() if bloch.plane == Plane.XY: - yield f"h q{cmd.node};\n" + yield f"h q{cmd.node};" if bloch.angle != 0: match bloch.plane: case Plane.XY: @@ -238,35 +238,38 @@ def command_to_qasm3_lines(cmd: CommandType) -> Iterator[str]: case _: assert_never(bloch.plane) rad_angle = angle_to_qasm3(angle) - yield f"{gate}({rad_angle}) q{cmd.node};\n" - yield f"bit c{cmd.node};\n" - yield f"c{cmd.node} = measure q{cmd.node};\n" + yield f"{gate}({rad_angle}) q{cmd.node};" + target_register = _pattern_node_to_qasm3(cmd.node) + yield f"bit {target_register};" + yield f"{target_register} = measure q{cmd.node};" case CommandKind.X: - yield from domain_to_qasm3_lines(cmd.domain, f"x q{cmd.node}") + yield from domain_to_qasm3_lines(cmd.domain, (f"x q{cmd.node};",), _pattern_node_to_qasm3) case CommandKind.Z: - yield from domain_to_qasm3_lines(cmd.domain, f"z q{cmd.node}") + yield from domain_to_qasm3_lines(cmd.domain, (f"z q{cmd.node};",), _pattern_node_to_qasm3) case CommandKind.C: for op in cmd.clifford.qasm3: - yield str(op) + " q" + str(cmd.node) + ";\n" + yield str(op) + " q" + str(cmd.node) + ";" case _: raise ValueError(f"invalid command {cmd}") - yield "\n" + yield "" def state_to_qasm3_lines(node: int, state: State) -> Iterator[str]: """Convert initial state into OpenQASM 3.0 statement.""" match state: case BasicStates.ZERO: - yield f"// qubit {node} prepared in |0⟩: do nothing\n" + yield f"// qubit {node} prepared in |0⟩: do nothing" case BasicStates.PLUS: - yield f"// qubit {node} prepared in |+⟩\n" - yield f"h q{node};\n" + yield f"// qubit {node} prepared in |+⟩" + yield f"h q{node};" case _: raise ValueError("QASM3 conversion only supports |0⟩ or |+⟩ initial states.") -def domain_to_qasm3_lines(domain: Iterable[int], cmd: str) -> Iterator[str]: +def domain_to_qasm3_lines( + domain: Iterable[int], lines: Iterable[str], node_to_qasm3: Callable[[int], str] +) -> Iterator[str]: """Convert domain controlled-command into OpenQASM 3.0 statement. Parameter @@ -281,9 +284,14 @@ def domain_to_qasm3_lines(domain: Iterable[int], cmd: str) -> Iterator[str]: string translated controlled command in OpenQASM 3.0 language """ - condition = " ^ ".join(f"c{node}" for node in domain) + condition = " ^ ".join(map(node_to_qasm3, domain)) if not condition: return - yield f"if ({condition}) {{\n" - yield f" {cmd};\n" - yield "}\n" + yield f"if ({condition}) {{" + for line in lines: + yield f" {line}" + yield "}" + + +def _pattern_node_to_qasm3(node: int) -> str: + return f"c{node}" diff --git a/noxfile.py b/noxfile.py index 072b0ba7d..6bf41e6b2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -97,7 +97,7 @@ class ReverseDependency: [ ReverseDependency("https://github.com/thierry-martinez/graphix-symbolic", branch="in-place_methods"), ReverseDependency("https://github.com/thierry-martinez/graphix-stim-backend", branch="rename-simulate"), - ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser"), + ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser", branch="refs/pull/16:head"), ReverseDependency( "https://github.com/thierry-martinez/graphix-ibmq", doctest_modules=False, branch="rename-simulate" ), diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index 723e167c1..9f2fecd30 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -11,7 +11,7 @@ import pytest from numpy.random import PCG64, Generator -from graphix import Circuit, instruction +from graphix import Circuit from graphix.fundamentals import ANGLE_PI, Axis from graphix.qasm3_exporter import angle_to_qasm3, circuit_to_qasm3, pattern_to_qasm3 from graphix.random_objects import rand_circuit @@ -23,23 +23,6 @@ def test_angle_to_qasm3(check: tuple[float, str]) -> None: assert angle_to_qasm3(angle) == expected -def test_measurement() -> None: - # Measurements are not supported yet by the parser. - # https://github.com/TeamGraphix/graphix-qasm-parser/issues/3 - # The best we can do is to check if the measurement instruction - # is exported as expected. - circuit = Circuit(1, instr=[instruction.M(target=0, axis=Axis.Z)]) - qasm = circuit_to_qasm3(circuit) - assert ( - qasm - == """OPENQASM 3; -include "stdgates.inc"; -qubit[1] q; -bit[1] b; -b[0] = measure q[0];""" - ) - - @pytest.mark.parametrize("jumps", range(1, 11)) def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: """Check the export to OpenQASM 3 without validating the result. diff --git a/tests/test_qasm3_exporter_to_graphix_parser.py b/tests/test_qasm3_exporter_to_graphix_parser.py index 87dcd7d16..ef3f5f050 100644 --- a/tests/test_qasm3_exporter_to_graphix_parser.py +++ b/tests/test_qasm3_exporter_to_graphix_parser.py @@ -8,7 +8,7 @@ from numpy.random import PCG64, Generator from graphix import Circuit, Instruction -from graphix.fundamentals import ANGLE_PI +from graphix.fundamentals import ANGLE_PI, Axis from graphix.instruction import InstructionKind from graphix.qasm3_exporter import circuit_to_qasm3 from graphix.random_objects import rand_circuit @@ -50,7 +50,7 @@ def test_circuit_to_qasm3(fx_bg: PCG64, jumps: int) -> None: @pytest.mark.parametrize("instruction", ALL_INSTRUCTIONS) def test_instruction_to_qasm3(instruction: InstructionType) -> None: - if instruction.kind == InstructionKind.M: + if instruction.kind in {InstructionKind.RZZ, InstructionKind.M}: pytest.skip() check_round_trip(Circuit(3, instr=[instruction])) @@ -67,3 +67,8 @@ def test_j_to_qasm3_failure() -> None: circuit = Circuit(3, instr=[Instruction.J(target=0, angle=ANGLE_PI / 4)]) with pytest.raises(ValueError): circuit_to_qasm3(circuit, transpile=False) + + +def test_measurement() -> None: + circuit = Circuit(1, instr=[Instruction.M(target=0, axis=Axis.Z)]) + check_round_trip(circuit) diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py index 9b0ff7028..588a85217 100644 --- a/tests/test_qasm3_exporter_to_qiskit.py +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -43,6 +43,7 @@ def check_qasm3(pattern: Pattern) -> None: """Check that we obtain equivalent statevectors whether we simulate the pattern with Graphix or we use Qiskit AER simulator.""" qasm3 = pattern_to_qasm3(pattern) + print(qasm3) qc = qiskit_qasm3_import.parse(qasm3) qc.save_statevector() # type:ignore[attr-defined] aer_backend = AerSimulator(method="statevector") From 222299a7fe9bd24499f8500d5ba180068fbce000 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 17:08:57 +0200 Subject: [PATCH 02/18] Fix reference in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00eec8a33..05283db47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- #594: Fixed exporting circuit measurements to OpenQASM. +- #595: Fixed exporting circuit measurements to OpenQASM. ## [0.4] - 2026-08-18 From bb03b7c360a69ca24a2d6abeb8b98f55d59168a5 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 17:41:04 +0200 Subject: [PATCH 03/18] Use `graphix-qasm-parser` for coverage, remove buggy `RZZ` The version of `graphix-qasm-parser` is set in `.github/qasm-parser-requirements.txt` for both nox and coverage. The gate `RZZ` does not belong to the OpenQASM standard set of gates and was incorrectly exported as `crz`. See TeamGraphix/graphix-qasm-parser#12 for more details. --- .github/qasm-parser-requirements.txt | 1 + .github/workflows/cov.yml | 4 +++- graphix/qasm3_exporter.py | 5 ----- noxfile.py | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 .github/qasm-parser-requirements.txt diff --git a/.github/qasm-parser-requirements.txt b/.github/qasm-parser-requirements.txt new file mode 100644 index 000000000..b36496df5 --- /dev/null +++ b/.github/qasm-parser-requirements.txt @@ -0,0 +1 @@ +graphix-qasm-parser>=0.1.1 diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml index c6c551394..15e7a3d9b 100644 --- a/.github/workflows/cov.yml +++ b/.github/workflows/cov.yml @@ -25,7 +25,9 @@ jobs: python-version: ${{ env.python-version }} - name: Run pytest - run: uv run --extra dev pytest --cov=./graphix --cov-report=xml --cov-report=term --doctest-modules + run: | + pip install -r .github/qasm-parser-requirements.txt + uv run --extra dev pytest --cov=./graphix --cov-report=xml --cov-report=term --doctest-modules - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 6cc05d7df..5a68e4b96 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -137,11 +137,6 @@ def instruction_to_qasm3(instruction: InstructionType) -> Iterable[str]: yield qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) case InstructionKind.CZ: yield qasm3_gate_call("cz", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) - case InstructionKind.RZZ: - angle = angle_to_qasm3(instruction.angle) - yield qasm3_gate_call( - "crz", args=[angle], operands=[qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)] - ) case InstructionKind.CCX: yield qasm3_gate_call( "ccx", diff --git a/noxfile.py b/noxfile.py index 6bf41e6b2..57a8f617e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -51,7 +51,7 @@ def tests_all(session: Session) -> None: """Run the test suite with all dependencies.""" session.install(".[dev]") # This dependency is added here to avoid circular dependencies - session.install("graphix-qasm-parser>=0.1.1") + session.install("-r", ".github/qasm-parser-requirements.txt") run_pytest(session, doctest_modules=True, mpl=True) From b18093d3b03094d4d997fe0de85de612281d9ee7 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 17:56:42 +0200 Subject: [PATCH 04/18] Add `transpile_rzz` --- CHANGELOG.md | 2 +- graphix/qasm3_exporter.py | 6 +++++- graphix/transpiler.py | 11 +++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05283db47..24a1206f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- #595: Fixed exporting circuit measurements to OpenQASM. +- #595: Fixed the export of circuit measurements and `RZZ` gates to OpenQASM. `RZZ` gates are no longer incorrectly exported as `crz`; transpilation to `CNOT`-`RZ`-`CNOT` is provided. ## [0.4] - 2026-08-18 diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 5a68e4b96..71cf740a8 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -62,7 +62,7 @@ def circuit_to_qasm3_lines(circuit: Circuit, *, transpile: bool = True) -> Itera An iterator over the OpenQASM 3.0 lines that represent the circuit. """ if transpile: - circuit = circuit.transpile_j_to_rzh().transpile_measurements_to_z_axis() + circuit = circuit.transpile_rzz().transpile_j_to_rzh().transpile_measurements_to_z_axis() yield "OPENQASM 3;" yield 'include "stdgates.inc";' yield f"qubit[{circuit.width}] q;" @@ -137,6 +137,10 @@ def instruction_to_qasm3(instruction: InstructionType) -> Iterable[str]: yield qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) case InstructionKind.CZ: yield qasm3_gate_call("cz", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) + case InstructionKind.RZZ: + raise ValueError( + "RZZ gates must be decomposed before QASM3 export using `Circuit.transpile_rzz`, or setting `transpile=True`." + ) case InstructionKind.CCX: yield qasm3_gate_call( "ccx", diff --git a/graphix/transpiler.py b/graphix/transpiler.py index 9613b9e9d..059b18758 100644 --- a/graphix/transpiler.py +++ b/graphix/transpiler.py @@ -749,6 +749,17 @@ def transpile_j_to_rzh(self) -> Circuit: new_circuit.add(instr) return new_circuit + def transpile_rzz(self) -> Circuit: + """Return an equivalent circuit where all RZZ gates have been replaced with OpenQASM gates.""" + new_circuit = Circuit(self.width) + for instr in self.instruction: + match instr.kind: + case InstructionKind.RZZ: + new_circuit.extend(decompose_rzz(instr)) + case _: + new_circuit.add(instr) + return new_circuit + def decompose_rzz(instr: instruction.RZZ) -> Iterator[instruction.CNOT | instruction.RZ]: """Yield a decomposition of RZZ(α) gate as CNOT(control, target)·Rz(target, α)·CNOT(control, target). From b97b2f08dcd8246ebc3f04a3fb3223de7471a28e Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 18:02:22 +0200 Subject: [PATCH 05/18] Use `uv run --with-requirements` `uv` ignores `pip`-installed packages. --- .github/workflows/cov.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml index 15e7a3d9b..46780545c 100644 --- a/.github/workflows/cov.yml +++ b/.github/workflows/cov.yml @@ -26,8 +26,10 @@ jobs: - name: Run pytest run: | - pip install -r .github/qasm-parser-requirements.txt - uv run --extra dev pytest --cov=./graphix --cov-report=xml --cov-report=term --doctest-modules + uv run --with-requirements .github/qasm-parser-requirements.txt \ + --extra dev \ + pytest --cov=./graphix --cov-report=xml --cov-report=term \ + --doctest-modules - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 From 0a166cc3bd6b8c364db6680301215b81305a6ac6 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 18:06:10 +0200 Subject: [PATCH 06/18] Use TeamGraphix/graphix-qasm-parser#16 for measurement support --- .github/qasm-parser-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/qasm-parser-requirements.txt b/.github/qasm-parser-requirements.txt index b36496df5..0c294a0e1 100644 --- a/.github/qasm-parser-requirements.txt +++ b/.github/qasm-parser-requirements.txt @@ -1 +1 @@ -graphix-qasm-parser>=0.1.1 +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser@refs/pull/16:head From ea0c6f7559a6081bbcb0d31ecfcef672aa8976d1 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 18:10:10 +0200 Subject: [PATCH 07/18] Fix ref --- .github/qasm-parser-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/qasm-parser-requirements.txt b/.github/qasm-parser-requirements.txt index 0c294a0e1..5c922358d 100644 --- a/.github/qasm-parser-requirements.txt +++ b/.github/qasm-parser-requirements.txt @@ -1 +1 @@ -graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser@refs/pull/16:head +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser@refs/pull/16/head From 03ca9767c8f856e41141f8e54488fd3c2d63b91f Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 18:17:22 +0200 Subject: [PATCH 08/18] Fix coverage --- tests/test_qasm3_exporter.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index 9f2fecd30..f21aec4d7 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -44,13 +44,25 @@ def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: _qasm3 = pattern_to_qasm3(pattern) -def test_to_qasm3_failures() -> None: - circuit = Circuit(2) +def test_to_qasm3_measure_on_x_axis() -> None: + circuit = Circuit(1) circuit.m(0, Axis.X) with pytest.raises(ValueError, match="OpenQASM3 only supports measurements on Z axis"): circuit_to_qasm3(circuit, transpile=False) - circuit = circuit.transpile_measurements_to_z_axis() - circuit.j(1, 0.25) + _qasm3 = circuit_to_qasm3(circuit) + + +def test_to_qasm3_j() -> None: + circuit = Circuit(1) + circuit.j(0, 0.25) with pytest.raises(ValueError, match="J gates must be decomposed before QASM3 export"): circuit_to_qasm3(circuit, transpile=False) _qasm3 = circuit_to_qasm3(circuit) + + +def test_to_qasm3_rzz() -> None: + circuit = Circuit(2) + circuit.rzz(0, 1, 0.25) + with pytest.raises(ValueError, match="RZZ gates must be decomposed before QASM3 export"): + circuit_to_qasm3(circuit, transpile=False) + _qasm3 = circuit_to_qasm3(circuit) From fd6dcc515714c0dcf68e131d2cab461404f1c3a0 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 19:03:12 +0200 Subject: [PATCH 09/18] Fix ref in noxfile --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 57a8f617e..793677e9f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -97,7 +97,7 @@ class ReverseDependency: [ ReverseDependency("https://github.com/thierry-martinez/graphix-symbolic", branch="in-place_methods"), ReverseDependency("https://github.com/thierry-martinez/graphix-stim-backend", branch="rename-simulate"), - ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser", branch="refs/pull/16:head"), + ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser", branch="refs/pull/16/head"), ReverseDependency( "https://github.com/thierry-martinez/graphix-ibmq", doctest_modules=False, branch="rename-simulate" ), From 540146c29ed11e24c241ad814569951d8d11099f Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 19:16:46 +0200 Subject: [PATCH 10/18] Fix support for special refs --- noxfile.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/noxfile.py b/noxfile.py index 793677e9f..336acc078 100644 --- a/noxfile.py +++ b/noxfile.py @@ -127,10 +127,11 @@ def tests_reverse_dependencies(session: Session, package: ReverseDependency) -> session.install("nox") with TemporaryDirectory() as tmpdir: with session.cd(tmpdir): - if package.branch is None: - session.run("git", "clone", package.repository, external=True) - else: - session.run("git", "clone", "-b", package.branch, package.repository, external=True) + session.run("git", "clone", package.repository, external=True) + if package.branch is not None: + # Use `git fetch` instead of `-b` to support special + # refs such as `refs/pull/N/head` + session.run("git", "fetch", "origin", package.branch) with session.cd(dirname): # graphix installation fails without constraint on numba session.install(package.install_target, "numba>=0.65.1") From c1a4e4e8c114a17d1799c79fcbcb3ca60ff9090d Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 19:18:15 +0200 Subject: [PATCH 11/18] Add `external=True` to remove warning --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 336acc078..de2351660 100644 --- a/noxfile.py +++ b/noxfile.py @@ -131,7 +131,7 @@ def tests_reverse_dependencies(session: Session, package: ReverseDependency) -> if package.branch is not None: # Use `git fetch` instead of `-b` to support special # refs such as `refs/pull/N/head` - session.run("git", "fetch", "origin", package.branch) + session.run("git", "fetch", "origin", package.branch, external=True) with session.cd(dirname): # graphix installation fails without constraint on numba session.install(package.install_target, "numba>=0.65.1") From b7a6d650318f29010f7a4637ac251b4a1aba69e2 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 19:18:56 +0200 Subject: [PATCH 12/18] Clearer comment --- noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index de2351660..4fa75b391 100644 --- a/noxfile.py +++ b/noxfile.py @@ -129,8 +129,8 @@ def tests_reverse_dependencies(session: Session, package: ReverseDependency) -> with session.cd(tmpdir): session.run("git", "clone", package.repository, external=True) if package.branch is not None: - # Use `git fetch` instead of `-b` to support special - # refs such as `refs/pull/N/head` + # Use `git fetch` instead of `git clone -b` to support + # special refs such as `refs/pull/N/head` session.run("git", "fetch", "origin", package.branch, external=True) with session.cd(dirname): # graphix installation fails without constraint on numba From ec93f33c9909d801781d3b73f7701ad363dd1772 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 19:23:40 +0200 Subject: [PATCH 13/18] `git fetch` inside `session.cd` --- noxfile.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/noxfile.py b/noxfile.py index 4fa75b391..0b632253b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -128,11 +128,11 @@ def tests_reverse_dependencies(session: Session, package: ReverseDependency) -> with TemporaryDirectory() as tmpdir: with session.cd(tmpdir): session.run("git", "clone", package.repository, external=True) - if package.branch is not None: - # Use `git fetch` instead of `git clone -b` to support - # special refs such as `refs/pull/N/head` - session.run("git", "fetch", "origin", package.branch, external=True) with session.cd(dirname): + if package.branch is not None: + # Use `git fetch` instead of `git clone -b` to support + # special refs such as `refs/pull/N/head` + session.run("git", "fetch", "origin", package.branch, external=True) # graphix installation fails without constraint on numba session.install(package.install_target, "numba>=0.65.1") # Note that `session.cd` is used as a context manager above, From c5733984f9f6500b3875e2d148777a66df175b8e Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 28 Aug 2026 19:36:29 +0200 Subject: [PATCH 14/18] Add `git checkout --detach FETCH_HEAD` --- noxfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/noxfile.py b/noxfile.py index 0b632253b..75b2173be 100644 --- a/noxfile.py +++ b/noxfile.py @@ -133,6 +133,7 @@ def tests_reverse_dependencies(session: Session, package: ReverseDependency) -> # Use `git fetch` instead of `git clone -b` to support # special refs such as `refs/pull/N/head` session.run("git", "fetch", "origin", package.branch, external=True) + session.run("git", "checkout", "--detach", "FETCH_HEAD", external=True) # graphix installation fails without constraint on numba session.install(package.install_target, "numba>=0.65.1") # Note that `session.cd` is used as a context manager above, From a9ef7060fc4ed6b9aa049ce5beff93f288012787 Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Wed, 2 Sep 2026 21:21:28 +0200 Subject: [PATCH 15/18] Update tests/test_qasm3_exporter_to_qiskit.py Co-authored-by: matulni --- tests/test_qasm3_exporter_to_qiskit.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py index 588a85217..9b0ff7028 100644 --- a/tests/test_qasm3_exporter_to_qiskit.py +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -43,7 +43,6 @@ def check_qasm3(pattern: Pattern) -> None: """Check that we obtain equivalent statevectors whether we simulate the pattern with Graphix or we use Qiskit AER simulator.""" qasm3 = pattern_to_qasm3(pattern) - print(qasm3) qc = qiskit_qasm3_import.parse(qasm3) qc.save_statevector() # type:ignore[attr-defined] aer_backend = AerSimulator(method="statevector") From af56ebd0af7c0bce89ad2f5466c2ddb81cfaf6ee Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 2 Sep 2026 21:27:07 +0200 Subject: [PATCH 16/18] Update docstring --- graphix/qasm3_exporter.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 71cf740a8..cff723b19 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -269,19 +269,21 @@ def state_to_qasm3_lines(node: int, state: State) -> Iterator[str]: def domain_to_qasm3_lines( domain: Iterable[int], lines: Iterable[str], node_to_qasm3: Callable[[int], str] ) -> Iterator[str]: - """Convert domain controlled-command into OpenQASM 3.0 statement. + """Convert a sequence of domain controlled-commands into OpenQASM 3.0 statement. Parameter --------- domain : Iterable[int] measured nodes - cmd : str - controlled command + lines : Iterable[str] + controlled commands, i.e., the body of the conditional, line by line + node_to_qasm3 : Callable[[int], str] + mapping from node indices to OpenQASM classical registers Yields ------ string - translated controlled command in OpenQASM 3.0 language + translated controlled command in OpenQASM 3.0 language, line by line """ condition = " ^ ".join(map(node_to_qasm3, domain)) if not condition: From fb6cec0ff3cb55874c7cfd6139386ed44b85b045 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 2 Sep 2026 21:57:48 +0200 Subject: [PATCH 17/18] Reduce the PR by only fixing RZZ since the other fix is not needed --- graphix/qasm3_exporter.py | 100 +++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 55 deletions(-) diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index cff723b19..668abcb5d 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -15,7 +15,7 @@ from graphix.states import BasicStates, State if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Iterator + from collections.abc import Iterable, Iterator from graphix import Circuit, Pattern from graphix.command import CommandType @@ -69,7 +69,7 @@ def circuit_to_qasm3_lines(circuit: Circuit, *, transpile: bool = True) -> Itera if any(instr.kind == InstructionKind.M for instr in circuit.instruction): yield f"bit[{circuit.width}] b;" for instr in circuit.instruction: - yield from instruction_to_qasm3(instr) + yield f"{instruction_to_qasm3(instr)};" def qasm3_qubit(index: int) -> str: @@ -81,9 +81,9 @@ def qasm3_gate_call(gate: str, operands: Iterable[str], args: Iterable[str] | No """Return the OpenQASM3 gate call.""" operands_str = ", ".join(operands) if args is None: - return f"{gate} {operands_str};" + return f"{gate} {operands_str}" args_str = ", ".join(args) - return f"{gate}({args_str}) {operands_str};" + return f"{gate}({args_str}) {operands_str}" def angle_to_qasm3(angle: ParameterizedAngle) -> str: @@ -93,7 +93,7 @@ def angle_to_qasm3(angle: ParameterizedAngle) -> str: return angle_to_str(angle, output=OutputFormat.ASCII, multiplication_sign=True) -def instruction_to_qasm3(instruction: InstructionType) -> Iterable[str]: +def instruction_to_qasm3(instruction: InstructionType) -> str: """Get the OpenQASM3 representation of a single circuit instruction. Parameters @@ -117,10 +117,10 @@ def instruction_to_qasm3(instruction: InstructionType) -> Iterable[str]: raise ValueError( "OpenQASM3 only supports measurements on Z axis. Use `Circuit.transpile_measurements_to_z_axis` to rewrite measurements on X and Y axes, or setting `transpile=True`." ) - yield f"b[{instruction.target}] = measure q[{instruction.target}];" + return f"b[{instruction.target}] = measure q[{instruction.target}]" case InstructionKind.RX | InstructionKind.RY | InstructionKind.RZ: angle = angle_to_qasm3(instruction.angle) - yield qasm3_gate_call( + return qasm3_gate_call( instruction.kind.name.lower(), args=[angle], operands=[qasm3_qubit(instruction.target)] ) case InstructionKind.J: @@ -128,21 +128,21 @@ def instruction_to_qasm3(instruction: InstructionType) -> Iterable[str]: "J gates must be decomposed before QASM3 export using `Circuit.transpile_j_to_rzh`, or setting `transpile=True`." ) case InstructionKind.H | InstructionKind.S | InstructionKind.X | InstructionKind.Y | InstructionKind.Z: - yield qasm3_gate_call(instruction.kind.name.lower(), [qasm3_qubit(instruction.target)]) + return qasm3_gate_call(instruction.kind.name.lower(), [qasm3_qubit(instruction.target)]) case InstructionKind.I: - yield qasm3_gate_call("id", [qasm3_qubit(instruction.target)]) + return qasm3_gate_call("id", [qasm3_qubit(instruction.target)]) case InstructionKind.CNOT: - yield qasm3_gate_call("cx", [qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)]) + return qasm3_gate_call("cx", [qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)]) case InstructionKind.SWAP: - yield qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) + return qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) case InstructionKind.CZ: - yield qasm3_gate_call("cz", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) + return qasm3_gate_call("cz", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) case InstructionKind.RZZ: raise ValueError( "RZZ gates must be decomposed before QASM3 export using `Circuit.transpile_rzz`, or setting `transpile=True`." ) case InstructionKind.CCX: - yield qasm3_gate_call( + return qasm3_gate_call( "ccx", [ qasm3_qubit(instruction.controls[0]), @@ -174,7 +174,7 @@ def pattern_to_qasm3(pattern: Pattern, input_state: dict[int, State] | State = B input_state : dict[int, State] | State, default BasicStates.PLUS The initial state for each input node. Only |0⟩ or |+⟩ states are supported. """ - return "\n".join(pattern_to_qasm3_lines(pattern, input_state=input_state)) + return "".join(pattern_to_qasm3_lines(pattern, input_state=input_state)) def pattern_to_qasm3_lines(pattern: Pattern, input_state: dict[int, State] | State = BasicStates.PLUS) -> Iterator[str]: @@ -182,15 +182,15 @@ def pattern_to_qasm3_lines(pattern: Pattern, input_state: dict[int, State] | Sta See :func:`pattern_to_qasm3`. """ - yield f"// generated by graphix {version}" - yield "OPENQASM 3;" - yield 'include "stdgates.inc";' - yield "" + yield f"// generated by graphix {version}\n" + yield "OPENQASM 3;\n" + yield 'include "stdgates.inc";\n' + yield "\n" for node in pattern.input_nodes: - yield f"qubit q{node};" + yield f"qubit q{node};\n" state = input_state if isinstance(input_state, State) else input_state[node] yield from state_to_qasm3_lines(node, state) - yield "" + yield "\n" for cmd in pattern: yield from command_to_qasm3_lines(cmd) @@ -209,20 +209,20 @@ def command_to_qasm3_lines(cmd: CommandType) -> Iterator[str]: translated pattern commands in OpenQASM 3.0 language """ - yield f"// {cmd}" + yield f"// {cmd}\n" match cmd.kind: case CommandKind.N: - yield f"qubit q{cmd.node};" + yield f"qubit q{cmd.node};\n" yield from state_to_qasm3_lines(cmd.node, cmd.state) case CommandKind.E: n0, n1 = cmd.nodes - yield f"cz q{n0}, q{n1};" + yield f"cz q{n0}, q{n1};\n" case CommandKind.M: - yield from domain_to_qasm3_lines(cmd.s_domain, (f"x q{cmd.node};",), _pattern_node_to_qasm3) - yield from domain_to_qasm3_lines(cmd.t_domain, (f"z q{cmd.node};",), _pattern_node_to_qasm3) + yield from domain_to_qasm3_lines(cmd.s_domain, f"x q{cmd.node}") + yield from domain_to_qasm3_lines(cmd.t_domain, f"z q{cmd.node}") bloch = cmd.measurement.to_bloch() if bloch.plane == Plane.XY: - yield f"h q{cmd.node};" + yield f"h q{cmd.node};\n" if bloch.angle != 0: match bloch.plane: case Plane.XY: @@ -237,62 +237,52 @@ def command_to_qasm3_lines(cmd: CommandType) -> Iterator[str]: case _: assert_never(bloch.plane) rad_angle = angle_to_qasm3(angle) - yield f"{gate}({rad_angle}) q{cmd.node};" - target_register = _pattern_node_to_qasm3(cmd.node) - yield f"bit {target_register};" - yield f"{target_register} = measure q{cmd.node};" + yield f"{gate}({rad_angle}) q{cmd.node};\n" + yield f"bit c{cmd.node};\n" + yield f"c{cmd.node} = measure q{cmd.node};\n" case CommandKind.X: - yield from domain_to_qasm3_lines(cmd.domain, (f"x q{cmd.node};",), _pattern_node_to_qasm3) + yield from domain_to_qasm3_lines(cmd.domain, f"x q{cmd.node}") case CommandKind.Z: - yield from domain_to_qasm3_lines(cmd.domain, (f"z q{cmd.node};",), _pattern_node_to_qasm3) + yield from domain_to_qasm3_lines(cmd.domain, f"z q{cmd.node}") case CommandKind.C: for op in cmd.clifford.qasm3: - yield str(op) + " q" + str(cmd.node) + ";" + yield str(op) + " q" + str(cmd.node) + ";\n" case _: raise ValueError(f"invalid command {cmd}") - yield "" + yield "\n" def state_to_qasm3_lines(node: int, state: State) -> Iterator[str]: """Convert initial state into OpenQASM 3.0 statement.""" match state: case BasicStates.ZERO: - yield f"// qubit {node} prepared in |0⟩: do nothing" + yield f"// qubit {node} prepared in |0⟩: do nothing\n" case BasicStates.PLUS: - yield f"// qubit {node} prepared in |+⟩" - yield f"h q{node};" + yield f"// qubit {node} prepared in |+⟩\n" + yield f"h q{node};\n" case _: raise ValueError("QASM3 conversion only supports |0⟩ or |+⟩ initial states.") -def domain_to_qasm3_lines( - domain: Iterable[int], lines: Iterable[str], node_to_qasm3: Callable[[int], str] -) -> Iterator[str]: - """Convert a sequence of domain controlled-commands into OpenQASM 3.0 statement. +def domain_to_qasm3_lines(domain: Iterable[int], cmd: str) -> Iterator[str]: + """Convert domain controlled-command into OpenQASM 3.0 statement. Parameter --------- domain : Iterable[int] measured nodes - lines : Iterable[str] - controlled commands, i.e., the body of the conditional, line by line - node_to_qasm3 : Callable[[int], str] - mapping from node indices to OpenQASM classical registers + cmd : str + controlled command Yields ------ string - translated controlled command in OpenQASM 3.0 language, line by line + translated controlled command in OpenQASM 3.0 language """ - condition = " ^ ".join(map(node_to_qasm3, domain)) + condition = " ^ ".join(f"c{node}" for node in domain) if not condition: return - yield f"if ({condition}) {{" - for line in lines: - yield f" {line}" - yield "}" - - -def _pattern_node_to_qasm3(node: int) -> str: - return f"c{node}" + yield f"if ({condition}) {{\n" + yield f" {cmd};\n" + yield "}\n" From 657893141efa74cb351adb4f1a65050bb95cbff8 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 2 Sep 2026 21:59:26 +0200 Subject: [PATCH 18/18] Update docstring --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c1255b8..f6e00d1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #591: `FixedBranchSelector` now passes its RNG parameter to its `default` branch selector. -- #595: Fixed the export of circuit measurements and `RZZ` gates to OpenQASM. `RZZ` gates are no longer incorrectly exported as `crz`; transpilation to `CNOT`-`RZ`-`CNOT` is provided. +- #595: `RZZ` gates are no longer incorrectly exported as `crz`; transpilation to `CNOT`-`RZ`-`CNOT` is provided. - #596, #597: Pattern's `n_node` property updated after Pauli removal.