From 1fda5491e07eebb96e3ff4d77a4df99e31337559 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 1 Oct 2025 14:05:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Add=20OpenQASM=E2=80=AF3=20exporter=20for?= =?UTF-8?q?=20circuits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces an OpenQASM 3 exporter for Graphix circuits. The functionality was originally proposed in #245 but has not yet been merged. The added tests verify that a round-trip through the OpenQASM 3 representation preserves Graphix circuits, using the graphix-qasm3-parser plugin. This plugin is therefore added as a `requirements-dev.txt` dependency. CI is updated so that `pip install .` can detect the current version number of Graphix, instead of the default `0.1`: to do so, the whole history and the tags should be available. We removed the "-e" option from CI because it is useless in the CI context. In the long term the `qasm3_exporter` module will also host the pattern exporter, but that feature is intentionally omitted from this PR to keep the change focused. --- .github/workflows/ci.yml | 5 +- .github/workflows/cov.yml | 5 +- .github/workflows/doc.yml | 5 +- .github/workflows/typecheck.yml | 10 +++- CHANGELOG.md | 3 ++ graphix/qasm3_exporter.py | 95 +++++++++++++++++++++++++++++++++ noxfile.py | 28 ++++++++-- requirements-dev.txt | 2 + tests/test_qasm3_exporter.py | 36 +++++++++++++ 9 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 graphix/qasm3_exporter.py create mode 100644 tests/test_qasm3_exporter.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7632d9b3b..6a09808b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all, necessary to find tags and branches + fetch-tags: true - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml index 3f459f974..855e74250 100644 --- a/.github/workflows/cov.yml +++ b/.github/workflows/cov.yml @@ -14,7 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all, necessary to find tags and branches + fetch-tags: true - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index b6d0685c0..2f71a2d81 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -20,7 +20,10 @@ jobs: name: "Check documentation" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all, necessary to find tags and branches + fetch-tags: true - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 572aa4530..76bb98914 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -19,7 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all, necessary to find tags and branches + fetch-tags: true - uses: actions/setup-python@v5 with: @@ -46,7 +49,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all, necessary to find tags and branches + fetch-tags: true - uses: actions/setup-python@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index eeb0580ba..cb7663074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- #343: Circuit exporter to OpenQASM3: + `graphix.qasm3_exporter.circuit_to_qasm3`. + - #337: New module `graphix.find_pauliflow` with the $O(N^3)$ Pauli-flow finding algorithm introduced in Mitosek and Backens, 2024 (arXiv:2410.23439). diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py new file mode 100644 index 000000000..45340108d --- /dev/null +++ b/graphix/qasm3_exporter.py @@ -0,0 +1,95 @@ +"""Exporter to OpenQASM3.""" + +from __future__ import annotations + +from fractions import Fraction +from math import pi +from typing import TYPE_CHECKING + +# assert_never added in Python 3.11 +from typing_extensions import assert_never + +from graphix.instruction import Instruction, InstructionKind + +if TYPE_CHECKING: + from collections.abc import Iterator + + from graphix import Circuit + + +def circuit_to_qasm3(circuit: Circuit) -> str: + """Export circuit instructions to OpenQASM 3.0 representation. + + Returns + ------- + str + The OpenQASM 3.0 string representation of the circuit. + """ + return "\n".join(circuit_to_qasm3_lines(circuit)) + + +def circuit_to_qasm3_lines(circuit: Circuit) -> Iterator[str]: + """Export circuit instructions to line-by-line OpenQASM 3.0 representation. + + Returns + ------- + Iterator[str] + The OpenQASM 3.0 lines that represent the circuit. + """ + yield "OPENQASM 3;" + yield 'include "stdgates.inc";' + yield f"qubit[{circuit.width}] q;" + 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)};" + + +def instruction_to_qasm3(instruction: Instruction) -> str: + """Get the qasm3 representation of a single circuit instruction.""" + if instruction.kind == InstructionKind.M: + return f"b[{instruction.target}] = measure q[{instruction.target}]" + # Use of `==` here for mypy + if ( + instruction.kind == InstructionKind.RX # noqa: PLR1714 + or instruction.kind == InstructionKind.RY + or instruction.kind == InstructionKind.RZ + ): + if not isinstance(instruction.angle, float): + raise ValueError("QASM export of symbolic pattern is not supported") + rad_over_pi = instruction.angle / pi + tol = 1e-9 + frac = Fraction(rad_over_pi).limit_denominator(1000) + if abs(rad_over_pi - float(frac)) > tol: + angle = f"{rad_over_pi}*pi" + num, den = frac.numerator, frac.denominator + sign = "-" if num < 0 else "" + num = abs(num) + if den == 1: + angle = f"{sign}pi" if num == 1 else f"{sign}{num}*pi" + else: + angle = f"{sign}pi/{den}" if num == 1 else f"{sign}{num}*pi/{den}" + return f"{instruction.kind.name.lower()}({angle}) q[{instruction.target}]" + + # Use of `==` here for mypy + if ( + instruction.kind == InstructionKind.H # noqa: PLR1714 + or instruction.kind == InstructionKind.I + or instruction.kind == InstructionKind.S + or instruction.kind == InstructionKind.X + or instruction.kind == InstructionKind.Y + or instruction.kind == InstructionKind.Z + ): + return f"{instruction.kind.name.lower()} q[{instruction.target}]" + if instruction.kind == InstructionKind.CNOT: + return f"cx q[{instruction.control}], q[{instruction.target}]" + if instruction.kind == InstructionKind.SWAP: + return f"swap q[{instruction.targets[0]}], q[{instruction.targets[1]}]" + if instruction.kind == InstructionKind.RZZ: + return f"rzz q[{instruction.control}], q[{instruction.target}]" + if instruction.kind == InstructionKind.CCX: + return f"ccx q[{instruction.controls[0]}], q[{instruction.controls[1]}], q[{instruction.target}]" + # Use of `==` here for mypy + if instruction.kind == InstructionKind._XC or instruction.kind == InstructionKind._ZC: # noqa: PLR1714 + raise ValueError("Internal instruction should not appear") + assert_never(instruction.kind) diff --git a/noxfile.py b/noxfile.py index 9473a13fc..02d354399 100644 --- a/noxfile.py +++ b/noxfile.py @@ -16,7 +16,7 @@ def install_pytest(session: Session) -> None: @nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"]) def tests_minimal(session: Session) -> None: """Run the test suite with minimal dependencies.""" - session.install("-e", ".") + session.install(".") install_pytest(session) # We cannot run `pytest --doctest-modules` here, since some tests # involve optional dependencies, like pyzx. @@ -27,7 +27,7 @@ def tests_minimal(session: Session) -> None: @nox.session(python=["3.10", "3.11", "3.12", "3.13"]) def tests_dev(session: Session) -> None: """Run the test suite with dev dependencies.""" - session.install("-e", ".[dev]") + session.install(".[dev]") # We cannot run `pytest --doctest-modules` here, since some tests # involve optional dependencies, like pyzx. session.run("pytest") @@ -36,7 +36,7 @@ def tests_dev(session: Session) -> None: @nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"]) def tests_extra(session: Session) -> None: """Run the test suite with extra dependencies.""" - session.install("-e", ".[extra]") + session.install(".[extra]") install_pytest(session) session.install("nox") # needed for `--doctest-modules` session.run("pytest", "--doctest-modules") @@ -45,14 +45,14 @@ def tests_extra(session: Session) -> None: @nox.session(python=["3.10", "3.11", "3.12", "3.13"]) def tests_all(session: Session) -> None: """Run the test suite with all dependencies.""" - session.install("-e", ".[dev,extra]") + session.install(".[dev,extra]") session.run("pytest", "--doctest-modules") @nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"]) def tests_symbolic(session: Session) -> None: """Run the test suite of graphix-symbolic.""" - session.install("-e", ".") + session.install(".") install_pytest(session) session.install("nox") # needed for `--doctest-modules` # Use `session.cd` as a context manager to ensure that the @@ -67,3 +67,21 @@ def tests_symbolic(session: Session) -> None: # session.run("git", "clone", "https://github.com/TeamGraphix/graphix-symbolic") with session.cd("graphix-symbolic"): session.run("pytest", "--doctest-modules") + + +@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"]) +def tests_qasm_parser(session: Session) -> None: + """Run the test suite of graphix-qasm-parser.""" + session.install(".") + install_pytest(session) + session.install("nox") # needed for `--doctest-modules` + # Use `session.cd` as a context manager to ensure that the + # working directory is restored afterward. This is important + # because Windows cannot delete a temporary directory while it + # is the working directory. + with TemporaryDirectory() as tmpdir, session.cd(tmpdir): + # See https://github.com/TeamGraphix/graphix-qasm-parser/pull/1 + session.run("git", "clone", "-b", "fix_typing_issues", "https://github.com/TeamGraphix/graphix-qasm-parser") + with session.cd("graphix-qasm-parser"): + session.install(".") + session.run("pytest", "--doctest-modules") diff --git a/requirements-dev.txt b/requirements-dev.txt index 936c1eb4e..6dfc9111b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -21,3 +21,5 @@ pytest-mock # Optional dependencies qiskit>=1.0 qiskit-aer + +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@fix_typing_issues diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py new file mode 100644 index 000000000..3d091d4c4 --- /dev/null +++ b/tests/test_qasm3_exporter.py @@ -0,0 +1,36 @@ +"""Test exporter to OpenQASM3.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from numpy.random import PCG64, Generator + +from graphix.qasm3_exporter import circuit_to_qasm3 +from graphix.random_objects import rand_circuit + +try: + from graphix_qasm_parser import OpenQASMParser +except ImportError: + pytestmark = pytest.mark.skip(reason="graphix-qasm-parser not installed") + + if TYPE_CHECKING: + import sys + + # We skip type-checking the case where there is no + # graphix-qasm-parser, since pyright cannot figure out that + # tests are skipped in this case. + sys.exit(1) + + +@pytest.mark.parametrize("jumps", range(1, 11)) +def test_circuit_to_qasm3(fx_bg: PCG64, jumps: int) -> None: + rng = Generator(fx_bg.jumped(jumps)) + nqubits = 5 + depth = 4 + circuit = rand_circuit(nqubits, depth, rng) + qasm = circuit_to_qasm3(circuit) + parser = OpenQASMParser() + parsed_circuit = parser.parse_str(qasm) + assert parsed_circuit.instruction == circuit.instruction From a0761422e3d38ce5ab1312ffd783ba8e0fb6f9d5 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 14 Oct 2025 16:22:46 +0200 Subject: [PATCH 2/2] Implement Mateo's comments --- graphix/pauli.py | 2 +- graphix/pretty_print.py | 24 ++++++++++-- graphix/qasm3_exporter.py | 73 ++++++++++++++++++++++++------------ noxfile.py | 3 +- pyproject.toml | 3 +- requirements-dev.txt | 2 +- tests/test_qasm3_exporter.py | 64 ++++++++++++++++++++++++++++--- 7 files changed, 133 insertions(+), 38 deletions(-) diff --git a/graphix/pauli.py b/graphix/pauli.py index e801fcfe6..6b50b5686 100644 --- a/graphix/pauli.py +++ b/graphix/pauli.py @@ -1,4 +1,4 @@ -"""Pauli gates ± {1,j} × {I, X, Y, Z}.""" # noqa: RUF002 +"""Pauli gates ± {1,j} × {I, X, Y, Z}.""" from __future__ import annotations diff --git a/graphix/pretty_print.py b/graphix/pretty_print.py index 2892ce174..773f10d30 100644 --- a/graphix/pretty_print.py +++ b/graphix/pretty_print.py @@ -27,7 +27,9 @@ class OutputFormat(Enum): Unicode = enum.auto() -def angle_to_str(angle: float, output: OutputFormat, max_denominator: int = 1000) -> str: +def angle_to_str( + angle: float, output: OutputFormat, max_denominator: int = 1000, multiplication_sign: bool = False +) -> str: r""" Return a string representation of an angle given in units of π. @@ -43,6 +45,13 @@ def angle_to_str(angle: float, output: OutputFormat, max_denominator: int = 1000 Desired formatting style: Unicode (π symbol), LaTeX (\pi), or ASCII ("pi"). max_denominator : int, optional Maximum denominator for detecting a simple fraction (default: 1000). + multiplication_sign : bool + Optional (default: ``False``). + If ``True``, the multiplication sign is made explicit between the + numerator and π: + ``2×π`` in Unicode, ``2 \times \pi`` in LaTeX, and ``2*pi`` in ASCII. + If ``False``, the multiplication sign is implicit: + ``2π`` in Unicode, ``2\pi`` in LaTeX, ``2pi`` in ASCII. Returns ------- @@ -54,7 +63,7 @@ def angle_to_str(angle: float, output: OutputFormat, max_denominator: int = 1000 if not math.isclose(angle, float(frac)): rad = angle * math.pi - return f"{rad:.2f}" + return f"{rad}" num, den = frac.numerator, frac.denominator sign = "-" if num < 0 else "" @@ -65,21 +74,28 @@ def angle_to_str(angle: float, output: OutputFormat, max_denominator: int = 1000 def mkfrac(num: str, den: str) -> str: return rf"\frac{{{num}}}{{{den}}}" + + mul = r" \times " else: pi = "π" if output == OutputFormat.Unicode else "pi" def mkfrac(num: str, den: str) -> str: return f"{num}/{den}" + mul = "×" if output == OutputFormat.Unicode else "*" + + if not multiplication_sign: + mul = "" + if den == 1: if num == 0: return "0" if num == 1: return f"{sign}{pi}" - return f"{sign}{num}{pi}" + return f"{sign}{num}{mul}{pi}" den_str = f"{den}" - num_str = pi if num == 1 else f"{num}{pi}" + num_str = pi if num == 1 else f"{num}{mul}{pi}" return f"{sign}{mkfrac(num_str, den_str)}" diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 45340108d..60e9dec5f 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -2,19 +2,22 @@ from __future__ import annotations -from fractions import Fraction from math import pi from typing import TYPE_CHECKING # assert_never added in Python 3.11 from typing_extensions import assert_never +from graphix.fundamentals import Axis, Sign from graphix.instruction import Instruction, InstructionKind +from graphix.measurements import PauliMeasurement +from graphix.pretty_print import OutputFormat, angle_to_str if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator from graphix import Circuit + from graphix.parameter import ExpressionOrFloat def circuit_to_qasm3(circuit: Circuit) -> str: @@ -45,9 +48,33 @@ def circuit_to_qasm3_lines(circuit: Circuit) -> Iterator[str]: yield f"{instruction_to_qasm3(instr)};" +def qasm3_qubit(index: int) -> str: + """Return the name of the indexed qubit.""" + return f"q[{index}]" + + +def qasm3_gate_call(gate: str, operands: Iterable[str], args: Iterable[str] | None = None) -> str: + """Return the OpenQASM3 gate call.""" + operands_str = ", ".join(operands) + if args is None: + return f"{gate} {operands_str}" + args_str = ", ".join(args) + return f"{gate}({args_str}) {operands_str}" + + +def angle_to_qasm3(angle: ExpressionOrFloat) -> str: + """Get the OpenQASM3 representation of an angle.""" + if not isinstance(angle, float): + raise TypeError("QASM export of symbolic pattern is not supported") + rad_over_pi = angle / pi + return angle_to_str(rad_over_pi, output=OutputFormat.ASCII, multiplication_sign=True) + + def instruction_to_qasm3(instruction: Instruction) -> str: - """Get the qasm3 representation of a single circuit instruction.""" + """Get the OpenQASM3 representation of a single circuit instruction.""" if instruction.kind == InstructionKind.M: + if PauliMeasurement.try_from(instruction.plane, instruction.angle) != PauliMeasurement(Axis.Z, Sign.PLUS): + raise ValueError("OpenQASM3 only supports measurements in Z axis.") return f"b[{instruction.target}] = measure q[{instruction.target}]" # Use of `==` here for mypy if ( @@ -55,40 +82,38 @@ def instruction_to_qasm3(instruction: Instruction) -> str: or instruction.kind == InstructionKind.RY or instruction.kind == InstructionKind.RZ ): - if not isinstance(instruction.angle, float): - raise ValueError("QASM export of symbolic pattern is not supported") - rad_over_pi = instruction.angle / pi - tol = 1e-9 - frac = Fraction(rad_over_pi).limit_denominator(1000) - if abs(rad_over_pi - float(frac)) > tol: - angle = f"{rad_over_pi}*pi" - num, den = frac.numerator, frac.denominator - sign = "-" if num < 0 else "" - num = abs(num) - if den == 1: - angle = f"{sign}pi" if num == 1 else f"{sign}{num}*pi" - else: - angle = f"{sign}pi/{den}" if num == 1 else f"{sign}{num}*pi/{den}" - return f"{instruction.kind.name.lower()}({angle}) q[{instruction.target}]" + angle = angle_to_qasm3(instruction.angle) + return qasm3_gate_call(instruction.kind.name.lower(), args=[angle], operands=[qasm3_qubit(instruction.target)]) # Use of `==` here for mypy if ( instruction.kind == InstructionKind.H # noqa: PLR1714 - or instruction.kind == InstructionKind.I or instruction.kind == InstructionKind.S or instruction.kind == InstructionKind.X or instruction.kind == InstructionKind.Y or instruction.kind == InstructionKind.Z ): - return f"{instruction.kind.name.lower()} q[{instruction.target}]" + return qasm3_gate_call(instruction.kind.name.lower(), [qasm3_qubit(instruction.target)]) + if instruction.kind == InstructionKind.I: + return qasm3_gate_call("id", [qasm3_qubit(instruction.target)]) if instruction.kind == InstructionKind.CNOT: - return f"cx q[{instruction.control}], q[{instruction.target}]" + return qasm3_gate_call("cx", [qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)]) if instruction.kind == InstructionKind.SWAP: - return f"swap q[{instruction.targets[0]}], q[{instruction.targets[1]}]" + return qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) if instruction.kind == InstructionKind.RZZ: - return f"rzz q[{instruction.control}], q[{instruction.target}]" + angle = angle_to_qasm3(instruction.angle) + return qasm3_gate_call( + "crz", args=[angle], operands=[qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)] + ) if instruction.kind == InstructionKind.CCX: - return f"ccx q[{instruction.controls[0]}], q[{instruction.controls[1]}], q[{instruction.target}]" + return qasm3_gate_call( + "ccx", + [ + qasm3_qubit(instruction.controls[0]), + qasm3_qubit(instruction.controls[1]), + qasm3_qubit(instruction.target), + ], + ) # Use of `==` here for mypy if instruction.kind == InstructionKind._XC or instruction.kind == InstructionKind._ZC: # noqa: PLR1714 raise ValueError("Internal instruction should not appear") diff --git a/noxfile.py b/noxfile.py index 90b102797..79aff5549 100644 --- a/noxfile.py +++ b/noxfile.py @@ -78,8 +78,7 @@ def tests_qasm_parser(session: Session) -> None: # because Windows cannot delete a temporary directory while it # is the working directory. with TemporaryDirectory() as tmpdir, session.cd(tmpdir): - # See https://github.com/TeamGraphix/graphix-qasm-parser/pull/1 - session.run("git", "clone", "-b", "fix_typing_issues", "https://github.com/TeamGraphix/graphix-qasm-parser") + session.run("git", "clone", "https://github.com/TeamGraphix/graphix-qasm-parser") with session.cd("graphix-qasm-parser"): session.install(".") session.run("pytest", "--doctest-modules") diff --git a/pyproject.toml b/pyproject.toml index 68e31b4e5..76a9f4be6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,8 @@ extend-ignore = [ "W191", ] # Allow "α" (U+03B1 GREEK SMALL LETTER ALPHA) which could be confused for "a" -allowed-confusables = ["α"] +# Allow "×" (U+00D7 MULTIPLICATION SIGN) which could be confused for "x" +allowed-confusables = ["α", "×"] [tool.ruff.format] docstring-code-format = true diff --git a/requirements-dev.txt b/requirements-dev.txt index cfa219fd6..bc1e6bce3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -22,4 +22,4 @@ pytest-mock qiskit>=1.0 qiskit-aer -graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@fix_typing_issues +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@id_gate diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index 3d091d4c4..22202e388 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -2,14 +2,20 @@ from __future__ import annotations +from math import pi from typing import TYPE_CHECKING import pytest from numpy.random import PCG64, Generator -from graphix.qasm3_exporter import circuit_to_qasm3 +from graphix import Circuit, instruction +from graphix.fundamentals import Plane +from graphix.qasm3_exporter import angle_to_qasm3, circuit_to_qasm3 from graphix.random_objects import rand_circuit +if TYPE_CHECKING: + from graphix.instruction import Instruction + try: from graphix_qasm_parser import OpenQASMParser except ImportError: @@ -24,13 +30,61 @@ sys.exit(1) +def check_round_trip(circuit: Circuit) -> None: + qasm = circuit_to_qasm3(circuit) + parser = OpenQASMParser() + parsed_circuit = parser.parse_str(qasm) + assert parsed_circuit.instruction == circuit.instruction + + @pytest.mark.parametrize("jumps", range(1, 11)) def test_circuit_to_qasm3(fx_bg: PCG64, jumps: int) -> None: rng = Generator(fx_bg.jumped(jumps)) nqubits = 5 depth = 4 - circuit = rand_circuit(nqubits, depth, rng) + check_round_trip(rand_circuit(nqubits, depth, rng)) + + +@pytest.mark.parametrize( + "instruction", + [ + instruction.CCX(target=0, controls=(1, 2)), + instruction.RZZ(target=0, control=1, angle=pi / 4), + instruction.CNOT(target=0, control=1), + instruction.SWAP(targets=(0, 1)), + instruction.H(target=0), + instruction.S(target=0), + instruction.X(target=0), + instruction.Y(target=0), + instruction.Z(target=0), + instruction.I(target=0), + instruction.RX(target=0, angle=pi / 4), + instruction.RY(target=0, angle=pi / 4), + instruction.RZ(target=0, angle=pi / 4), + ], +) +def test_instruction_to_qasm3(instruction: Instruction) -> None: + check_round_trip(Circuit(3, instr=[instruction])) + + +@pytest.mark.parametrize("check", [(pi / 4, "pi/4"), (3 * pi / 4, "3*pi/4"), (0.5, "0.5")]) +def test_angle_to_qasm3(check: tuple[float, str]) -> None: + angle, expected = check + 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, plane=Plane.XZ, angle=0)]) qasm = circuit_to_qasm3(circuit) - parser = OpenQASMParser() - parsed_circuit = parser.parse_str(qasm) - assert parsed_circuit.instruction == circuit.instruction + assert ( + qasm + == """OPENQASM 3; +include "stdgates.inc"; +qubit[1] q; +bit[1] b; +b[0] = measure q[0];""" + )