Skip to content

Commit 1fda549

Browse files
Add OpenQASM 3 exporter for circuits
This commit introduces an OpenQASM 3 exporter for Graphix circuits. The functionality was originally proposed in TeamGraphix#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.
1 parent c9e3926 commit 1fda549

File tree

9 files changed

+179
-10
lines changed

9 files changed

+179
-10
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ jobs:
2323
runs-on: ${{ matrix.os }}
2424

2525
steps:
26-
- uses: actions/checkout@v4
26+
- uses: actions/checkout@v5
27+
with:
28+
fetch-depth: 0 # Fetch all, necessary to find tags and branches
29+
fetch-tags: true
2730

2831
- uses: actions/setup-python@v5
2932
with:

.github/workflows/cov.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ jobs:
1414
runs-on: ubuntu-latest
1515

1616
steps:
17-
- uses: actions/checkout@v4
17+
- uses: actions/checkout@v5
18+
with:
19+
fetch-depth: 0 # Fetch all, necessary to find tags and branches
20+
fetch-tags: true
1821

1922
- name: Set up Python
2023
uses: actions/setup-python@v5

.github/workflows/doc.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ jobs:
2020
name: "Check documentation"
2121
runs-on: ubuntu-latest
2222
steps:
23-
- uses: actions/checkout@v4
23+
- uses: actions/checkout@v5
24+
with:
25+
fetch-depth: 0 # Fetch all, necessary to find tags and branches
26+
fetch-tags: true
2427

2528
- uses: actions/setup-python@v5
2629
with:

.github/workflows/typecheck.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ jobs:
1919
runs-on: ubuntu-latest
2020

2121
steps:
22-
- uses: actions/checkout@v4
22+
- uses: actions/checkout@v5
23+
with:
24+
fetch-depth: 0 # Fetch all, necessary to find tags and branches
25+
fetch-tags: true
2326

2427
- uses: actions/setup-python@v5
2528
with:
@@ -46,7 +49,10 @@ jobs:
4649
runs-on: ubuntu-latest
4750

4851
steps:
49-
- uses: actions/checkout@v4
52+
- uses: actions/checkout@v5
53+
with:
54+
fetch-depth: 0 # Fetch all, necessary to find tags and branches
55+
fetch-tags: true
5056

5157
- uses: actions/setup-python@v5
5258
with:

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- #343: Circuit exporter to OpenQASM3:
13+
`graphix.qasm3_exporter.circuit_to_qasm3`.
14+
1215
- #337: New module `graphix.find_pauliflow` with the $O(N^3)$
1316
Pauli-flow finding algorithm introduced in Mitosek and Backens, 2024
1417
(arXiv:2410.23439).

graphix/qasm3_exporter.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Exporter to OpenQASM3."""
2+
3+
from __future__ import annotations
4+
5+
from fractions import Fraction
6+
from math import pi
7+
from typing import TYPE_CHECKING
8+
9+
# assert_never added in Python 3.11
10+
from typing_extensions import assert_never
11+
12+
from graphix.instruction import Instruction, InstructionKind
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Iterator
16+
17+
from graphix import Circuit
18+
19+
20+
def circuit_to_qasm3(circuit: Circuit) -> str:
21+
"""Export circuit instructions to OpenQASM 3.0 representation.
22+
23+
Returns
24+
-------
25+
str
26+
The OpenQASM 3.0 string representation of the circuit.
27+
"""
28+
return "\n".join(circuit_to_qasm3_lines(circuit))
29+
30+
31+
def circuit_to_qasm3_lines(circuit: Circuit) -> Iterator[str]:
32+
"""Export circuit instructions to line-by-line OpenQASM 3.0 representation.
33+
34+
Returns
35+
-------
36+
Iterator[str]
37+
The OpenQASM 3.0 lines that represent the circuit.
38+
"""
39+
yield "OPENQASM 3;"
40+
yield 'include "stdgates.inc";'
41+
yield f"qubit[{circuit.width}] q;"
42+
if any(instr.kind == InstructionKind.M for instr in circuit.instruction):
43+
yield f"bit[{circuit.width}] b;"
44+
for instr in circuit.instruction:
45+
yield f"{instruction_to_qasm3(instr)};"
46+
47+
48+
def instruction_to_qasm3(instruction: Instruction) -> str:
49+
"""Get the qasm3 representation of a single circuit instruction."""
50+
if instruction.kind == InstructionKind.M:
51+
return f"b[{instruction.target}] = measure q[{instruction.target}]"
52+
# Use of `==` here for mypy
53+
if (
54+
instruction.kind == InstructionKind.RX # noqa: PLR1714
55+
or instruction.kind == InstructionKind.RY
56+
or instruction.kind == InstructionKind.RZ
57+
):
58+
if not isinstance(instruction.angle, float):
59+
raise ValueError("QASM export of symbolic pattern is not supported")
60+
rad_over_pi = instruction.angle / pi
61+
tol = 1e-9
62+
frac = Fraction(rad_over_pi).limit_denominator(1000)
63+
if abs(rad_over_pi - float(frac)) > tol:
64+
angle = f"{rad_over_pi}*pi"
65+
num, den = frac.numerator, frac.denominator
66+
sign = "-" if num < 0 else ""
67+
num = abs(num)
68+
if den == 1:
69+
angle = f"{sign}pi" if num == 1 else f"{sign}{num}*pi"
70+
else:
71+
angle = f"{sign}pi/{den}" if num == 1 else f"{sign}{num}*pi/{den}"
72+
return f"{instruction.kind.name.lower()}({angle}) q[{instruction.target}]"
73+
74+
# Use of `==` here for mypy
75+
if (
76+
instruction.kind == InstructionKind.H # noqa: PLR1714
77+
or instruction.kind == InstructionKind.I
78+
or instruction.kind == InstructionKind.S
79+
or instruction.kind == InstructionKind.X
80+
or instruction.kind == InstructionKind.Y
81+
or instruction.kind == InstructionKind.Z
82+
):
83+
return f"{instruction.kind.name.lower()} q[{instruction.target}]"
84+
if instruction.kind == InstructionKind.CNOT:
85+
return f"cx q[{instruction.control}], q[{instruction.target}]"
86+
if instruction.kind == InstructionKind.SWAP:
87+
return f"swap q[{instruction.targets[0]}], q[{instruction.targets[1]}]"
88+
if instruction.kind == InstructionKind.RZZ:
89+
return f"rzz q[{instruction.control}], q[{instruction.target}]"
90+
if instruction.kind == InstructionKind.CCX:
91+
return f"ccx q[{instruction.controls[0]}], q[{instruction.controls[1]}], q[{instruction.target}]"
92+
# Use of `==` here for mypy
93+
if instruction.kind == InstructionKind._XC or instruction.kind == InstructionKind._ZC: # noqa: PLR1714
94+
raise ValueError("Internal instruction should not appear")
95+
assert_never(instruction.kind)

noxfile.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def install_pytest(session: Session) -> None:
1616
@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
1717
def tests_minimal(session: Session) -> None:
1818
"""Run the test suite with minimal dependencies."""
19-
session.install("-e", ".")
19+
session.install(".")
2020
install_pytest(session)
2121
# We cannot run `pytest --doctest-modules` here, since some tests
2222
# involve optional dependencies, like pyzx.
@@ -27,7 +27,7 @@ def tests_minimal(session: Session) -> None:
2727
@nox.session(python=["3.10", "3.11", "3.12", "3.13"])
2828
def tests_dev(session: Session) -> None:
2929
"""Run the test suite with dev dependencies."""
30-
session.install("-e", ".[dev]")
30+
session.install(".[dev]")
3131
# We cannot run `pytest --doctest-modules` here, since some tests
3232
# involve optional dependencies, like pyzx.
3333
session.run("pytest")
@@ -36,7 +36,7 @@ def tests_dev(session: Session) -> None:
3636
@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
3737
def tests_extra(session: Session) -> None:
3838
"""Run the test suite with extra dependencies."""
39-
session.install("-e", ".[extra]")
39+
session.install(".[extra]")
4040
install_pytest(session)
4141
session.install("nox") # needed for `--doctest-modules`
4242
session.run("pytest", "--doctest-modules")
@@ -45,14 +45,14 @@ def tests_extra(session: Session) -> None:
4545
@nox.session(python=["3.10", "3.11", "3.12", "3.13"])
4646
def tests_all(session: Session) -> None:
4747
"""Run the test suite with all dependencies."""
48-
session.install("-e", ".[dev,extra]")
48+
session.install(".[dev,extra]")
4949
session.run("pytest", "--doctest-modules")
5050

5151

5252
@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
5353
def tests_symbolic(session: Session) -> None:
5454
"""Run the test suite of graphix-symbolic."""
55-
session.install("-e", ".")
55+
session.install(".")
5656
install_pytest(session)
5757
session.install("nox") # needed for `--doctest-modules`
5858
# Use `session.cd` as a context manager to ensure that the
@@ -67,3 +67,21 @@ def tests_symbolic(session: Session) -> None:
6767
# session.run("git", "clone", "https://github.com/TeamGraphix/graphix-symbolic")
6868
with session.cd("graphix-symbolic"):
6969
session.run("pytest", "--doctest-modules")
70+
71+
72+
@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"])
73+
def tests_qasm_parser(session: Session) -> None:
74+
"""Run the test suite of graphix-qasm-parser."""
75+
session.install(".")
76+
install_pytest(session)
77+
session.install("nox") # needed for `--doctest-modules`
78+
# Use `session.cd` as a context manager to ensure that the
79+
# working directory is restored afterward. This is important
80+
# because Windows cannot delete a temporary directory while it
81+
# is the working directory.
82+
with TemporaryDirectory() as tmpdir, session.cd(tmpdir):
83+
# See https://github.com/TeamGraphix/graphix-qasm-parser/pull/1
84+
session.run("git", "clone", "-b", "fix_typing_issues", "https://github.com/TeamGraphix/graphix-qasm-parser")
85+
with session.cd("graphix-qasm-parser"):
86+
session.install(".")
87+
session.run("pytest", "--doctest-modules")

requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ pytest-mock
2121
# Optional dependencies
2222
qiskit>=1.0
2323
qiskit-aer
24+
25+
graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@fix_typing_issues

tests/test_qasm3_exporter.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Test exporter to OpenQASM3."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
from numpy.random import PCG64, Generator
9+
10+
from graphix.qasm3_exporter import circuit_to_qasm3
11+
from graphix.random_objects import rand_circuit
12+
13+
try:
14+
from graphix_qasm_parser import OpenQASMParser
15+
except ImportError:
16+
pytestmark = pytest.mark.skip(reason="graphix-qasm-parser not installed")
17+
18+
if TYPE_CHECKING:
19+
import sys
20+
21+
# We skip type-checking the case where there is no
22+
# graphix-qasm-parser, since pyright cannot figure out that
23+
# tests are skipped in this case.
24+
sys.exit(1)
25+
26+
27+
@pytest.mark.parametrize("jumps", range(1, 11))
28+
def test_circuit_to_qasm3(fx_bg: PCG64, jumps: int) -> None:
29+
rng = Generator(fx_bg.jumped(jumps))
30+
nqubits = 5
31+
depth = 4
32+
circuit = rand_circuit(nqubits, depth, rng)
33+
qasm = circuit_to_qasm3(circuit)
34+
parser = OpenQASMParser()
35+
parsed_circuit = parser.parse_str(qasm)
36+
assert parsed_circuit.instruction == circuit.instruction

0 commit comments

Comments
 (0)