diff --git a/.github/qasm-parser-requirements.txt b/.github/qasm-parser-requirements.txt new file mode 100644 index 000000000..5c922358d --- /dev/null +++ b/.github/qasm-parser-requirements.txt @@ -0,0 +1 @@ +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser@refs/pull/16/head diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml index c6c551394..46780545c 100644 --- a/.github/workflows/cov.yml +++ b/.github/workflows/cov.yml @@ -25,7 +25,11 @@ 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: | + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6370f581c..f6e00d1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - #591: `FixedBranchSelector` now passes its RNG parameter to its `default` branch selector. + +- #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. ## [0.4] - 2026-08-18 diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 19d152f64..668abcb5d 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;" @@ -138,9 +138,8 @@ def instruction_to_qasm3(instruction: InstructionType) -> str: case InstructionKind.CZ: return 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( - "crz", args=[angle], operands=[qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)] + raise ValueError( + "RZZ gates must be decomposed before QASM3 export using `Circuit.transpile_rzz`, or setting `transpile=True`." ) case InstructionKind.CCX: return qasm3_gate_call( 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). diff --git a/noxfile.py b/noxfile.py index 072b0ba7d..75b2173be 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) @@ -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" ), @@ -127,11 +127,13 @@ 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) 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) + 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, diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index 723e167c1..f21aec4d7 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. @@ -61,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) 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)