diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d645c1..9b77fda 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,9 @@ jobs: - name: Setup requirements run: pip install -r requirements.txt -r requirements-dev.txt + - name: Run pytest + run: pytest + - name: Run ruff-check run: ruff check @@ -34,6 +37,3 @@ jobs: - name: Run mypy run: mypy . - - - name: Run pytest - run: pytest diff --git a/.gitignore b/.gitignore index 542103c..4d5145a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ *~ *.egg-info/ __pycache__/ +.coverage +.vscode/ +.mypy_cache/ \ No newline at end of file diff --git a/README.md b/README.md index 8db388b..c600d92 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,14 @@ universality of the gate set consisting of 饾攳(伪) and 鈭. This package implements that transpilation mehtod in a straightforward and principled way. +The package allows for transpilation of circuits with the following steps: + +1. Convert circuit, defined by gates available in Graphix, to a set of J and 鈭 gates. + +2. Construct an open graph from the J and 鈭 gates. + +3. Find a flow for the open graph and convert it into a pattern. + Compared to the existing transpilation procedure in Graphix, this implementation is more naive but also more transparent: diff --git a/graphix_jcz_transpiler/__init__.py b/graphix_jcz_transpiler/__init__.py index 4d5752a..e773135 100644 --- a/graphix_jcz_transpiler/__init__.py +++ b/graphix_jcz_transpiler/__init__.py @@ -3,6 +3,38 @@ Copyright (C) 2025, QAT team (ENS-PSL, Inria, CNRS). """ -from graphix_jcz_transpiler.jcz_transpiler import transpile_jcz +from graphix_jcz_transpiler.jcz_transpiler import ( + CZ, + InternalInstructionError, + J, + JCZInstructionKind, + circuit_to_open_graph, + decompose_ccx, + decompose_rx, + decompose_ry, + decompose_rz, + decompose_rzz, + decompose_swap, + decompose_y, + j_commands, + transpile_jcz, + transpile_jcz_open_graph, +) -__all__ = ["transpile_jcz"] +__all__ = [ + "CZ", + "InternalInstructionError", + "J", + "JCZInstructionKind", + "circuit_to_open_graph", + "decompose_ccx", + "decompose_rx", + "decompose_ry", + "decompose_rz", + "decompose_rzz", + "decompose_swap", + "decompose_y", + "j_commands", + "transpile_jcz", + "transpile_jcz_open_graph", +] diff --git a/graphix_jcz_transpiler/jcz_transpiler.py b/graphix_jcz_transpiler/jcz_transpiler.py index 09c8ddc..cc39071 100644 --- a/graphix_jcz_transpiler/jcz_transpiler.py +++ b/graphix_jcz_transpiler/jcz_transpiler.py @@ -12,15 +12,19 @@ from math import pi from typing import TYPE_CHECKING, ClassVar, Literal +import networkx as nx from graphix import Pattern, command, instruction +from graphix.fundamentals import Plane from graphix.instruction import InstructionKind +from graphix.measurements import Measurement +from graphix.opengraph import OpenGraph from graphix.transpiler import Circuit, TranspileResult from typing_extensions import TypeAlias, assert_never if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Sequence - from graphix.parameters import ExpressionOrFloat + from graphix.parameter import ExpressionOrFloat class JCZInstructionKind(Enum): @@ -124,15 +128,17 @@ def decompose_rzz(instr: instruction.RZZ) -> list[instruction.CNOT | instruction """ return [ - instruction.CNOT(control=instr.control, target=instr.target), + instruction.CNOT(target=instr.target, control=instr.control), instruction.RZ(instr.target, instr.angle), - instruction.CNOT(control=instr.control, target=instr.target), + instruction.CNOT(target=instr.target, control=instr.control), ] def decompose_cnot(instr: instruction.CNOT) -> list[instruction.H | CZ]: """Return a decomposition of the CNOT gate as H路鈭路H. + Vincent Danos, Elham Kashefi, Prakash Panangaden, The Measurement Calculus, 2007. + Args: ---- instr: the CNOT instruction to decompose. @@ -152,6 +158,11 @@ def decompose_cnot(instr: instruction.CNOT) -> list[instruction.H | CZ]: def decompose_swap(instr: instruction.SWAP) -> list[instruction.CNOT]: """Return a decomposition of the SWAP gate as CNOT(0, 1)路CNOT(1, 0)路CNOT(0, 1). + Michael A. Nielsen and Isaac L. Chuang, + Quantum Computation and Quantum Information, + Cambridge University Press, 2000 + (p. 23 in the 10th Anniversary Edition). + Args: ---- instr: the SWAP instruction to decompose. @@ -168,7 +179,7 @@ def decompose_swap(instr: instruction.SWAP) -> list[instruction.CNOT]: ] -def decompose_y(instr: instruction.Y) -> Iterable[instruction.X | instruction.Z]: +def decompose_y(instr: instruction.Y) -> list[instruction.X | instruction.Z]: """Return a decomposition of the Y gate as X路Z. Args: @@ -180,7 +191,7 @@ def decompose_y(instr: instruction.Y) -> Iterable[instruction.X | instruction.Z] the decomposition. """ - return reversed([instruction.X(instr.target), instruction.Z(instr.target)]) + return list(reversed([instruction.X(instr.target), instruction.Z(instr.target)])) def decompose_rx(instr: instruction.RX) -> list[J]: @@ -220,7 +231,7 @@ def decompose_ry(instr: instruction.RY) -> list[J]: return [J(target=instr.target, angle=angle) for angle in reversed((0, pi / 2, instr.angle, -pi / 2))] -def decompose_rz(instr: instruction.RZ) -> Iterable[J]: +def decompose_rz(instr: instruction.RZ) -> list[J]: """Return a J decomposition of the RZ gate. The Rz(伪) gate is decomposed into H路J(伪) (that is to say, J(0)路J(伪)). @@ -238,7 +249,7 @@ def decompose_rz(instr: instruction.RZ) -> Iterable[J]: return [J(target=instr.target, angle=angle) for angle in reversed((0, instr.angle))] -def instruction_to_jcz(instr: JCZInstruction) -> Iterable[J | CZ]: +def instruction_to_jcz(instr: JCZInstruction) -> Sequence[J | CZ]: """Return a J-鈭 decomposition of the instruction. Args: @@ -282,7 +293,7 @@ def instruction_to_jcz(instr: JCZInstruction) -> Iterable[J | CZ]: assert_never(instr.kind) -def instruction_list_to_jcz(instrs: Iterable[JCZInstruction]) -> list[J | CZ]: +def instruction_list_to_jcz(instrs: Sequence[JCZInstruction]) -> list[J | CZ]: """Return a J-鈭 decomposition of the sequence of instructions. Args: @@ -297,7 +308,7 @@ def instruction_list_to_jcz(instrs: Iterable[JCZInstruction]) -> list[J | CZ]: return [jcz_instr for instr in instrs for jcz_instr in instruction_to_jcz(instr)] -class IllformedPatternError(Exception): +class IllformedCircuitError(Exception): """Raised if the circuit is ill-formed.""" def __init__(self) -> None: @@ -305,6 +316,14 @@ def __init__(self) -> None: super().__init__("Ill-formed pattern") +class CircuitWithMeasurementError(Exception): + """Raised if the circuit contains measurements.""" + + def __init__(self) -> None: + """Build the exception.""" + super().__init__("Circuits containing measurements are not supported by the transpiler.") + + class InternalInstructionError(Exception): """Raised if the circuit contains internal _XC or _ZC instructions.""" @@ -313,6 +332,29 @@ def __init__(self, instr: instruction.Instruction) -> None: super().__init__(f"Internal instruction: {instr}") +def j_commands(current_node: int, next_node: int, angle: ExpressionOrFloat) -> list[command.Command]: + """Return the MBQC pattern commands for a J gate. + + Args: + ---- + current_node: the current node. + next_node: the next node. + angle: the angle of the J gate. + domain: the domain the X correction is based on. + + Returns: + ------- + the MBQC pattern commands for a J gate as a list + + """ + return [ + command.N(node=next_node), + command.E(nodes=(current_node, next_node)), + command.M(node=current_node, angle=(angle / pi) + 0.0), # Avoids -0.0 + command.X(node=next_node, domain={current_node}), + ] + + def transpile_jcz(circuit: Circuit) -> TranspileResult: """Transpile a circuit via a J-鈭 decomposition. @@ -326,8 +368,8 @@ def transpile_jcz(circuit: Circuit) -> TranspileResult: Raises: ------ - IllformedPatternError: if the pattern is ill-formed. InternalInstructionError: if the circuit contains internal _XC or _ZC instructions. + IllformedCircuitError: if the circuit has underdefined instructions. """ indices: list[int | None] = list(range(circuit.width)) @@ -347,26 +389,91 @@ def transpile_jcz(circuit: Circuit) -> TranspileResult: if instr_jcz.kind == JCZInstructionKind.J: target = indices[instr_jcz.target] if target is None: - raise IllformedPatternError + raise IllformedCircuitError ancilla = n_nodes n_nodes += 1 - pattern.extend( - [ - command.N(node=ancilla), - command.E(nodes=(target, ancilla)), - command.M(node=target, angle=-instr_jcz.angle / pi), - command.X(node=ancilla, domain={target}), - ], - ) + pattern.extend(j_commands(target, ancilla, -instr_jcz.angle)) indices[instr_jcz.target] = ancilla continue if instr_jcz.kind == JCZInstructionKind.CZ: t0, t1 = instr_jcz.targets i0, i1 = indices[t0], indices[t1] if i0 is None or i1 is None: - raise IllformedPatternError + raise IllformedCircuitError pattern.extend([command.E(nodes=(i0, i1))]) continue assert_never(instr_jcz.kind) pattern.reorder_output_nodes([i for i in indices if i is not None]) return TranspileResult(pattern, tuple(classical_outputs)) + + +def circuit_to_open_graph(circuit: Circuit) -> OpenGraph[Measurement]: + """Transpile a circuit via a J-鈭-like decomposition to an open graph. + + Args: + ---- + circuit: the circuit to transpile. + + Returns: + ------- + the result of the transpilation: an open graph. + + Raises: + ------ + IllformedCircuitError: if the pattern is ill-formed (operation on already measured node) + InternalInstructionError: if the circuit contains internal _XC or _ZC instructions. + CircuitWithMeasurementError: if the circuit contains measurements. + + """ + indices: list[int | None] = list(range(circuit.width)) + n_nodes = circuit.width + measurements: dict[int, Measurement] = {} + inputs = list(range(n_nodes)) + graph: nx.Graph[int] = nx.Graph() # type: ignore[name-defined,attr-defined] + graph.add_nodes_from(inputs) + for instr in circuit.instruction: + if instr.kind == InstructionKind.M: + raise CircuitWithMeasurementError + # Use == for mypy + if instr.kind == InstructionKind._XC or instr.kind == InstructionKind._ZC: # noqa: PLR1714, SLF001 + raise InternalInstructionError(instr) + for instr_jcz in instruction_to_jcz(instr): + if instr_jcz.kind == JCZInstructionKind.J: + target = indices[instr_jcz.target] + if target is None: + raise IllformedCircuitError + ancilla = n_nodes + n_nodes += 1 + graph.add_node(ancilla) + graph.add_edge(target, ancilla) + measurements[target] = Measurement(-instr_jcz.angle / pi, plane=Plane.XY) + indices[instr_jcz.target] = ancilla + continue + if instr_jcz.kind == JCZInstructionKind.CZ: + t0, t1 = instr_jcz.targets + i0, i1 = indices[t0], indices[t1] + if i0 is None or i1 is None: + raise IllformedCircuitError + graph.add_edge(i0, i1) + continue + assert_never(instr_jcz.kind) + outputs = [i for i in indices if i is not None] + return OpenGraph(graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=measurements) + + +def transpile_jcz_open_graph(circuit: Circuit) -> TranspileResult: + """Transpile a circuit via a J-鈭-like decomposition to a pattern. + + Currently fails due to overuse of memory in conversion from open graph to pattern, assumed in the causal flow step. + + Args: + ---- + circuit: the circuit to transpile. + + Returns: + ------- + the result of the transpilation: a pattern. + + """ + og = circuit_to_open_graph(circuit) + return TranspileResult(og.to_pattern(), tuple(og.measurements.keys())) diff --git a/pyproject.toml b/pyproject.toml index 787287c..23364d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,8 @@ name = "graphix_jcz_transpiler" version = "1" -[tool.setuptools] -packages = ["graphix_jcz_transpiler"] +[tool.setuptools.packages.find] +include = ["graphix_jcz_transpiler"] [tool.ruff] line-length = 120 diff --git a/tests/test_jcz_transpiler.py b/tests/test_jcz_transpiler.py index 124d13e..0ee8d64 100644 --- a/tests/test_jcz_transpiler.py +++ b/tests/test_jcz_transpiler.py @@ -13,14 +13,13 @@ from graphix import instruction from graphix.fundamentals import Plane from graphix.gflow import find_flow -from graphix.opengraph import OpenGraph from graphix.random_objects import rand_circuit from graphix.sim.statevec import Statevec from graphix.simulator import DefaultMeasureMethod from graphix.transpiler import Circuit from numpy.random import PCG64, Generator -from graphix_jcz_transpiler import transpile_jcz +from graphix_jcz_transpiler import circuit_to_open_graph, transpile_jcz, transpile_jcz_open_graph logger = logging.getLogger(__name__) @@ -37,7 +36,7 @@ Circuit(2, instr=[instruction.CNOT(0, 1)]), Circuit(3, instr=[instruction.CCX(0, (1, 2))]), Circuit(2, instr=[instruction.RZZ(0, 1, pi / 4)]), -] + ] @pytest.mark.parametrize("circuit", TEST_BASIC_CIRCUITS) @@ -53,9 +52,9 @@ def test_circuit_simulation(circuit: Circuit, fx_rng: Generator) -> None: def test_circuit_flow(circuit: Circuit) -> None: """Test transpiled circuits have flow.""" pattern = transpile_jcz(circuit).pattern - og = OpenGraph.from_pattern(pattern) + og = pattern.extract_opengraph() f, _layers = find_flow( - og.inside, set(og.inputs), set(og.outputs), {node: meas.plane for node, meas in og.measurements.items()} + og.graph, set(og.input_nodes), set(og.output_nodes), {node: meas.plane for node, meas in og.measurements.items()} ) assert f is not None @@ -75,7 +74,12 @@ def test_random_circuit(fx_bg: PCG64, jumps: int, check: str) -> None: def test_measure(fx_rng: Generator) -> None: - """Test circuit transpilation with measurement.""" + """Test circuit transpilation with measurement. + + Circuits transpiled in JCZ give patterns with causal flow. + This test checks manual measurements work for the `transpile_jcz` function. + It also checks that measurements have uniform outcomes. + """ circuit = Circuit(2) circuit.h(1) circuit.cnot(0, 1) @@ -85,7 +89,7 @@ def test_measure(fx_rng: Generator) -> None: transpiled.pattern.minimize_space() def simulate_and_measure() -> int: - measure_method = DefaultMeasureMethod(results=transpiled.pattern.results) # type: ignore[no-untyped-call] + measure_method = DefaultMeasureMethod(results=transpiled.pattern.results) state = transpiled.pattern.simulate_pattern(rng=fx_rng, measure_method=measure_method) measured = measure_method.get_measure_result(transpiled.classical_outputs[0]) assert isinstance(state, Statevec) @@ -94,3 +98,62 @@ def simulate_and_measure() -> int: nb_shots = 10000 count = sum(1 for _ in range(nb_shots) if simulate_and_measure()) assert abs(count - nb_shots / 2) < nb_shots / 20 + +@pytest.mark.parametrize("circuit", TEST_BASIC_CIRCUITS) +def test_circuit_simulation_og(circuit: Circuit, fx_rng: Generator) -> None: + """Test circuit transpilation comparing state vector back-end.""" + pattern = transpile_jcz_open_graph(circuit).pattern + pattern.minimize_space() + state = circuit.simulate_statevector().statevec + state_mbqc = pattern.simulate_pattern(rng=fx_rng) + assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) + + +@pytest.mark.parametrize("circuit", TEST_BASIC_CIRCUITS) +def test_circuit_flow_og(circuit: Circuit) -> None: + """Test transpiled circuits have flow.""" + pattern = transpile_jcz_open_graph(circuit).pattern + og = pattern.extract_opengraph() + f, _layers = find_flow( + og.graph, set(og.input_nodes), set(og.output_nodes), {node: meas.plane for node, meas in og.measurements.items()} + ) + assert f is not None + + +@pytest.mark.parametrize("circuit", TEST_BASIC_CIRCUITS) +def test_og_generation(circuit: Circuit) -> None: + """Test that open graphs are extracted in the expected way.""" + pattern = transpile_jcz(circuit).pattern + og = pattern.extract_opengraph() + og_jcz = circuit_to_open_graph(circuit) + assert og.measurements == og_jcz.measurements + assert og.input_nodes == og_jcz.input_nodes + assert og.output_nodes == og_jcz.output_nodes + + +@pytest.mark.parametrize("circuit", TEST_BASIC_CIRCUITS) +def test_circuit_simulation_compare(circuit: Circuit, fx_rng: Generator) -> None: + """Test circuit transpilation comparing state vector back-end.""" + pattern = transpile_jcz(circuit).pattern + pattern_og = transpile_jcz_open_graph(circuit).pattern + pattern.perform_pauli_measurements() + pattern.minimize_space() + pattern_og.perform_pauli_measurements() + pattern_og.minimize_space() + state_mbqc = pattern.simulate_pattern(rng=fx_rng) + state_mbqc_og = pattern_og.simulate_pattern(rng=fx_rng) + assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state_mbqc_og.flatten())) == pytest.approx(1) + + +@pytest.mark.parametrize("jumps", range(1, 11)) +@pytest.mark.parametrize("check", ["simulation", "flow"]) +def test_random_circuit_og(fx_bg: PCG64, jumps: int, check: str) -> None: + """Test random circuit transpilation.""" + rng = Generator(fx_bg.jumped(jumps)) + nqubits = 4 + depth = 6 + circuit = rand_circuit(nqubits, depth, rng, use_ccx=True) + if check == "simulation": + test_circuit_simulation_og(circuit, rng) + elif check == "flow": + test_circuit_flow_og(circuit)