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/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 π. diff --git a/docs/source/optimization.rst b/docs/source/optimization.rst new file mode 100644 index 000000000..bee7a4dea --- /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 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 893debe30..be7fe96a2 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -30,7 +30,7 @@ from graphix.states import BasicStates 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 @@ -41,14 +41,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. @@ -61,6 +62,7 @@ def standardize(pattern: Pattern) -> Pattern: ------- standardized : Pattern The standardized pattern, if it exists. + """ return StandardizedPattern.from_pattern(pattern).to_pattern() @@ -97,7 +99,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 @@ -685,3 +687,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 976f26fd9..accb6121e 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 @@ -41,6 +42,7 @@ from graphix.flow.core import CausalFlow, GFlow from graphix.parameter import ExpressionOrSupportsFloat, Parameter from graphix.sim import Backend, BackendState, Data + from graphix.states import State _StateT_co = TypeVar("_StateT_co", bound="BackendState", covariant=True) @@ -1498,27 +1500,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: """ @@ -1814,86 +1810,6 @@ def pauli_nodes(pattern: optimization.StandardizedPattern) -> tuple[list[tuple[c 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 4d59650cc..0e3102f1d 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -7,14 +7,18 @@ # assert_never added in Python 3.11 from typing_extensions import assert_never -from graphix.fundamentals import Axis, ParameterizedAngle +from graphix._version import version +from graphix.command import CommandKind +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 if TYPE_CHECKING: from collections.abc import Iterable, Iterator - from graphix import Circuit + from graphix import Circuit, Pattern + from graphix.command import Command def circuit_to_qasm3(circuit: Circuit) -> str: @@ -118,3 +122,146 @@ 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 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};\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: + gate = "rx" + angle = -cmd.angle + elif cmd.plane == Plane.XZ: + gate = "ry" + angle = -cmd.angle + elif cmd.plane == Plane.YZ: + gate = "rx" + angle = cmd.angle + else: + 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" + + 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: + 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.") + + +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 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 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_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])) diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py new file mode 100644 index 000000000..6f77a8729 --- /dev/null +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -0,0 +1,131 @@ +"""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 qiskit, + # 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, 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)])) + + +@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.remove_input_nodes() + 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 949cfa9f4..f1566ff16 100644 --- a/tests/test_statevec.py +++ b/tests/test_statevec.py @@ -7,7 +7,7 @@ import pytest from graphix.fundamentals import ANGLE_PI, 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