From 48d1f643b28bcd065779548c2b5059b5dc885fe3 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 24 Dec 2025 00:53:55 +0100 Subject: [PATCH 01/12] Fix #177: Correct pattern export to OpenQASM 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces `pattern_to_qasm3`, improving the export of patterns to OpenQASM 3: - Planes YZ and XZ are now handled correctly (#177). - For plane XY, the angle is no longer ignored when the s-domain is empty. - Support for |0⟩ and |+⟩ initial states. - Arithmetic expressions unsupported by `qiskit-qasm3-import` are replaced with a simpler encoding, and the `single_qubit_domains` pass ensures compatibility with Qiskit. - `test_qasm3_exporter_to_qiskit.py` verifies that the Graphix pattern simulator and Qiskit AER simulator produce equivalent statevectors for exported circuits. Additionally, this commit fixes a regression from #312: statevectors are now properly normalized. --- docs/source/references.rst | 1 + graphix/optimization.py | 48 +++++++-- graphix/pattern.py | 103 ++---------------- graphix/qasm3_exporter.py | 143 ++++++++++++++++++++++++- graphix/sim/statevec.py | 2 + requirements-dev.txt | 1 + tests/test_qasm3_exporter_to_qiskit.py | 128 ++++++++++++++++++++++ tests/test_statevec.py | 8 +- 8 files changed, 331 insertions(+), 103 deletions(-) create mode 100644 tests/test_qasm3_exporter_to_qiskit.py diff --git a/docs/source/references.rst b/docs/source/references.rst index 8ebd74370..dfc818d58 100644 --- a/docs/source/references.rst +++ b/docs/source/references.rst @@ -16,3 +16,4 @@ Module reference channels random_objects open_graph + optimization diff --git a/graphix/optimization.py b/graphix/optimization.py index cc67f2833..f0a7daa27 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -21,7 +21,7 @@ from graphix.measurements import Domains, Outcome, PauliMeasurement if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Callable, Iterable, Mapping from collections.abc import Set as AbstractSet from typing import Self @@ -32,14 +32,15 @@ def standardize(pattern: Pattern) -> Pattern: """Return a standardized form to the given pattern. A standardized form is an equivalent pattern where the commands - appear in the following order: `N`, `E`, `M`, `Z`, `X`, `C`. + appear in the following order: ``N``, ``E``, ``M``, ``Z``, ``X``, + ``C``. Note that a standardized form does not always exist in presence of - `C` commands. For instance, there is no standardized form for the + ``C`` commands. For instance, there is no standardized form for the following pattern (written in the right-to-left convention): - `E(0, 1) C(0, H) N(1) N(0)`. + ``E(0, 1) C(0, H) N(1) N(0)``. - The function raises `NotImplementedError` if there is no + The function raises ``NotImplementedError`` if there is no standardized form. This behavior can change in the future. @@ -52,6 +53,7 @@ def standardize(pattern: Pattern) -> Pattern: ------- standardized : Pattern The standardized pattern, if it exists. + """ return StandardizedPattern.from_pattern(pattern).to_pattern() @@ -88,7 +90,7 @@ class StandardizedPattern(_StandardizedPattern): Instances can be generated with the constructor from any compatible data structures, and an instance can be generated - directly from a pattern with the class method `from_pattern`. + directly from a pattern with the class method :meth:`from_pattern`. The constructor instantiates the ``Mapping`` fields as ``MappingProxyType`` objects over fresh dictionaries, ensuring @@ -481,3 +483,37 @@ def remove_useless_domains(pattern: Pattern) -> Pattern: new_pattern.add(cmd) new_pattern.reorder_output_nodes(pattern.output_nodes) return new_pattern + + +def single_qubit_domains(pattern: Pattern) -> Pattern: + """Return an equivalent pattern where domains contains at most one qubit.""" + new_pattern = graphix.pattern.Pattern(input_nodes=pattern.input_nodes) + new_pattern.results = pattern.results + + def decompose_domain(cmd: Callable[[int, set[int]], command.Command], node: int, domain: AbstractSet[int]) -> bool: + if len(domain) <= 1: + return False + for src in domain: + new_pattern.add(cmd(node, {src})) + return True + + for cmd in pattern: + if cmd.kind == CommandKind.M: + replaced_s_domain = decompose_domain(command.X, cmd.node, cmd.s_domain) + replaced_t_domain = decompose_domain(command.Z, cmd.node, cmd.t_domain) + if replaced_s_domain or replaced_t_domain: + new_s_domain = set() if replaced_s_domain else cmd.s_domain + new_t_domain = set() if replaced_t_domain else cmd.t_domain + new_cmd = dataclasses.replace(cmd, s_domain=new_s_domain, t_domain=new_t_domain) + new_pattern.add(new_cmd) + continue + elif cmd.kind == CommandKind.X: + if decompose_domain(command.X, cmd.node, cmd.domain): + continue + elif cmd.kind == CommandKind.Z: + if decompose_domain(command.Z, cmd.node, cmd.domain): + continue + new_pattern.add(cmd) + + new_pattern.reorder_output_nodes(pattern.output_nodes) + return new_pattern diff --git a/graphix/pattern.py b/graphix/pattern.py index dbc312553..db6cbac8a 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -27,6 +27,7 @@ from graphix.measurements import Measurement, Outcome, PauliMeasurement, toggle_outcome from graphix.opengraph import OpenGraph from graphix.pretty_print import OutputFormat, pattern_to_str +from graphix.qasm3_exporter import pattern_to_qasm3_lines from graphix.simulator import PatternSimulator from graphix.states import BasicStates from graphix.visualization import GraphVisualizer @@ -1461,27 +1462,21 @@ def draw_graph( filename=filename, ) - def to_qasm3(self, filename: Path | str) -> None: + def to_qasm3(self, filename: Path | str, input_state: dict[int, State] | State = BasicStates.PLUS) -> None: """Export measurement pattern to OpenQASM 3.0 file. + See :func:`graphix.qasm3_exporter.pattern_to_qasm3`. + Parameters ---------- filename : Path | str - file name to export to. example: "filename.qasm" + File name to export to. Example: "filename.qasm" + + input_state : dict[int, State] | State, default BasicStates.PLUS + The initial state for each input node. Only |0⟩ or |+⟩ states are supported. """ with Path(filename).with_suffix(".qasm").open("w", encoding="utf-8") as file: - file.write("// generated by graphix\n") - file.write("OPENQASM 3;\n") - file.write('include "stdgates.inc";\n') - file.write("\n") - if self.results != {}: - for i in self.results: - res = self.results[i] - file.write("// measurement result of qubit q" + str(i) + "\n") - file.write("bit c" + str(i) + " = " + str(res) + ";\n") - file.write("\n") - for cmd in self.__seq: - file.writelines(cmd_to_qasm3(cmd)) + file.writelines(pattern_to_qasm3_lines(self, input_state=input_state)) def is_parameterized(self) -> bool: """ @@ -1789,86 +1784,6 @@ def pauli_nodes( return pauli_node, non_pauli_node -def cmd_to_qasm3(cmd: Command) -> Iterator[str]: - """Convert a command in the pattern into OpenQASM 3.0 statement. - - Parameter - --------- - cmd : list - command [type:str, node:int, attr] - - Yields - ------ - string - translated pattern commands in OpenQASM 3.0 language - - """ - if cmd.kind == CommandKind.N: - qubit = cmd.node - yield "// prepare qubit q" + str(qubit) + "\n" - yield "qubit q" + str(qubit) + ";\n" - yield "h q" + str(qubit) + ";\n" - yield "\n" - - elif cmd.kind == CommandKind.E: - qubits = cmd.nodes - yield "// entangle qubit q" + str(qubits[0]) + " and q" + str(qubits[1]) + "\n" - yield "cz q" + str(qubits[0]) + ", q" + str(qubits[1]) + ";\n" - yield "\n" - - elif cmd.kind == CommandKind.M: - qubit = cmd.node - plane = cmd.plane - alpha = cmd.angle - sdomain = cmd.s_domain - tdomain = cmd.t_domain - yield "// measure qubit q" + str(qubit) + "\n" - yield "bit c" + str(qubit) + ";\n" - yield "float theta" + str(qubit) + " = 0;\n" - if plane == Plane.XY: - if sdomain: - yield "int s" + str(qubit) + " = 0;\n" - for sid in sdomain: - yield "s" + str(qubit) + " += c" + str(sid) + ";\n" - yield "theta" + str(qubit) + " += (-1)**(s" + str(qubit) + " % 2) * (" + str(alpha) + " * pi);\n" - if tdomain: - yield "int t" + str(qubit) + " = 0;\n" - for tid in tdomain: - yield "t" + str(qubit) + " += c" + str(tid) + ";\n" - yield "theta" + str(qubit) + " += t" + str(qubit) + " * pi;\n" - yield "p(-theta" + str(qubit) + ") q" + str(qubit) + ";\n" - yield "h q" + str(qubit) + ";\n" - yield "c" + str(qubit) + " = measure q" + str(qubit) + ";\n" - yield "h q" + str(qubit) + ";\n" - yield "p(theta" + str(qubit) + ") q" + str(qubit) + ";\n" - yield "\n" - - # Use of == for mypy - elif cmd.kind == CommandKind.X or cmd.kind == CommandKind.Z: # noqa: PLR1714 - qubit = cmd.node - sdomain = cmd.domain - yield "// byproduct correction on qubit q" + str(qubit) + "\n" - yield "int s" + str(qubit) + " = 0;\n" - for sid in sdomain: - yield "s" + str(qubit) + " += c" + str(sid) + ";\n" - yield "if(s" + str(qubit) + " % 2 == 1){\n" - if cmd.kind == CommandKind.X: - yield "\t x q" + str(qubit) + ";\n}\n" - else: - yield "\t z q" + str(qubit) + ";\n}\n" - yield "\n" - - elif cmd.kind == CommandKind.C: - qubit = cmd.node - yield "// Clifford operations on qubit q" + str(qubit) + "\n" - for op in cmd.clifford.qasm3: - yield str(op) + " q" + str(qubit) + ";\n" - yield "\n" - - else: - raise ValueError(f"invalid command {cmd}") - - def assert_permutation(original: list[int], user: list[int]) -> None: """Check that the provided `user` node list is a permutation from `original`.""" node_set = set(user) diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index c798d86f9..57541b49f 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -8,14 +8,17 @@ # assert_never added in Python 3.11 from typing_extensions import assert_never -from graphix.fundamentals import Axis +from graphix.command import CommandKind +from graphix.fundamentals import Axis, Plane from graphix.instruction import Instruction, InstructionKind from graphix.pretty_print import OutputFormat, angle_to_str +from graphix.states import BasicStates, State if TYPE_CHECKING: from collections.abc import Iterable, Iterator - from graphix import Circuit + from graphix import Circuit, Pattern + from graphix.command import Command from graphix.parameter import ExpressionOrFloat @@ -121,3 +124,139 @@ def instruction_to_qasm3(instruction: Instruction) -> str: if instruction.kind == InstructionKind._XC or instruction.kind == InstructionKind._ZC: # noqa: PLR1714 raise ValueError("Internal instruction should not appear") assert_never(instruction.kind) + + +def pattern_to_qasm3(pattern: Pattern, input_state: dict[int, State] | State = BasicStates.PLUS) -> str: + """Export a pattern to OpenQASM 3.0 representation. + + The generated OpenQASM may include initializations of classical + qubits if the pattern has been Pauli-presimulated, and it may include + Boolean expressions using xor (`^`) if some domains contain + multiple qubits. These features are not supported by + `qiskit-qasm3-import`. The functions + :func:`graphix.optimization.incorporate_pauli_results` and + :func:`graphix.optimization.single_qubit_domains` transform any + pattern into an equivalent one such that exporting to OpenQASM 3.0 + produces a circuit that can be imported into Qiskit. + + Parameters + ---------- + pattern : Pattern + The pattern to export. + + 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)) + + +def pattern_to_qasm3_lines(pattern: Pattern, input_state: dict[int, State] | State = BasicStates.PLUS) -> Iterator[str]: + """Export pattern to line-by-line OpenQASM 3.0 representation. + + See :func:`pattern_to_qasm3`. + """ + yield "// generated by graphix\n" + yield "OPENQASM 3;\n" + yield 'include "stdgates.inc";\n' + yield "\n" + for node in pattern.input_nodes: + yield f"// prepare input qubit {node} in |+⟩\n" + 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 "\n" + if pattern.results != {}: + for i in pattern.results: + res = pattern.results[i] + yield f"// measurement result of qubit q{i}\n" + yield f"bit c{i} = {res};\n" + yield "\n" + for cmd in pattern: + yield from command_to_qasm3_lines(cmd) + + +def command_to_qasm3_lines(cmd: Command) -> Iterator[str]: + """Convert a command in the pattern into OpenQASM 3.0 statement. + + Parameter + --------- + cmd : Command + command + + Yields + ------ + string + translated pattern commands in OpenQASM 3.0 language + + """ + yield f"// {cmd}\n" + if cmd.kind == CommandKind.N: + yield f"qubit q{cmd.node};\n" + yield from state_to_qasm3_lines(cmd.node, cmd.state) + + elif cmd.kind == CommandKind.E: + n0, n1 = cmd.nodes + yield f"cz q{n0}, q{n1};\n" + + elif cmd.kind == 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}") + if cmd.plane == Plane.XY: + yield f"h q{cmd.node};\n" + if cmd.angle != 0: + if cmd.plane == Plane.XY: + yield f"rx({-cmd.angle * pi}) q{cmd.node};\n" + elif cmd.plane == Plane.XZ: + yield f"ry({-cmd.angle * pi}) q{cmd.node};\n" + else: + yield f"rx({cmd.angle * pi}) q{cmd.node};\n" + yield f"bit c{cmd.node};\n" + yield f"c{cmd.node} = measure q{cmd.node};\n" + + elif cmd.kind == CommandKind.X: + yield from domain_to_qasm3_lines(cmd.domain, f"x q{cmd.node}") + + elif cmd.kind == CommandKind.Z: + yield from domain_to_qasm3_lines(cmd.domain, f"z q{cmd.node}") + + elif cmd.kind == CommandKind.C: + for op in cmd.clifford.qasm3: + yield str(op) + " q" + str(cmd.node) + ";\n" + + else: + raise ValueError(f"invalid command {cmd}") + + yield "\n" + + +def state_to_qasm3_lines(node: int, state: State) -> Iterator[str]: + """Convert initial state into OpenQASM 3.0 statement.""" + if state == BasicStates.ZERO: + pass + elif state == BasicStates.PLUS: + yield f"h q{node};\n" + else: + raise ValueError("QASM3 conversion only supports |0⟩ or |+⟩ initial states.") + + +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 + cmd : str + controlled command + + Yields + ------ + string + translated controlled command in OpenQASM 3.0 language + """ + condition = " ^ ".join(f"c{node}" for node in domain) + if not condition: + return + yield f"if ({condition}) {{\n" + yield f" {cmd};\n" + yield "}\n" diff --git a/graphix/sim/statevec.py b/graphix/sim/statevec.py index cf3f1e8b9..b27ae9695 100644 --- a/graphix/sim/statevec.py +++ b/graphix/sim/statevec.py @@ -336,10 +336,12 @@ def normalize(self) -> None: psi_o = self.psi.astype(np.object_, copy=False) norm_o = _get_statevec_norm_symbolic(psi_o) psi_o /= norm_o + self.psi = psi_o else: psi_c = self.psi.astype(np.complex128, copy=False) norm_c = _get_statevec_norm_numeric(psi_c) psi_c /= norm_c + self.psi = psi_c def flatten(self) -> Matrix: """Return flattened statevector.""" diff --git a/requirements-dev.txt b/requirements-dev.txt index bdf8b5778..2aa29b347 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,6 +23,7 @@ pytest-mpl # Optional dependencies qiskit>=1.0 qiskit-aer +qiskit-qasm3-import openqasm-parser>=3.1.0 graphix-qasm-parser diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py new file mode 100644 index 000000000..856aaa248 --- /dev/null +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -0,0 +1,128 @@ +"""Test exporter to OpenQASM3 targetting Qiskit.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import pytest +from numpy.random import PCG64, Generator + +from graphix import Circuit, Pattern +from graphix.branch_selector import FixedBranchSelector +from graphix.clifford import Clifford +from graphix.command import C, CommandKind, E, M, N +from graphix.fundamentals import Plane +from graphix.measurements import Measurement, outcome +from graphix.optimization import incorporate_pauli_results, single_qubit_domains +from graphix.qasm3_exporter import pattern_to_qasm3 +from graphix.random_objects import rand_circuit +from graphix.sim.statevec import StatevectorBackend +from graphix.states import BasicStates + +if TYPE_CHECKING: + from graphix.measurements import Outcome + from graphix.states import State + +try: + import qiskit + import qiskit_qasm3_import + from qiskit_aer import AerSimulator # type:ignore[attr-defined] +except ImportError: + pytestmark = pytest.mark.skip(reason="Missing packages: qiskit, qiskit_qasm3_import, qiskit_aer") + + 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) + + +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) + qc = qiskit_qasm3_import.parse(qasm3) + qc.save_statevector() # type:ignore[attr-defined] + aer_backend = AerSimulator(method="statevector") + transpiled = qiskit.transpile(qc, aer_backend) + result = aer_backend.run(transpiled, shots=1, memory=True).result() + if qc.clbits: + # One bitstring per shot; we ran exactly one shot. + memory = result.get_memory()[0] + # Qiskit reports measurement outcomes in reversed order: + # the first measured qubit appears at the end of the string. + results: dict[int, Outcome] = { + cmd.node: outcome(measurement == "1") + for cmd, measurement in zip(pattern.extract_measurement_commands(), reversed(memory), strict=True) + } + else: + results = {} + branch_selector = FixedBranchSelector(results) + backend = StatevectorBackend(branch_selector=branch_selector) + # Qiskit and Graphix order qubits in opposite directions. + nodes = [ + node + for src in (pattern.input_nodes, (cmd.node for cmd in pattern if cmd.kind == CommandKind.N)) + for node in src + ] + nodes.reverse() + backend.add_nodes(nodes=nodes, data=np.asarray(result.get_statevector())) + # Trace out measured qubits. + for cmd in pattern.extract_measurement_commands(): + backend.measure(cmd.node, Measurement(angle=0, plane=Plane.XZ)) + # Reorder qubits to match the pattern's expected output ordering. + backend.finalize(pattern.output_nodes) + state_qiskit = backend.state + state_mbqc = pattern.simulate_pattern(branch_selector=branch_selector) + assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state_qiskit.flatten())) == pytest.approx(1) + + +def test_to_qasm3_qubits_preparation() -> None: + check_qasm3(Pattern(cmds=[N(0), N(1)])) + check_qasm3(Pattern(input_nodes=[0], cmds=[N(1)])) + + +def test_to_qasm3_entanglement() -> None: + check_qasm3(Pattern(input_nodes=[0, 1], cmds=[E((0, 1))])) + check_qasm3(Pattern(input_nodes=[0, 1], cmds=[N(2), E((1, 2))])) + + +@pytest.mark.parametrize("clifford", Clifford) +@pytest.mark.parametrize("state", [BasicStates.ZERO, BasicStates.PLUS]) +def test_to_qasm3_clifford(clifford: Clifford, state: State) -> None: + check_qasm3(Pattern(cmds=[N(0, state), C(0, clifford)])) + + +@pytest.mark.parametrize("state", [BasicStates.ZERO, BasicStates.PLUS]) +@pytest.mark.parametrize("plane", list(Plane)) +@pytest.mark.parametrize("angle", [0, 0.25, 1.75]) +def test_to_qasm3_measurement(state: State, plane: Plane, angle: float) -> None: + check_qasm3(Pattern(cmds=[N(0, state), N(1), E((0, 1)), M(0, plane=plane, angle=angle)])) + + +def test_to_qasm3_hadamard() -> None: + circuit = Circuit(1) + circuit.h(0) + pattern = circuit.transpile().pattern + check_qasm3(pattern) + + +@pytest.mark.parametrize("jumps", range(1, 11)) +def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: + rng = Generator(fx_bg.jumped(jumps)) + nqubits = 5 + depth = 5 + circuit = rand_circuit(nqubits, depth, rng=rng) + pattern = circuit.transpile().pattern + pattern.perform_pauli_measurements() + pattern.minimize_space() + + # qiskit_qasm3_import.exceptions.ConversionError: initialisation of classical bits is not supported + pattern = incorporate_pauli_results(pattern) + + # qiskit_qasm3_import.exceptions.ConversionError: unhandled binary operator '^' + pattern = single_qubit_domains(pattern) + + check_qasm3(pattern) diff --git a/tests/test_statevec.py b/tests/test_statevec.py index 3b010a015..98f85ac5b 100644 --- a/tests/test_statevec.py +++ b/tests/test_statevec.py @@ -7,7 +7,7 @@ import pytest from graphix.fundamentals import Plane -from graphix.sim.statevec import Statevec +from graphix.sim.statevec import Statevec, _get_statevec_norm_numeric from graphix.states import BasicStates, PlanarState if TYPE_CHECKING: @@ -143,3 +143,9 @@ def test_copy_fail(self, fx_rng: Generator) -> None: with pytest.raises(ValueError): _vec = Statevec(nqubit=length - 1, data=test_vec) + + +def test_normalize() -> None: + statevec = Statevec(nqubit=1, data=BasicStates.PLUS) + statevec.remove_qubit(0) + assert _get_statevec_norm_numeric(statevec) == 1 From 320262a5eb875c6db3491e30b742834a6cbda41d Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 24 Dec 2025 01:33:30 +0100 Subject: [PATCH 02/12] Fix ruff --- graphix/pattern.py | 1 + 1 file changed, 1 insertion(+) diff --git a/graphix/pattern.py b/graphix/pattern.py index db6cbac8a..fdb3a74e7 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -41,6 +41,7 @@ from graphix.parameter import ExpressionOrFloat, ExpressionOrSupportsFloat, Parameter from graphix.sim import Backend, BackendState, Data + from graphix.states import State _StateT_co = TypeVar("_StateT_co", bound="BackendState", covariant=True) From 0b2d08f7d56cabcd66dccca6cfa488d97d528013 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 24 Dec 2025 01:35:13 +0100 Subject: [PATCH 03/12] Missing file --- docs/source/optimization.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/source/optimization.rst diff --git a/docs/source/optimization.rst b/docs/source/optimization.rst new file mode 100644 index 000000000..0716e712c --- /dev/null +++ b/docs/source/optimization.rst @@ -0,0 +1,19 @@ +Optimization passes +====================== + +:mod:`graphix.optimization` module +++++++++++++++++++++++++++++++++++ + +This module defines some optimization passes for patterns. + +.. currentmodule:: graphix.optimization + +.. autofunction:: standardize + +.. autoclass:: StandardizedPattern + +.. autofunction:: incorporate_pauli_results + +.. autofunction:: remove_useless_domains + +.. autofunction:: single_qubit_domains From d7d17ac45dd078a4db7ed2b7d6ad66b86e82d1d9 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 24 Dec 2025 01:35:52 +0100 Subject: [PATCH 04/12] Fix doc --- docs/source/optimization.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/optimization.rst b/docs/source/optimization.rst index 0716e712c..bee7a4dea 100644 --- a/docs/source/optimization.rst +++ b/docs/source/optimization.rst @@ -1,5 +1,5 @@ Optimization passes -====================== +=================== :mod:`graphix.optimization` module ++++++++++++++++++++++++++++++++++ From 68b1887559556de68c7294c29d8e6f22edb9e401 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 24 Dec 2025 01:42:50 +0100 Subject: [PATCH 05/12] Fix doc --- graphix/pattern.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index fdb3a74e7..808859b08 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1471,10 +1471,10 @@ def to_qasm3(self, filename: Path | str, input_state: dict[int, State] | State = Parameters ---------- filename : Path | str - File name to export to. Example: "filename.qasm" + File name to export to. Example: ``"filename.qasm"``. input_state : dict[int, State] | State, default BasicStates.PLUS - The initial state for each input node. Only |0⟩ or |+⟩ states are supported. + The initial state for each input node. Only ``|0⟩`` or ``|+⟩`` states are supported. """ with Path(filename).with_suffix(".qasm").open("w", encoding="utf-8") as file: file.writelines(pattern_to_qasm3_lines(self, input_state=input_state)) From 08045bd53498d49f599c03aa2a2f056a82cf80ce Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 6 Jan 2026 09:42:28 +0100 Subject: [PATCH 06/12] Fix comment --- tests/test_qasm3_exporter_to_qiskit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py index 856aaa248..e49729da9 100644 --- a/tests/test_qasm3_exporter_to_qiskit.py +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -34,9 +34,9 @@ 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. + # We skip type-checking the case where there is no qiskit, + # since pyright cannot figure out that tests are skipped in + # this case. sys.exit(1) From c712ed1a2046fa3dc9d28fa95d14990c12e51073 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 8 Jan 2026 14:43:11 +0100 Subject: [PATCH 07/12] Separate OpenQASM3 tests that do not rely on external dependencies --- tests/test_qasm3_exporter.py | 89 +++++++++----------------- tests/test_qasm3_exporter_to_qiskit.py | 4 +- 2 files changed, 32 insertions(+), 61 deletions(-) diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index c03382c13..f9751d0de 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -1,73 +1,21 @@ -"""Test exporter to OpenQASM3.""" +"""Test exporter to OpenQASM3 without external dependencies. -from __future__ import annotations +See Also +-------- +- :mod:`test_qasm3_exporter_to_graphix_parser`, which checks the round trip with ``graphix-qasm-parser``; +- :mod:`test_qasm3_exporter_to_qiskit`, which checks against Qiskit simulation. +""" -from typing import TYPE_CHECKING +from __future__ import annotations import pytest from numpy.random import PCG64, Generator from graphix import Circuit, instruction from graphix.fundamentals import ANGLE_PI, Axis -from graphix.qasm3_exporter import angle_to_qasm3, circuit_to_qasm3 +from graphix.qasm3_exporter import angle_to_qasm3, circuit_to_qasm3, pattern_to_qasm3 from graphix.random_objects import rand_circuit -if TYPE_CHECKING: - from graphix.instruction import Instruction - -try: - from graphix_qasm_parser import OpenQASMParser # type: ignore[import-not-found, unused-ignore] -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) - - -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 - # See https://github.com/TeamGraphix/graphix-qasm-parser/pull/5 - check_round_trip(rand_circuit(nqubits, depth, rng, use_cz=False)) - - -@pytest.mark.parametrize( - "instruction", - [ - instruction.CCX(target=0, controls=(1, 2)), - instruction.RZZ(target=0, control=1, angle=ANGLE_PI / 4), - instruction.CNOT(target=0, control=1), - instruction.SWAP(targets=(0, 1)), - # See https://github.com/TeamGraphix/graphix-qasm-parser/pull/5 - # instruction.CZ(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=ANGLE_PI / 4), - instruction.RY(target=0, angle=ANGLE_PI / 4), - instruction.RZ(target=0, angle=ANGLE_PI / 4), - ], -) -def test_instruction_to_qasm3(instruction: Instruction) -> None: - check_round_trip(Circuit(3, instr=[instruction])) - @pytest.mark.parametrize("check", [(ANGLE_PI / 4, "pi/4"), (3 * ANGLE_PI / 4, "3*pi/4"), (ANGLE_PI / 2, "pi/2")]) def test_angle_to_qasm3(check: tuple[float, str]) -> None: @@ -90,3 +38,24 @@ def test_measurement() -> None: 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. + + See + :func:`test_qasm3_exporter_to_qiskit:test_to_qasm3_random_circuit`, + where the result is validated. The current test does not go through the + normalization passes ``incorporate_pauli_results`` and ``single_qubit_domains``, + so it exercises execution paths that are not tested elsewhere. + """ + rng = Generator(fx_bg.jumped(jumps)) + nqubits = 5 + depth = 5 + circuit = rand_circuit(nqubits, depth, rng=rng) + pattern = circuit.transpile().pattern + pattern.remove_input_nodes() + pattern.perform_pauli_measurements() + pattern.minimize_space() + _qasm3 = pattern_to_qasm3(pattern) diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py index 3777c891f..6f77a8729 100644 --- a/tests/test_qasm3_exporter_to_qiskit.py +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -90,7 +90,9 @@ def test_to_qasm3_entanglement() -> None: @pytest.mark.parametrize("clifford", Clifford) -@pytest.mark.parametrize("state", [BasicStates.ZERO, BasicStates.PLUS]) +@pytest.mark.parametrize( + "state", [BasicStates.ZERO, BasicStates.PLUS, pytest.param(BasicStates.MINUS, marks=pytest.mark.xfail)] +) def test_to_qasm3_clifford(clifford: Clifford, state: State) -> None: check_qasm3(Pattern(cmds=[N(0, state), C(0, clifford)])) From 653b5b26b6f6d72369a8dfa744f2e8189ae436f2 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 8 Jan 2026 14:44:45 +0100 Subject: [PATCH 08/12] Test with qiskit-aer on Python <3.14 --- noxfile.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/noxfile.py b/noxfile.py index 9260b97fd..1dca07ffa 100644 --- a/noxfile.py +++ b/noxfile.py @@ -56,6 +56,16 @@ def tests_all(session: Session) -> None: run_pytest(session, doctest_modules=True, mpl=True) +@nox.session(python=["3.10", "3.11", "3.12", "3.13"]) +def tests_qiskit_aer(session: Session) -> None: + """Run the test suite with qiskit-aer, which is not available yet for Python 3.14. + + See https://github.com/Qiskit/qiskit-aer/issues/2378. + """ + session.install(".[dev,extra]", "qiskit-aer") + run_pytest(session, doctest_modules=True, mpl=True) + + @nox.session(python=["3.10", "3.11", "3.12", "3.13", "3.14"]) def tests_symbolic(session: Session) -> None: """Run the test suite of graphix-symbolic.""" From 7494c1dc9afe118f0a0d2b1db183b9c327e9068e Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 8 Jan 2026 14:54:40 +0100 Subject: [PATCH 09/12] Perform type-checking with Python 3.13 --- .github/workflows/typecheck.yml | 4 +++- noxfile.py | 10 ---------- requirements-dev.txt | 2 ++ 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 5a8cb1b05..3f974667b 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -12,7 +12,9 @@ concurrency: cancel-in-progress: true env: - python-version: "3.14" + # We stick to Python 3.13 since qiskit-aer is not available yet for Python 3.14. + # See https://github.com/Qiskit/qiskit-aer/issues/2378. + python-version: "3.13" jobs: mypy-pyright: diff --git a/noxfile.py b/noxfile.py index 1dca07ffa..9260b97fd 100644 --- a/noxfile.py +++ b/noxfile.py @@ -56,16 +56,6 @@ def tests_all(session: Session) -> None: run_pytest(session, doctest_modules=True, mpl=True) -@nox.session(python=["3.10", "3.11", "3.12", "3.13"]) -def tests_qiskit_aer(session: Session) -> None: - """Run the test suite with qiskit-aer, which is not available yet for Python 3.14. - - See https://github.com/Qiskit/qiskit-aer/issues/2378. - """ - session.install(".[dev,extra]", "qiskit-aer") - run_pytest(session, doctest_modules=True, mpl=True) - - @nox.session(python=["3.10", "3.11", "3.12", "3.13", "3.14"]) def tests_symbolic(session: Session) -> None: """Run the test suite of graphix-symbolic.""" diff --git a/requirements-dev.txt b/requirements-dev.txt index 996980688..0501e7fb3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -22,6 +22,8 @@ pytest-mpl # Optional dependencies qiskit>=1.0 +qiskit_qasm3_import +qiskit-aer; python_version < "3.14" openqasm-parser>=3.1.0 graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git From 76d7b5327295b17f3d862c36faecfaf13ee7a01d Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 8 Jan 2026 15:03:39 +0100 Subject: [PATCH 10/12] Update CHANGELOG --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4d1276e..0aa984aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #394: The method `Circuit.transpile_measurements_to_z_axis` returns an equivalent circuit where all measurements are on the Z axis. This can be used to prepare a circuit for export to OpenQASM with `circuit_to_qasm3`. +- #402: Support for Python 3.14. + ### Fixed - #392: `Pattern.remove_input_nodes` is required before the `Pattern.perform_pauli_measurements` method to ensure input nodes are removed and fixed in the |+> state. @@ -49,6 +51,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #389, #391: `Pattern.extract_opengraph` raises an exception if pattern has `N` commands which do not represent a |+> state. +- #404: Fixed pattern export to OpenQASM 3. Compatibility with Qiskit + is ensured with normalization passed `incorporate_pauli_results` and + `single_qubit_domains`. + - #409: Axis labels are shown when visualizing a pattern. Legend is placed outside the plot so that the graph remains visible. ### Changed @@ -59,7 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #352, #394: Circuit measurements are now limited to axes X, Y, and Z. -- #233, #398: The angle convention is now consistent across the +- #233, #399: The angle convention is now consistent across the library: angles are represented as floats and expressed in units of π. In particular, angles that appear in parameters of circuit instructions are now expressed in units of π. From 3a25dc6890d3fd17fb423a13c2795b7756fea5d8 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 8 Jan 2026 18:18:58 +0100 Subject: [PATCH 11/12] Missing file --- .../test_qasm3_exporter_to_graphix_parser.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_qasm3_exporter_to_graphix_parser.py diff --git a/tests/test_qasm3_exporter_to_graphix_parser.py b/tests/test_qasm3_exporter_to_graphix_parser.py new file mode 100644 index 000000000..0a3dc58ed --- /dev/null +++ b/tests/test_qasm3_exporter_to_graphix_parser.py @@ -0,0 +1,69 @@ +"""Test exporter to OpenQASM3 using graphix-qasm-parser to check the round-trip.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from numpy.random import PCG64, Generator + +from graphix import Circuit, instruction +from graphix.fundamentals import ANGLE_PI +from graphix.qasm3_exporter import 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 # type: ignore[import-not-found, unused-ignore] +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) + + +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 + # See https://github.com/TeamGraphix/graphix-qasm-parser/pull/5 + check_round_trip(rand_circuit(nqubits, depth, rng, use_cz=False)) + + +@pytest.mark.parametrize( + "instruction", + [ + instruction.CCX(target=0, controls=(1, 2)), + instruction.RZZ(target=0, control=1, angle=ANGLE_PI / 4), + instruction.CNOT(target=0, control=1), + instruction.SWAP(targets=(0, 1)), + # See https://github.com/TeamGraphix/graphix-qasm-parser/pull/5 + # instruction.CZ(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=ANGLE_PI / 4), + instruction.RY(target=0, angle=ANGLE_PI / 4), + instruction.RZ(target=0, angle=ANGLE_PI / 4), + ], +) +def test_instruction_to_qasm3(instruction: Instruction) -> None: + check_round_trip(Circuit(3, instr=[instruction])) From b8f4416d85de9894c59de7ff0b3dc6b643ad62ce Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 9 Jan 2026 23:52:35 +0100 Subject: [PATCH 12/12] Mateo's comments --- graphix/qasm3_exporter.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 747855d48..0e3102f1d 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -7,8 +7,9 @@ # assert_never added in Python 3.11 from typing_extensions import assert_never +from graphix._version import version from graphix.command import CommandKind -from graphix.fundamentals import Axis, ParameterizedAngle, Plane, angle_to_rad +from graphix.fundamentals import Axis, ParameterizedAngle, Plane from graphix.instruction import Instruction, InstructionKind from graphix.pretty_print import OutputFormat, angle_to_str from graphix.states import BasicStates, State @@ -152,12 +153,11 @@ def pattern_to_qasm3_lines(pattern: Pattern, input_state: dict[int, State] | Sta See :func:`pattern_to_qasm3`. """ - yield "// generated by graphix\n" + 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"// prepare input qubit {node} in |+⟩\n" 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) @@ -201,13 +201,19 @@ def command_to_qasm3_lines(cmd: Command) -> Iterator[str]: if cmd.plane == Plane.XY: yield f"h q{cmd.node};\n" if cmd.angle != 0: - rad_angle = angle_to_rad(cmd.angle) if cmd.plane == Plane.XY: - yield f"rx({-rad_angle}) q{cmd.node};\n" + gate = "rx" + angle = -cmd.angle elif cmd.plane == Plane.XZ: - yield f"ry({-rad_angle}) q{cmd.node};\n" + gate = "ry" + angle = -cmd.angle + elif cmd.plane == Plane.YZ: + gate = "rx" + angle = cmd.angle else: - yield f"rx({rad_angle}) q{cmd.node};\n" + assert_never(cmd.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" @@ -230,8 +236,9 @@ def command_to_qasm3_lines(cmd: Command) -> Iterator[str]: def state_to_qasm3_lines(node: int, state: State) -> Iterator[str]: """Convert initial state into OpenQASM 3.0 statement.""" if state == BasicStates.ZERO: - pass + yield f"// qubit {node} prepared in |0⟩: do nothing\n" elif state == BasicStates.PLUS: + yield f"// qubit {node} prepared in |+⟩\n" yield f"h q{node};\n" else: raise ValueError("QASM3 conversion only supports |0⟩ or |+⟩ initial states.")