From 5bc244b4040e05abc7af800b2985f70fe1edc5b5 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 28 Nov 2025 15:37:31 +0100 Subject: [PATCH 01/21] added CZ gate to circuits and downstream components --- graphix/instruction.py | 14 +++++++---- graphix/qasm3_exporter.py | 2 ++ graphix/transpiler.py | 44 +++++++++++++++++++++++++++++++++- tests/test_qasm3_exporter.py | 1 + tests/test_statevec_backend.py | 8 +++---- tests/test_tnsim.py | 12 ++++++++++ tests/test_transpiler.py | 10 ++++++++ 7 files changed, 82 insertions(+), 9 deletions(-) diff --git a/graphix/instruction.py b/graphix/instruction.py index 81c149957..47b10ee58 100644 --- a/graphix/instruction.py +++ b/graphix/instruction.py @@ -40,6 +40,7 @@ class InstructionKind(Enum): RZZ = enum.auto() CNOT = enum.auto() SWAP = enum.auto() + CZ = enum.auto() H = enum.auto() S = enum.auto() X = enum.auto() @@ -80,9 +81,6 @@ class RZZ(_KindChecker, DataclassReprMixin): target: int control: int angle: ExpressionOrFloat = field(metadata={"repr": repr_angle}) - # FIXME: Remove `| None` from `meas_index` - # - `None` makes codes messy/type-unsafe - meas_index: int | None = None kind: ClassVar[Literal[InstructionKind.RZZ]] = field(default=InstructionKind.RZZ, init=False) @@ -95,6 +93,14 @@ class CNOT(_KindChecker, DataclassReprMixin): kind: ClassVar[Literal[InstructionKind.CNOT]] = field(default=InstructionKind.CNOT, init=False) +@dataclass(repr=False) +class CZ(_KindChecker, DataclassReprMixin): + """CZ circuit instruction.""" + + targets: tuple[int, int] + kind: ClassVar[Literal[InstructionKind.CZ]] = field(default=InstructionKind.CZ, init=False) + + @dataclass(repr=False) class SWAP(_KindChecker, DataclassReprMixin): """SWAP circuit instruction.""" @@ -209,4 +215,4 @@ class _ZC(_KindChecker): kind: ClassVar[Literal[InstructionKind._ZC]] = field(default=InstructionKind._ZC, init=False) -Instruction = CCX | RZZ | CNOT | SWAP | H | S | X | Y | Z | I | M | RX | RY | RZ | _XC | _ZC +Instruction = CCX | RZZ | CNOT | SWAP | CZ | H | S | X | Y | Z | I | M | RX | RY | RZ | _XC | _ZC diff --git a/graphix/qasm3_exporter.py b/graphix/qasm3_exporter.py index 60e9dec5f..372fb71b0 100644 --- a/graphix/qasm3_exporter.py +++ b/graphix/qasm3_exporter.py @@ -100,6 +100,8 @@ def instruction_to_qasm3(instruction: Instruction) -> str: return qasm3_gate_call("cx", [qasm3_qubit(instruction.control), qasm3_qubit(instruction.target)]) if instruction.kind == InstructionKind.SWAP: return qasm3_gate_call("swap", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) + if instruction.kind == InstructionKind.CZ: + return qasm3_gate_call("cz", [qasm3_qubit(instruction.targets[i]) for i in (0, 1)]) if instruction.kind == InstructionKind.RZZ: angle = angle_to_qasm3(instruction.angle) return qasm3_gate_call( diff --git a/graphix/transpiler.py b/graphix/transpiler.py index 70f8bc2c9..8b5d2fceb 100644 --- a/graphix/transpiler.py +++ b/graphix/transpiler.py @@ -109,6 +109,8 @@ def add(self, instr: Instruction) -> None: self.cnot(instr.control, instr.target) elif instr.kind == InstructionKind.SWAP: self.swap(instr.targets[0], instr.targets[1]) + elif instr.kind == InstructionKind.CZ: + self.cz(instr.targets[0], instr.targets[1]) elif instr.kind == InstructionKind.H: self.h(instr.target) elif instr.kind == InstructionKind.S: @@ -174,6 +176,21 @@ def swap(self, qubit1: int, qubit2: int) -> None: assert qubit1 != qubit2 self.instruction.append(instruction.SWAP(targets=(qubit1, qubit2))) + def cz(self, qubit1: int, qubit2: int) -> None: + """Apply a CNOT gate. + + Parameters + ---------- + qubit1 : int + control qubit + qubit2 : int + target qubit + """ + assert qubit1 in self.active_qubits + assert qubit2 in self.active_qubits + assert qubit1 != qubit2 + self.instruction.append(instruction.CZ(targets=(qubit1, qubit2))) + def h(self, qubit: int) -> None: """Apply a Hadamard gate. @@ -351,7 +368,12 @@ def transpile(self) -> TranspileResult: pattern = Pattern(input_nodes=list(range(self.width))) classical_outputs = [] for instr in _transpile_rzz(self.instruction): - if instr.kind == instruction.InstructionKind.CNOT: + if instr.kind == instruction.InstructionKind.CZ: + target0 = _check_target(out, instr.targets[0]) + target1 = _check_target(out, instr.targets[1]) + seq = self._cz_command(target0, target1) + pattern.extend(seq) + elif instr.kind == instruction.InstructionKind.CNOT: ancilla = [n_node, n_node + 1] control = _check_target(out, instr.control) target = _check_target(out, instr.target) @@ -485,6 +507,24 @@ def _cnot_command( ) return control_node, ancilla[1], seq + @classmethod + def _cz_command(cls, target_1: int, target_2: int) -> list[Command]: + """MBQC commands for CZ gate. + + Parameters + ---------- + target_1 : int + target node on graph + target_2 : int + other target node on graph + + Returns + ------- + commands : list + list of MBQC commands + """ + return [E(nodes=(target_1, target_2))] + @classmethod def _m_command(cls, input_node: int, plane: Plane, angle: Angle) -> list[Command]: """MBQC commands for measuring qubit. @@ -913,6 +953,8 @@ def simulate_statevector( state.cnot((instr.control, instr.target)) elif instr.kind == instruction.InstructionKind.SWAP: state.swap(instr.targets) + elif instr.kind == instruction.InstructionKind.CZ: + state.entangle(instr.targets) elif instr.kind == instruction.InstructionKind.I: pass elif instr.kind == instruction.InstructionKind.S: diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index 22202e388..92df7ac26 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -52,6 +52,7 @@ def test_circuit_to_qasm3(fx_bg: PCG64, jumps: int) -> None: instruction.RZZ(target=0, control=1, angle=pi / 4), instruction.CNOT(target=0, control=1), instruction.SWAP(targets=(0, 1)), + instruction.CZ(targets=(0, 1)), instruction.H(target=0), instruction.S(target=0), instruction.X(target=0), diff --git a/tests/test_statevec_backend.py b/tests/test_statevec_backend.py index 9d14f372b..a4906bd60 100644 --- a/tests/test_statevec_backend.py +++ b/tests/test_statevec_backend.py @@ -93,7 +93,7 @@ def test_clifford(self) -> None: backend.apply_clifford(node=0, clifford=clifford) np.testing.assert_allclose(vec.psi, backend.state.psi) - def test_deterministic_measure_one(self, fx_rng: Generator): + def test_deterministic_measure_one(self, fx_rng: Generator) -> None: # plus state & zero state (default), but with tossed coins for _ in range(10): backend = StatevectorBackend() @@ -112,7 +112,7 @@ def test_deterministic_measure_one(self, fx_rng: Generator): result = backend.measure(node=node_to_measure, measurement=measurement) assert result == expected_result - def test_deterministic_measure(self): + def test_deterministic_measure(self) -> None: """Entangle |+> state with N |0> states, the (XY,0) measurement yields the outcome 0 with probability 1.""" for _ in range(10): # plus state (default) @@ -130,7 +130,7 @@ def test_deterministic_measure(self): assert result == 0 assert list(backend.node_index) == list(range(1, n_neighbors + 1)) - def test_deterministic_measure_many(self): + def test_deterministic_measure_many(self) -> None: """Entangle |+> state with N |0> states, the (XY,0) measurement yields the outcome 0 with probability 1.""" for _ in range(10): # plus state (default) @@ -161,7 +161,7 @@ def test_deterministic_measure_many(self): assert list(backend.node_index) == list(range(n_traps, n_neighbors + n_traps + n_whatever)) - def test_deterministic_measure_with_coin(self, fx_rng: Generator): + def test_deterministic_measure_with_coin(self, fx_rng: Generator) -> None: """Entangle |+> state with N |0> states, the (XY,0) measurement yields the outcome 0 with probability 1. We add coin toss to that. diff --git a/tests/test_tnsim.py b/tests/test_tnsim.py index cc669a590..dd5ac3906 100644 --- a/tests/test_tnsim.py +++ b/tests/test_tnsim.py @@ -288,6 +288,18 @@ def test_i(self, fx_rng: Generator) -> None: value2 = tn_mbqc.expectation_value(random_op1, [0]) assert value1 == pytest.approx(value2) + def test_cz(self, fx_rng: Generator) -> None: + circuit = Circuit(2) + circuit.cz(0, 1) + pattern = circuit.transpile().pattern + pattern.standardize() + state = circuit.simulate_statevector().statevec + tn_mbqc = pattern.simulate_pattern(backend="tensornetwork", rng=fx_rng) + random_op2 = random_op(2, np.complex128, fx_rng) + value1 = state.expectation_value(random_op2, [0, 1]) + value2 = tn_mbqc.expectation_value(random_op2, [0, 1]) + assert value1 == pytest.approx(value2) + def test_cnot(self, fx_rng: Generator) -> None: circuit = Circuit(2) circuit.cnot(0, 1) diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py index 82a16aeeb..c0b88ca2b 100644 --- a/tests/test_transpiler.py +++ b/tests/test_transpiler.py @@ -16,6 +16,14 @@ class TestTranspilerUnitGates: + def test_cz(self, fx_rng: Generator) -> None: + circuit = Circuit(2) + circuit.cz(0, 1) + pattern = circuit.transpile().pattern + 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) + def test_cnot(self, fx_rng: Generator) -> None: circuit = Circuit(2) circuit.cnot(0, 1) @@ -141,6 +149,7 @@ def test_add_extend(self) -> None: circuit = Circuit(3) circuit.ccx(0, 1, 2) circuit.rzz(0, 1, 2) + circuit.cz(0, 1) circuit.cnot(0, 1) circuit.swap(0, 1) circuit.h(0) @@ -163,6 +172,7 @@ def test_add_extend(self) -> None: instruction.RZZ(0, 1, np.pi / 4), instruction.CNOT(0, 1), instruction.SWAP((0, 1)), + instruction.CZ((0, 1)), instruction.H(0), instruction.S(0), instruction.X(0), From 34bcc267446f5016597c0684f464de5e1fb073e1 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 28 Nov 2025 16:04:40 +0100 Subject: [PATCH 02/21] added correct requirement for CI test --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 930587d20..b154736c9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,4 +23,4 @@ pytest-mpl qiskit>=1.0 qiskit-aer -graphix-qasm-parser +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@add_cz From 39c842455ceb3ab60a9308837f986ecae33d6324 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 28 Nov 2025 16:36:21 +0100 Subject: [PATCH 03/21] fixing CI --- .github/workflows/cov.yml | 3 +++ pyproject.toml | 1 + requirements-dev.txt | 2 +- requirements-no-deps.txt | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 requirements-no-deps.txt diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml index 855e74250..0f5cf1bab 100644 --- a/.github/workflows/cov.yml +++ b/.github/workflows/cov.yml @@ -30,6 +30,9 @@ jobs: - name: Install graphix with dev deps. run: pip install .[dev] + - name: Install graphix-openqasm-parser without deps. + run: pip install --no-deps .[no-deps] + - name: Run pytest run: pytest --cov=./graphix --cov-report=xml --cov-report=term diff --git a/pyproject.toml b/pyproject.toml index 8ce11e036..b2925ea9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = { file = ["requirements.txt"] } dev = { file = ["requirements-dev.txt"] } extra = { file = ["requirements-extra.txt"] } doc = { file = ["requirements-doc.txt"] } +no-deps = { file = ["requirements-no-deps.txt"] } [tool.setuptools.packages.find] include = ["graphix", "stubs"] diff --git a/requirements-dev.txt b/requirements-dev.txt index b154736c9..0485c7268 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,4 +23,4 @@ pytest-mpl qiskit>=1.0 qiskit-aer -graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@add_cz +openqasm-parser>=3.1.0 \ No newline at end of file diff --git a/requirements-no-deps.txt b/requirements-no-deps.txt new file mode 100644 index 000000000..e4b696a0e --- /dev/null +++ b/requirements-no-deps.txt @@ -0,0 +1 @@ +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@add_cz From 6d0d1fdaaacc731a14b1bf48e598538e0453bbee Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 28 Nov 2025 16:59:03 +0100 Subject: [PATCH 04/21] fixed RX,Y,Z and mypy checks --- .github/workflows/typecheck.yml | 1 + graphix/instruction.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 76bb98914..e2540f903 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -31,6 +31,7 @@ jobs: - run: | python -m pip install --upgrade pip pip install -e .[dev,extra] + pip install -e --no-deps .[no-deps] - run: mypy diff --git a/graphix/instruction.py b/graphix/instruction.py index 47b10ee58..f84ae96a6 100644 --- a/graphix/instruction.py +++ b/graphix/instruction.py @@ -173,7 +173,6 @@ class RX(_KindChecker, DataclassReprMixin): target: int angle: ExpressionOrFloat = field(metadata={"repr": repr_angle}) - meas_index: int | None = None kind: ClassVar[Literal[InstructionKind.RX]] = field(default=InstructionKind.RX, init=False) @@ -183,7 +182,6 @@ class RY(_KindChecker, DataclassReprMixin): target: int angle: ExpressionOrFloat = field(metadata={"repr": repr_angle}) - meas_index: int | None = None kind: ClassVar[Literal[InstructionKind.RY]] = field(default=InstructionKind.RY, init=False) @@ -193,7 +191,6 @@ class RZ(_KindChecker, DataclassReprMixin): target: int angle: ExpressionOrFloat = field(metadata={"repr": repr_angle}) - meas_index: int | None = None kind: ClassVar[Literal[InstructionKind.RZ]] = field(default=InstructionKind.RZ, init=False) From 65921a88d63e36291cb6fb005f4e7457ec087566 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 28 Nov 2025 17:04:30 +0100 Subject: [PATCH 05/21] fixed RX,Y,Z and mypy checks --- .github/workflows/typecheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index e2540f903..b97b4b510 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -31,7 +31,7 @@ jobs: - run: | python -m pip install --upgrade pip pip install -e .[dev,extra] - pip install -e --no-deps .[no-deps] + pip install --no-deps -e .[no-deps] - run: mypy From 85500fb49baaddc3e9dc4cf22196263db66fe324 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 28 Nov 2025 17:42:14 +0100 Subject: [PATCH 06/21] fixed mypy checks --- tests/test_qasm3_exporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index 92df7ac26..49bf07860 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -17,7 +17,7 @@ from graphix.instruction import Instruction try: - from graphix_qasm_parser import OpenQASMParser + 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") From 835fc72019e8c66e543f30ca9ab13b67a461aafc Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 5 Dec 2025 11:38:11 +0100 Subject: [PATCH 07/21] Added rng to tests, and added cz to rand_circuit function in --- graphix/random_objects.py | 2 ++ tests/test_transpiler.py | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/graphix/random_objects.py b/graphix/random_objects.py index 5249c8327..fdb643b03 100644 --- a/graphix/random_objects.py +++ b/graphix/random_objects.py @@ -411,6 +411,8 @@ def rand_circuit( for _ in range(depth): for j, k in _genpair(nqubits, 2, rng): circuit.cnot(j, k) + for j, k in _genpair(nqubits, 2, rng): + circuit.cz(j, k) if use_rzz: for j, k in _genpair(nqubits, 2, rng): circuit.rzz(j, k, np.pi / 4) diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py index c0b88ca2b..a5767c8e7 100644 --- a/tests/test_transpiler.py +++ b/tests/test_transpiler.py @@ -20,7 +20,7 @@ def test_cz(self, fx_rng: Generator) -> None: circuit = Circuit(2) circuit.cz(0, 1) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -28,7 +28,7 @@ def test_cnot(self, fx_rng: Generator) -> None: circuit = Circuit(2) circuit.cnot(0, 1) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -36,7 +36,7 @@ def test_hadamard(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.h(0) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -44,7 +44,7 @@ def test_s(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.s(0) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -52,7 +52,7 @@ def test_x(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.x(0) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -60,7 +60,7 @@ def test_y(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.y(0) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -68,7 +68,7 @@ def test_z(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.z(0) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -77,7 +77,7 @@ def test_rx(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.rx(0, theta) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -86,7 +86,7 @@ def test_ry(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.ry(0, theta) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -95,7 +95,7 @@ def test_rz(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.rz(0, theta) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -103,7 +103,7 @@ def test_i(self, fx_rng: Generator) -> None: circuit = Circuit(1) circuit.i(0) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -115,7 +115,7 @@ def test_ccx(self, fx_bg: PCG64, jumps: int) -> None: circuit = rand_circuit(nqubits, depth, rng, use_ccx=True) pattern = circuit.transpile().pattern pattern.minimize_space() - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=rng).statevec state_mbqc = pattern.simulate_pattern(rng=rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -125,7 +125,7 @@ def test_transpiled(self, fx_rng: Generator) -> None: pairs = [(i, np.mod(i + 1, nqubits)) for i in range(nqubits)] circuit = rand_gate(nqubits, depth, pairs, fx_rng, use_rzz=True) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -187,6 +187,6 @@ def test_add_extend(self) -> None: def test_instructions(self, fx_rng: Generator, instruction: Instruction) -> None: circuit = Circuit(3, instr=[instruction]) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector().statevec + state = circuit.simulate_statevector(rng=fx_rng).statevec state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) From 3ea4964303003adf32964ff34070ff1ed8a2a468 Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Fri, 28 Nov 2025 14:02:51 +0100 Subject: [PATCH 08/21] Enable type-checking for `random_objects.py` (#369) This commit adds type annotations to `random_objects.py`, along with its corresponding tests, enabling full type-checking with mypy while preserving existing functionality. The exclusion of `device_interface.py` is removed from `pyproject.toml` since this module was removed in #261. `scipy-stubs` is added to `requirements-dev.txt` since `scipy` is used in `random_objects.py`. Revealed bad type annotation for `unitary_group.rvs`: https://github.com/scipy/scipy-stubs/issues/987 **Related issue:** This PR continues the work started in #302, #308, #312 and #347. --------- Co-authored-by: matulni --- graphix/random_objects.py | 63 ++++++++++++++++++---------------- pyproject.toml | 8 ----- requirements-dev.txt | 1 + tests/test_random_utilities.py | 19 ++-------- 4 files changed, 37 insertions(+), 54 deletions(-) diff --git a/graphix/random_objects.py b/graphix/random_objects.py index fdb643b03..bcd04757d 100644 --- a/graphix/random_objects.py +++ b/graphix/random_objects.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import math from typing import TYPE_CHECKING import numpy as np @@ -16,32 +17,35 @@ from graphix.transpiler import Circuit if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Callable, Iterable, Iterator + from typing import Literal, TypeAlias from numpy.random import Generator from graphix.parameter import Parameter + IntLike: TypeAlias = int | np.integer -def rand_herm(sz: int, rng: Generator | None = None) -> npt.NDArray[np.complex128]: + +def rand_herm(sz: IntLike, rng: Generator | None = None) -> npt.NDArray[np.complex128]: """Generate random hermitian matrix of size sz*sz.""" rng = ensure_rng(rng) tmp = rng.random(size=(sz, sz)) + 1j * rng.random(size=(sz, sz)) return tmp + tmp.conj().T -def rand_unit(sz: int, rng: Generator | None = None) -> npt.NDArray[np.complex128]: +def rand_unit(sz: IntLike, rng: Generator | None = None) -> npt.NDArray[np.complex128]: """Generate haar random unitary matrix of size sz*sz.""" rng = ensure_rng(rng) if sz == 1: return np.array([np.exp(1j * rng.random(size=1) * 2 * np.pi)]) - return unitary_group.rvs(sz, random_state=rng) + return unitary_group.rvs(int(sz), random_state=rng) UNITS = np.array([1, 1j]) -def rand_dm(dim: int, rng: Generator | None = None, rank: int | None = None) -> npt.NDArray[np.complex128]: +def rand_dm(dim: IntLike, rng: Generator | None = None, rank: IntLike | None = None) -> npt.NDArray[np.complex128]: """Generate random density matrices (positive semi-definite matrices with unit trace). Returns either a :class:`graphix.sim.density_matrix.DensityMatrix` or a :class:`np.ndarray` depending on the parameter *dm_dtype*. @@ -73,9 +77,20 @@ def rand_dm(dim: int, rng: Generator | None = None, rank: int | None = None) -> return rand_u @ dm @ rand_u.transpose().conj() -def rand_gauss_cpx_mat( - dim: int, rng: Generator | None = None, sig: float = 1 / np.sqrt(2) -) -> npt.NDArray[np.complex128]: +if TYPE_CHECKING: + _SIG: TypeAlias = float | Literal["ginibre"] | None + + +def _make_sig(sig: _SIG, dim: IntLike) -> float: + if sig is None: + # B008 Do not perform function call in argument defaults + return 1 / math.sqrt(2) + if sig == "ginibre": + return 1 / math.sqrt(2 * dim) + return sig + + +def rand_gauss_cpx_mat(dim: IntLike, rng: Generator | None = None, sig: _SIG = None) -> npt.NDArray[np.complex128]: """Return a square array of standard normal complex random variates. Code from QuTiP: https://qutip.org/docs/4.0.2/modules/qutip/random_objects.html @@ -84,21 +99,21 @@ def rand_gauss_cpx_mat( ---------- dim : int Linear dimension of the (square) matrix - sig : float + sig : float | Literal["ginibre"] | None standard deviation of random variates. ``sig = 'ginibre`` draws from the Ginibre ensemble ie sig = 1 / sqrt(2 * dim). """ rng = ensure_rng(rng) - if sig == "ginibre": - sig = 1.0 / np.sqrt(2 * dim) - - return np.sum(rng.normal(loc=0.0, scale=sig, size=((dim,) * 2 + (2,))) * UNITS, axis=-1) + result: npt.NDArray[np.complex128] = np.sum( + rng.normal(loc=0.0, scale=_make_sig(sig, dim), size=((dim,) * 2 + (2,))) * UNITS, axis=-1 + ) + return result def rand_channel_kraus( - dim: int, rng: Generator | None = None, rank: int | None = None, sig: float = 1 / np.sqrt(2) + dim: int, rng: Generator | None = None, rank: int | None = None, sig: _SIG = None ) -> KrausChannel: """Return a random :class:`graphix.sim.channels.KrausChannel` object of given dimension and rank. @@ -122,16 +137,10 @@ def rand_channel_kraus( if rank is None: rank = dim**2 - if sig == "ginibre": - sig = 1.0 / np.sqrt(2 * dim) - - if not isinstance(rank, int): - raise TypeError("The rank of a Kraus expansion must be an integer.") - if not rank >= 1: raise ValueError("The rank of a Kraus expansion must be greater or equal than 1.") - pre_kraus_list = [rand_gauss_cpx_mat(dim=dim, sig=sig) for _ in range(rank)] + pre_kraus_list = [rand_gauss_cpx_mat(dim=dim, sig=_make_sig(sig, dim)) for _ in range(rank)] h_mat = np.sum([m.transpose().conjugate() @ m for m in pre_kraus_list], axis=0) kraus_list = np.array(pre_kraus_list) @ scipy.linalg.inv(scipy.linalg.sqrtm(h_mat)) @@ -144,9 +153,6 @@ def rand_pauli_channel_kraus(dim: int, rng: Generator | None = None, rank: int | """Return a random Kraus channel operator.""" rng = ensure_rng(rng) - if not isinstance(dim, int): - raise TypeError(f"The dimension must be an integer and not {dim}.") - if not dim & (dim - 1) == 0: raise ValueError(f"The dimension must be a power of 2 and not {dim}.") @@ -156,11 +162,8 @@ def rand_pauli_channel_kraus(dim: int, rng: Generator | None = None, rank: int | # default is full rank. if rank is None: rank = dim**2 - else: - if not isinstance(rank, int): - raise TypeError("The rank of a Kraus expansion must be an integer.") - if not rank >= 1: - raise ValueError("The rank of a Kraus expansion must be an integer greater or equal than 1.") + elif not rank >= 1: + raise ValueError("The rank of a Kraus expansion must be an integer greater or equal than 1.") # full probability has to have dim**2 operators. prob_list = np.zeros(dim**2) @@ -397,7 +400,7 @@ def rand_circuit( for rotation in (circuit.rx, circuit.ry, circuit.rz) for parameter in parameters or [] ) - gate_choice = ( + gate_choice: tuple[Callable[[int], None], ...] = ( functools.partial(circuit.ry, angle=np.pi / 4), functools.partial(circuit.rz, angle=-np.pi / 4), functools.partial(circuit.rx, angle=-np.pi / 4), diff --git a/pyproject.toml b/pyproject.toml index b2925ea9a..e377cac6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,16 +143,12 @@ exclude = [ '^examples/qnn\.py$', '^examples/rotation\.py$', '^examples/tn_simulation\.py$', - '^graphix/device_interface\.py$', '^graphix/gflow\.py$', '^graphix/linalg\.py$', - '^graphix/random_objects\.py$', '^tests/test_density_matrix\.py$', '^tests/test_gflow\.py$', '^tests/test_linalg\.py$', '^tests/test_noisy_density_matrix\.py$', - '^tests/test_random_utilities\.py$', - '^tests/test_runner\.py$', '^tests/test_statevec\.py$', '^tests/test_statevec_backend\.py$', '^tests/test_tnsim\.py$', @@ -174,16 +170,12 @@ exclude = [ "examples/qnn.py", "examples/rotation.py", "examples/tn_simulation.py", - "graphix/device_interface.py", "graphix/gflow.py", "graphix/linalg.py", - "graphix/random_objects.py", "tests/test_density_matrix.py", "tests/test_gflow.py", "tests/test_linalg.py", "tests/test_noisy_density_matrix.py", - "tests/test_random_utilities.py", - "tests/test_runner.py", "tests/test_statevec.py", "tests/test_statevec_backend.py", "tests/test_tnsim.py", diff --git a/requirements-dev.txt b/requirements-dev.txt index 0485c7268..82f0f784e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,6 +8,7 @@ ruff==0.14.6 types-networkx==3.5.0.20250712 types-psutil types-setuptools +scipy-stubs # Tests # Keep in sync with CI diff --git a/tests/test_random_utilities.py b/tests/test_random_utilities.py index 539874fe9..4d8c0734e 100644 --- a/tests/test_random_utilities.py +++ b/tests/test_random_utilities.py @@ -23,7 +23,7 @@ def test_rand_herm(self, fx_rng: Generator) -> None: # TODO : work on that. Verify an a random vector and not at the operator level... def test_rand_unit(self, fx_rng: Generator) -> None: - d = fx_rng.integers(2, 20) + d = fx_rng.integers(2, 20).astype(int) tmp = randobj.rand_unit(d, fx_rng) print(type(tmp), tmp.dtype) @@ -52,10 +52,6 @@ def test_random_channel_success(self, fx_rng: Generator) -> None: assert len(channel) == rk def test_random_channel_fail(self, fx_rng: Generator) -> None: - # incorrect rank type - with pytest.raises(TypeError): - _ = randobj.rand_channel_kraus(dim=2**2, rank=3.0, rng=fx_rng) - # null rank with pytest.raises(ValueError): _ = randobj.rand_channel_kraus(dim=2**2, rank=0, rng=fx_rng) @@ -132,7 +128,7 @@ def test_rand_dm_rank(self, fx_rng: Generator) -> None: assert lv.is_psd(dm.rho) assert lv.is_unit_trace(dm.rho) - evals = np.linalg.eigvalsh(dm.rho) + evals = np.linalg.eigvalsh(dm.rho.astype(np.complex128)) evals[np.abs(evals) < 1e-15] = 0 @@ -149,10 +145,7 @@ def test_pauli_tensor_ops(self, fx_rng: Generator) -> None: # or np.apply_along_axis ? assert np.all(dims == (2**nqb, 2**nqb)) - def test_pauli_tensor_ops_fail(self, fx_rng: Generator) -> None: - with pytest.raises(TypeError): - _ = Ops.build_tensor_pauli_ops(fx_rng.integers(2, 6) + 0.5) - + def test_pauli_tensor_ops_fail(self) -> None: with pytest.raises(ValueError): _ = Ops.build_tensor_pauli_ops(0) @@ -168,12 +161,6 @@ def test_random_pauli_channel_success(self, fx_rng: Generator) -> None: def test_random_pauli_channel_fail(self, fx_rng: Generator) -> None: nqb = 3 rk = 2 - with pytest.raises(TypeError): - randobj.rand_pauli_channel_kraus(dim=2**nqb, rank=rk + 0.5, rng=fx_rng) - - with pytest.raises(TypeError): - randobj.rand_pauli_channel_kraus(dim=2**nqb + 0.5, rank=rk, rng=fx_rng) - with pytest.raises(ValueError): randobj.rand_pauli_channel_kraus(dim=2**nqb, rank=-3, rng=fx_rng) From 347e312d04ab5ff751c0c6ecb8621dabab44ef7a Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 2 Dec 2025 15:46:51 +0100 Subject: [PATCH 09/21] Refactor of flow tools - `OpenGraph.isclose` (#374) This commit adapts the existing method `graphix.opengraph.OpenGraph.isclose` to the new API introduced in #358. Additionally, it introduces the new methods `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs, and `graphix.fundamentals.AbstractMeasurement.isclose` which defaults to `==` comparison. --- CHANGELOG.md | 4 ++ graphix/fundamentals.py | 23 +++++++++ graphix/measurements.py | 45 ++++++++++++++---- graphix/opengraph.py | 97 +++++++++++++++++++++++++------------- tests/test_fundamentals.py | 15 ++++++ tests/test_measurements.py | 14 ++++++ tests/test_opengraph.py | 97 +++++++++++++++++++++++++++++++++++++- 7 files changed, 251 insertions(+), 44 deletions(-) create mode 100644 tests/test_measurements.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 49160b5a4..039e3d2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- #347: + - Introduced new method `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs. + - Added new method `isclose` to `graphix.fundamentals.AbstractMeasurement` which defaults to `==` comparison. ### Fixed @@ -15,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.3.3] - 2025-10-23 +- #347: Adapted existing method `graphix.opengraph.OpenGraph.isclose` to the new API introduced in #358. ### Added - #343: Circuit exporter to OpenQASM3: diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index 08e92fdde..4d4f2ca77 100644 --- a/graphix/fundamentals.py +++ b/graphix/fundamentals.py @@ -235,6 +235,29 @@ def to_plane_or_axis(self) -> Plane | Axis: Plane | Axis """ + @abstractmethod + def isclose(self, other: AbstractMeasurement, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool: + """Determine whether this measurement is close to another. + + Subclasses should implement a notion of “closeness” between two measurements, comparing measurement-specific attributes. The default comparison for ``float`` values involves checking equality within given relative or absolute tolerances. + + Parameters + ---------- + other : AbstractMeasurement + The measurement to compare against. + rel_tol : float, optional + Relative tolerance for determining closeness. Relevant for comparing angles in the `Measurement` subclass. Default is ``1e-9``. + abs_tol : float, optional + Absolute tolerance for determining closeness. Relevant for comparing angles in the `Measurement` subclass. Default is ``0.0``. + + Returns + ------- + bool + ``True`` if this measurement is considered close to ``other`` according + to the subclass's comparison rules; ``False`` otherwise. + """ + return self == other + class AbstractPlanarMeasurement(AbstractMeasurement): """Abstract base class for planar measurement objects. diff --git a/graphix/measurements.py b/graphix/measurements.py index 104796fa1..08deca83f 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -11,8 +11,11 @@ TypeAlias, ) +# override introduced in Python 3.12 +from typing_extensions import override + from graphix import utils -from graphix.fundamentals import AbstractPlanarMeasurement, Axis, Plane, Sign +from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane, Sign # Ruff suggests to move this import to a type-checking block, but dataclass requires it here from graphix.parameter import ExpressionOrFloat # noqa: TC001 @@ -44,7 +47,7 @@ class Measurement(AbstractPlanarMeasurement): Attributes ---------- - angle : Expressionor Float + angle : ExpressionOrFloat The angle of the measurement in units of :math:`\pi`. Should be between [0, 2). plane : graphix.fundamentals.Plane The measurement plane. @@ -53,11 +56,31 @@ class Measurement(AbstractPlanarMeasurement): angle: ExpressionOrFloat plane: Plane - def isclose(self, other: Measurement, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool: - """Compare if two measurements have the same plane and their angles are close. + @override + def isclose(self, other: AbstractMeasurement, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool: + """Determine whether two measurements are close in angle and share the same plane. + + This method compares the angle of the current measurement with that of + another measurement, using :func:`math.isclose` when both angles are floats. + The planes must match exactly for the measurements to be considered close. + + Parameters + ---------- + other : AbstractMeasurement + The measurement to compare against. + rel_tol : float, optional + Relative tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``1e-9``. + abs_tol : float, optional + Absolute tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``0.0``. - Example + Returns ------- + bool + ``True`` if both measurements lie in the same plane and their angles + are equal or close within the given tolerances; ``False`` otherwise. + + Examples + -------- >>> from graphix.measurements import Measurement >>> from graphix.fundamentals import Plane >>> Measurement(0.0, Plane.XY).isclose(Measurement(0.0, Plane.XY)) @@ -68,10 +91,14 @@ def isclose(self, other: Measurement, rel_tol: float = 1e-09, abs_tol: float = 0 False """ return ( - math.isclose(self.angle, other.angle, rel_tol=rel_tol, abs_tol=abs_tol) - if isinstance(self.angle, float) and isinstance(other.angle, float) - else self.angle == other.angle - ) and self.plane == other.plane + isinstance(other, Measurement) + and ( + math.isclose(self.angle, other.angle, rel_tol=rel_tol, abs_tol=abs_tol) + if isinstance(self.angle, float) and isinstance(other.angle, float) + else self.angle == other.angle + ) + and self.plane == other.plane + ) def to_plane_or_axis(self) -> Plane | Axis: """Return the measurements's plane or axis. diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 540aab3d2..f6b8b1e25 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -69,45 +69,19 @@ def __post_init__(self) -> None: outputs = set(self.output_nodes) if not set(self.measurements).issubset(all_nodes): - raise ValueError("All measured nodes must be part of the graph's nodes.") + raise OpenGraphError("All measured nodes must be part of the graph's nodes.") if not inputs.issubset(all_nodes): - raise ValueError("All input nodes must be part of the graph's nodes.") + raise OpenGraphError("All input nodes must be part of the graph's nodes.") if not outputs.issubset(all_nodes): - raise ValueError("All output nodes must be part of the graph's nodes.") + raise OpenGraphError("All output nodes must be part of the graph's nodes.") if outputs & self.measurements.keys(): - raise ValueError("Output nodes cannot be measured.") + raise OpenGraphError("Output nodes cannot be measured.") if all_nodes - outputs != self.measurements.keys(): - raise ValueError("All non-output nodes must be measured.") + raise OpenGraphError("All non-output nodes must be measured.") if len(inputs) != len(self.input_nodes): - raise ValueError("Input nodes contain duplicates.") + raise OpenGraphError("Input nodes contain duplicates.") if len(outputs) != len(self.output_nodes): - raise ValueError("Output nodes contain duplicates.") - - # TODO: Up docstrings and generalise to any type - def isclose( - self: OpenGraph[Measurement], other: OpenGraph[Measurement], rel_tol: float = 1e-09, abs_tol: float = 0.0 - ) -> bool: - """Return `True` if two open graphs implement approximately the same unitary operator. - - Ensures the structure of the graphs are the same and all - measurement angles are sufficiently close. - - This doesn't check they are equal up to an isomorphism. - - """ - if not nx.utils.graphs_equal(self.graph, other.graph): - return False - - if self.input_nodes != other.input_nodes or self.output_nodes != other.output_nodes: - return False - - if set(self.measurements.keys()) != set(other.measurements.keys()): - return False - - return all( - m.isclose(other.measurements[node], rel_tol=rel_tol, abs_tol=abs_tol) - for node, m in self.measurements.items() - ) + raise OpenGraphError("Output nodes contain duplicates.") def to_pattern(self: OpenGraph[Measurement]) -> Pattern: """Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists. @@ -140,6 +114,63 @@ def to_pattern(self: OpenGraph[Measurement]) -> Pattern: raise OpenGraphError("The open graph does not have flow. It does not support a deterministic pattern.") + def isclose(self, other: OpenGraph[_M_co], rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool: + """Check if two open graphs are equal within a given tolerance. + + Parameters + ---------- + other : OpenGraph[_M_co] + rel_tol : float + Relative tolerance. Optional, defaults to ``1e-09``. + abs_tol : float + Absolute tolerance. Optional, defaults to ``0.0``. + + Returns + ------- + bool + ``True`` if the two open graphs are approximately equal. + + Notes + ----- + This method verifies the open graphs have: + - Truly equal underlying graphs (not up to an isomorphism). + - Equal input and output nodes. + - Same measurement planes or axes and approximately equal measurement angles if the open graph is of parametric type `Measurement`. + + The static typer does not allow an ``isclose`` comparison of two open graphs with different parametric type. For a structural comparison, see :func:`OpenGraph.is_equal_structurally`. + """ + return self.is_equal_structurally(other) and all( + m.isclose(other.measurements[node], rel_tol=rel_tol, abs_tol=abs_tol) + for node, m in self.measurements.items() + ) + + def is_equal_structurally(self, other: OpenGraph[AbstractMeasurement]) -> bool: + """Compare the underlying structure of two open graphs. + + Parameters + ---------- + other : OpenGraph[AbstractMeasurement] + + Returns + ------- + bool + ``True`` if ``self`` and ``og`` have the same structure. + + Notes + ----- + This method verifies the open graphs have: + - Truly equal underlying graphs (not up to an isomorphism). + - Equal input and output nodes. + It assumes the open graphs are well formed. + + The static typer allows comparing the structure of two open graphs with different parametric type. + """ + return ( + nx.utils.graphs_equal(self.graph, other.graph) + and self.input_nodes == other.input_nodes + and other.output_nodes == other.output_nodes + ) + def neighbors(self, nodes: Collection[int]) -> set[int]: """Return the set containing the neighborhood of a set of nodes in the open graph. diff --git a/tests/test_fundamentals.py b/tests/test_fundamentals.py index a127a83ee..74a81d4d2 100644 --- a/tests/test_fundamentals.py +++ b/tests/test_fundamentals.py @@ -161,3 +161,18 @@ def test_from_axes_ng(self) -> None: Plane.from_axes(Axis.Y, Axis.Y) with pytest.raises(ValueError): Plane.from_axes(Axis.Z, Axis.Z) + + def test_isclose(self) -> None: + for p1, p2 in itertools.combinations(Plane, 2): + assert not p1.isclose(p2) + + for a1, a2 in itertools.combinations(Axis, 2): + assert not a1.isclose(a2) + + for p in Plane: + assert p.isclose(p) + for a in Axis: + assert not p.isclose(a) + + for a in Axis: + assert a.isclose(a) diff --git a/tests/test_measurements.py b/tests/test_measurements.py new file mode 100644 index 000000000..a4386b887 --- /dev/null +++ b/tests/test_measurements.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from graphix.fundamentals import Plane +from graphix.measurements import Measurement + + +class TestMeasurement: + def test_isclose(self) -> None: + m1 = Measurement(0.1, Plane.XY) + m2 = Measurement(0.15, Plane.XY) + + assert not m1.isclose(m2) + assert not m1.isclose(Plane.XY) + assert m1.isclose(m2, abs_tol=0.1) diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index bb0955a52..bf2eb58c4 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -13,7 +13,7 @@ import pytest from graphix.command import E -from graphix.fundamentals import Plane +from graphix.fundamentals import Axis, Plane from graphix.measurements import Measurement from graphix.opengraph import OpenGraph, OpenGraphError from graphix.pattern import Pattern @@ -633,8 +633,101 @@ def test_from_to_pattern(self, fx_rng: Generator) -> None: state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) assert np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten())) == pytest.approx(1) + def test_isclose_measurement(self) -> None: + og_1 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Measurement(0.1, Plane.XY)), + ) + og_2 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Measurement(0.15, Plane.XY)), + ) + og_3 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3), (0, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Measurement(0.15, Plane.XY)), + ) + assert og_1.isclose(og_2, abs_tol=0.1) + assert not og_1.isclose(og_2) + assert not og_2.isclose(og_3) + + def test_isclose_plane(self) -> None: + og_1 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Plane.XY), + ) + og_2 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Plane.XZ), + ) + + assert not og_1.isclose(og_2) + assert og_1.isclose(og_1) + + def test_isclose_axis(self) -> None: + og_1 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Axis.X), + ) + og_2 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Axis.Y), + ) + + assert not og_1.isclose(og_2) + assert og_1.isclose(og_1) + assert og_2.isclose(og_2) + + def test_is_equal_structurally(self) -> None: + og_1 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Measurement(0.15, Plane.XY)), + ) + og_2 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Measurement(0.1, Plane.XY)), + ) + og_3 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Plane.XY), + ) + og_4 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Axis.X), + ) + og_5 = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3), (0, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Axis.X), + ) + assert og_1.is_equal_structurally(og_2) + assert og_1.is_equal_structurally(og_3) + assert og_1.is_equal_structurally(og_4) + assert not og_1.is_equal_structurally(og_5) + -# TODO: Add test `OpenGraph.is_close` # TODO: rewrite as parametric tests # Tests composition of two graphs From 719780d05828db29ed1abe1e9cb8f15f56574f25 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 2 Dec 2025 16:24:16 +0100 Subject: [PATCH 10/21] Refactor of flow tools - `OpenGraph.compose` (#375) This commit adapts the existing method `:func: OpenGraph.compose` to the new API introduced in #358. --- CHANGELOG.md | 5 +- graphix/opengraph.py | 22 +- tests/test_opengraph.py | 494 +++++++++++++++++++++++----------------- 3 files changed, 292 insertions(+), 229 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 039e3d2fb..015421049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added -- #347: +- #374: - Introduced new method `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs. - Added new method `isclose` to `graphix.fundamentals.AbstractMeasurement` which defaults to `==` comparison. ### Fixed ### Changed +- #374: Adapted existing method `graphix.opengraph.OpenGraph.isclose` to the new API introduced in #358. +- #375: Adapted existing method `graphix.opengraph.OpenGraph.compose` to the new API introduced in #358. ## [0.3.3] - 2025-10-23 -- #347: Adapted existing method `graphix.opengraph.OpenGraph.isclose` to the new API introduced in #358. ### Added - #343: Circuit exporter to OpenQASM3: diff --git a/graphix/opengraph.py b/graphix/opengraph.py index f6b8b1e25..93d235ecb 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -360,25 +360,22 @@ def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: correction_matrix ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. - # TODO: Generalise `compose` to any type of OpenGraph - def compose( - self: OpenGraph[Measurement], other: OpenGraph[Measurement], mapping: Mapping[int, int] - ) -> tuple[OpenGraph[Measurement], dict[int, int]]: - r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. + def compose(self, other: OpenGraph[_M_co], mapping: Mapping[int, int]) -> tuple[OpenGraph[_M_co], dict[int, int]]: + r"""Compose two open graphs by merging subsets of nodes from ``self`` and ``other``, and relabeling the nodes of ``other`` that were not merged. Parameters ---------- - other : OpenGraph - Open graph to be composed with `self`. + other : OpenGraph[_M_co] + Open graph to be composed with ``self``. mapping: dict[int, int] - Partial relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node labels, respectively. + Partial relabelling of the nodes in ``other``, with ``keys`` and ``values`` denoting the old and new node labels, respectively. Returns ------- - og: OpenGraph - composed open graph + og: OpenGraph[_M_co] + Composed open graph. mapping_complete: dict[int, int] - Complete relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node label, respectively. + Complete relabelling of the nodes in ``other``, with ``keys`` and ``values`` denoting the old and new node label, respectively. Notes ----- @@ -399,13 +396,14 @@ def compose( raise ValueError("Keys of mapping must be correspond to nodes of other.") if len(mapping) != len(set(mapping.values())): raise ValueError("Values in mapping contain duplicates.") + for v, u in mapping.items(): if ( (vm := other.measurements.get(v)) is not None and (um := self.measurements.get(u)) is not None and not vm.isclose(um) ): - raise ValueError(f"Attempted to merge nodes {v}:{u} but have different measurements") + raise OpenGraphError(f"Attempted to merge nodes with different measurements: {v, vm} -> {u, um}.") shift = max(*self.graph.nodes, *mapping.values()) + 1 diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index bf2eb58c4..f86eced4f 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, NamedTuple import networkx as nx @@ -25,6 +26,8 @@ from numpy.random import Generator + from graphix.fundamentals import AbstractMeasurement + class OpenGraphFlowTestCase(NamedTuple): og: OpenGraph[Measurement] @@ -34,6 +37,7 @@ class OpenGraphFlowTestCase(NamedTuple): OPEN_GRAPH_FLOW_TEST_CASES: list[OpenGraphFlowTestCase] = [] +OPEN_GRAPH_COMPOSE_TEST_CASES: list[OpenGraphComposeTestCase] = [] def register_open_graph_flow_test_case( @@ -43,6 +47,13 @@ def register_open_graph_flow_test_case( return test_case +def register_open_graph_compose_test_case( + test_case: Callable[[], OpenGraphComposeTestCase], +) -> Callable[[], OpenGraphComposeTestCase]: + OPEN_GRAPH_COMPOSE_TEST_CASES.append(test_case()) + return test_case + + @register_open_graph_flow_test_case def _og_0() -> OpenGraphFlowTestCase: """Generate open graph. @@ -544,6 +555,241 @@ def _og_19() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) +class OpenGraphComposeTestCase(NamedTuple): + og1: OpenGraph[AbstractMeasurement] + og2: OpenGraph[AbstractMeasurement] + og_ref: OpenGraph[AbstractMeasurement] + mapping: dict[int, int] + + +# Parallel composition +@register_open_graph_compose_test_case +def _compose_0() -> OpenGraphComposeTestCase: + """Generate composition test. + + Graph 1 + [1] -- (2) + + Graph 2 = Graph 1 + + Mapping: 1 -> 100, 2 -> 200 + + Expected graph + [1] -- (2) + + [100] -- (200) + """ + g: nx.Graph[int] = nx.Graph([(1, 2)]) + inputs = [1] + outputs = [2] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og1 = OpenGraph(g, inputs, outputs, meas) + og2 = OpenGraph(g, inputs, outputs, meas) + og_ref = OpenGraph( + nx.Graph([(1, 2), (100, 200)]), + input_nodes=[1, 100], + output_nodes=[2, 200], + measurements={1: Measurement(0, Plane.XY), 100: Measurement(0, Plane.XY)}, + ) + + mapping = {1: 100, 2: 200} + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + +# Series composition +@register_open_graph_compose_test_case +def _compose_1() -> OpenGraphComposeTestCase: + """Generate composition test. + + Graph 1 + [0] -- 17 -- (23) + | + [3] -- 4 -- (13) + + Graph 2 + [6] -- 17 -- (1) + | | + [7] -- 4 -- (2) + + Mapping: 6 -> 23, 7 -> 13, 1 -> 100, 2 -> 200, 17 -> 90 + + Expected graph + [0] -- 17 -- 23 -- 90 -- (100) + | | | + [3] -- 4 -- 13 -- 201 -- (200) + """ + g: nx.Graph[int] = nx.Graph([(0, 17), (17, 23), (17, 4), (3, 4), (4, 13)]) + inputs = [0, 3] + outputs = [13, 23] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og1 = OpenGraph(g, inputs, outputs, meas) + + g = nx.Graph([(6, 7), (6, 17), (17, 1), (7, 4), (17, 4), (4, 2)]) + inputs = [6, 7] + outputs = [1, 2] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og2 = OpenGraph(g, inputs, outputs, meas) + + mapping = {6: 23, 7: 13, 1: 100, 2: 200, 17: 90} + + g = nx.Graph( + [(0, 17), (17, 23), (17, 4), (3, 4), (4, 13), (23, 13), (23, 90), (13, 201), (90, 201), (90, 100), (201, 200)] + ) + inputs = [0, 3] + outputs = [100, 200] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og_ref = OpenGraph(g, inputs, outputs, meas) + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + +# Full overlap +@register_open_graph_compose_test_case +def _compose_2() -> OpenGraphComposeTestCase: + """Generate composition test. + + Graph 1 + [0] -- 17 -- (23) + | + [3] -- 4 -- (13) + + Graph 2 = Graph 1 + + Mapping: 0 -> 0, 3 -> 3, 17 -> 17, 4 -> 4, 23 -> 23, 13 -> 13 + + Expected graph = Graph 1 + """ + g: nx.Graph[int] + g = nx.Graph([(0, 17), (17, 23), (17, 4), (3, 4), (4, 13)]) + inputs = [0, 3] + outputs = [13, 23] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og1 = OpenGraph(g, inputs, outputs, meas) + og2 = OpenGraph(g, inputs, outputs, meas) + og_ref = OpenGraph(g, inputs, outputs, meas) + + mapping = {i: i for i in g.nodes} + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + +# Overlap inputs/outputs +@register_open_graph_compose_test_case +def _compose_3() -> OpenGraphComposeTestCase: + """Generate composition test. + + Graph 1 + ([17]) -- (3) + | + [18] + + Graph 2 + [1] -- 2 -- (3) + + Mapping: 1 -> 17, 3 -> 300 + + Expected graph + (300) -- 301 -- [17] -- (3) + | + [18] + """ + g: nx.Graph[int] = nx.Graph([(18, 17), (17, 3)]) + inputs = [17, 18] + outputs = [3, 17] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og1 = OpenGraph(g, inputs, outputs, meas) + + g = nx.Graph([(1, 2), (2, 3)]) + inputs = [1] + outputs = [3] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og2 = OpenGraph(g, inputs, outputs, meas) + + mapping = {1: 17, 3: 300} + + g = nx.Graph([(18, 17), (17, 3), (17, 301), (301, 300)]) + inputs = [17, 18] # the input character of node 17 is kept because node 1 (in G2) is an input. + outputs = [3, 300] # the output character of node 17 is lost because node 1 (in G2) is not an output + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og_ref = OpenGraph(g, inputs, outputs, meas) + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + +# Inverse series composition +@register_open_graph_compose_test_case +def _compose_4() -> OpenGraphComposeTestCase: + """Generate composition test. + + Graph 1 + [1] -- (2) + | + [3] + + Graph 2 + [3] -- (4) + + Mapping: 4 -> 1, 3 -> 300 + + Expected graph + [300] -- 1 -- (2) + | + [3] + """ + g: nx.Graph[int] = nx.Graph([(1, 2), (1, 3)]) + inputs = [1, 3] + outputs = [2] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og1 = OpenGraph(g, inputs, outputs, meas) + + g = nx.Graph([(3, 4)]) + inputs = [3] + outputs = [4] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og2 = OpenGraph(g, inputs, outputs, meas) + + mapping = {4: 1, 3: 300} + + g = nx.Graph([(1, 2), (1, 3), (1, 300)]) + inputs = [3, 300] + outputs = [2] + meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} + og_ref = OpenGraph(g, inputs, outputs, meas) + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + +@register_open_graph_compose_test_case +def _compose_5() -> OpenGraphComposeTestCase: + """Generate composition test. + + Graph 1 + [1] -- (2) + + Graph 2 = Graph 1 + + Mapping: 1 -> 2 + + Expected graph + [1] -- 2 -- (3) + + """ + g: nx.Graph[int] = nx.Graph([(1, 2)]) + inputs = [1] + outputs = [2] + meas = dict.fromkeys(g.nodes - set(outputs), Plane.XY) + og1 = OpenGraph(g, inputs, outputs, meas) + og2 = OpenGraph(g, inputs, outputs, meas) + og_ref = OpenGraph( + nx.Graph([(1, 2), (2, 3)]), input_nodes=[1], output_nodes=[3], measurements={1: Plane.XY, 2: Plane.XY} + ) + + mapping = {1: 2} + + return OpenGraphComposeTestCase(og1, og2, og_ref, mapping) + + def check_determinism(pattern: Pattern, fx_rng: Generator, n_shots: int = 3) -> bool: """Verify if the input pattern is deterministic.""" for plane in {Plane.XY, Plane.XZ, Plane.YZ}: @@ -727,218 +973,36 @@ def test_is_equal_structurally(self) -> None: assert og_1.is_equal_structurally(og_4) assert not og_1.is_equal_structurally(og_5) - -# TODO: rewrite as parametric tests - -# Tests composition of two graphs - - -# Parallel composition -def test_compose_1() -> None: - # Graph 1 - # [1] -- (2) - # - # Graph 2 = Graph 1 - # - # Mapping: 1 -> 100, 2 -> 200 - # - # Expected graph - # [1] -- (2) - # - # [100] -- (200) - - g: nx.Graph[int] - g = nx.Graph([(1, 2)]) - inputs = [1] - outputs = [2] - meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, inputs, outputs, meas) - - mapping = {1: 100, 2: 200} - - og, mapping_complete = og_1.compose(og_1, mapping) - - expected_graph: nx.Graph[int] - expected_graph = nx.Graph([(1, 2), (100, 200)]) - assert nx.is_isomorphic(og.graph, expected_graph) - assert og.input_nodes == [1, 100] - assert og.output_nodes == [2, 200] - - outputs_c = {i for i in og.graph.nodes if i not in og.output_nodes} - assert og.measurements.keys() == outputs_c - assert mapping.keys() <= mapping_complete.keys() - assert set(mapping.values()) <= set(mapping_complete.values()) - - -# Series composition -def test_compose_2() -> None: - # Graph 1 - # [0] -- 17 -- (23) - # | - # [3] -- 4 -- (13) - # - # Graph 2 - # [6] -- 17 -- (1) - # | | - # [7] -- 4 -- (2) - # - # Mapping: 6 -> 23, 7 -> 13, 1 -> 100, 2 -> 200 - # - # Expected graph - # [0] -- 17 -- 23 -- o -- (100) - # | | | - # [3] -- 4 -- 13 -- o -- (200) - - g: nx.Graph[int] - g = nx.Graph([(0, 17), (17, 23), (17, 4), (3, 4), (4, 13)]) - inputs = [0, 3] - outputs = [13, 23] - meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, inputs, outputs, meas) - - g = nx.Graph([(6, 7), (6, 17), (17, 1), (7, 4), (17, 4), (4, 2)]) - inputs = [6, 7] - outputs = [1, 2] - meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_2 = OpenGraph(g, inputs, outputs, meas) - - mapping = {6: 23, 7: 13, 1: 100, 2: 200} - - og, mapping_complete = og_1.compose(og_2, mapping) - - expected_graph: nx.Graph[int] - expected_graph = nx.Graph( - [(0, 17), (17, 23), (17, 4), (3, 4), (4, 13), (23, 13), (23, 1), (13, 2), (1, 2), (1, 100), (2, 200)] - ) - assert nx.is_isomorphic(og.graph, expected_graph) - assert og.input_nodes == [0, 3] - assert og.output_nodes == [100, 200] - - outputs_c = {i for i in og.graph.nodes if i not in og.output_nodes} - assert og.measurements.keys() == outputs_c - assert mapping.keys() <= mapping_complete.keys() - assert set(mapping.values()) <= set(mapping_complete.values()) - - -# Full overlap -def test_compose_3() -> None: - # Graph 1 - # [0] -- 17 -- (23) - # | - # [3] -- 4 -- (13) - # - # Graph 2 = Graph 1 - # - # Mapping: 0 -> 0, 3 -> 3, 17 -> 17, 4 -> 4, 23 -> 23, 13 -> 13 - # - # Expected graph = Graph 1 - - g: nx.Graph[int] - g = nx.Graph([(0, 17), (17, 23), (17, 4), (3, 4), (4, 13)]) - inputs = [0, 3] - outputs = [13, 23] - meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, inputs, outputs, meas) - - mapping = {i: i for i in g.nodes} - - og, mapping_complete = og_1.compose(og_1, mapping) - - assert og.isclose(og_1) - assert mapping.keys() <= mapping_complete.keys() - assert set(mapping.values()) <= set(mapping_complete.values()) - - -# Overlap inputs/outputs -def test_compose_4() -> None: - # Graph 1 - # ([17]) -- (3) - # | - # [18] - # - # Graph 2 - # [1] -- 2 -- (3) - # - # Mapping: 1 -> 17, 3 -> 300 - # - # Expected graph - # (300) -- 2 -- [17] -- (3) - # | - # [18] - - g: nx.Graph[int] - g = nx.Graph([(18, 17), (17, 3)]) - inputs = [17, 18] - outputs = [3, 17] - meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, inputs, outputs, meas) - - g = nx.Graph([(1, 2), (2, 3)]) - inputs = [1] - outputs = [3] - meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_2 = OpenGraph(g, inputs, outputs, meas) - - mapping = {1: 17, 3: 300} - - og, mapping_complete = og_1.compose(og_2, mapping) - - expected_graph: nx.Graph[int] - expected_graph = nx.Graph([(18, 17), (17, 3), (17, 2), (2, 300)]) - assert nx.is_isomorphic(og.graph, expected_graph) - assert og.input_nodes == [17, 18] # the input character of node 17 is kept because node 1 (in G2) is an input - assert og.output_nodes == [ - 3, - 300, - ] # the output character of node 17 is lost because node 1 (in G2) is not an output - - outputs_c = {i for i in og.graph.nodes if i not in og.output_nodes} - assert og.measurements.keys() == outputs_c - assert mapping.keys() <= mapping_complete.keys() - assert set(mapping.values()) <= set(mapping_complete.values()) - - -# Inverse series composition -def test_compose_5() -> None: - # Graph 1 - # [1] -- (2) - # | - # [3] - # - # Graph 2 - # [3] -- (4) - # - # Mapping: 4 -> 1, 3 -> 300 - # - # Expected graph - # [300] -- 1 -- (2) - # | - # [3] - - g: nx.Graph[int] - g = nx.Graph([(1, 2), (1, 3)]) - inputs = [1, 3] - outputs = [2] - meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, inputs, outputs, meas) - - g = nx.Graph([(3, 4)]) - inputs = [3] - outputs = [4] - meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_2 = OpenGraph(g, inputs, outputs, meas) - - mapping = {4: 1, 3: 300} - - og, mapping_complete = og_1.compose(og_2, mapping) - - expected_graph: nx.Graph[int] - expected_graph = nx.Graph([(1, 2), (1, 3), (1, 300)]) - assert nx.is_isomorphic(og.graph, expected_graph) - assert og.input_nodes == [3, 300] - assert og.output_nodes == [2] - - outputs_c = {i for i in og.graph.nodes if i not in og.output_nodes} - assert og.measurements.keys() == outputs_c - assert mapping.keys() <= mapping_complete.keys() - assert set(mapping.values()) <= set(mapping_complete.values()) + @pytest.mark.parametrize("test_case", OPEN_GRAPH_COMPOSE_TEST_CASES) + def test_compose(self, test_case: OpenGraphComposeTestCase) -> None: + og1, og2, og_ref, mapping = test_case + og, mapping_complete = og1.compose(og2, mapping) + assert og.isclose(og_ref) + assert mapping.keys() <= mapping_complete.keys() + assert set(mapping.values()) <= set(mapping_complete.values()) + + def test_compose_exception(self) -> None: + g: nx.Graph[int] = nx.Graph([(0, 1)]) + inputs = [0] + outputs = [1] + mapping = {0: 0, 1: 1} + + og1 = OpenGraph(g, inputs, outputs, measurements={0: Measurement(0, Plane.XY)}) + og2 = OpenGraph(g, inputs, outputs, measurements={0: Measurement(0.5, Plane.XY)}) + + with pytest.raises( + OpenGraphError, + match=re.escape( + "Attempted to merge nodes with different measurements: (0, Measurement(angle=0.5, plane=Plane.XY)) -> (0, Measurement(angle=0, plane=Plane.XY))." + ), + ): + og1.compose(og2, mapping) + + og3 = OpenGraph(g, inputs, outputs, measurements={0: Plane.XY}) + og4 = OpenGraph(g, inputs, outputs, measurements={0: Plane.XZ}) + + with pytest.raises( + OpenGraphError, + match=re.escape("Attempted to merge nodes with different measurements: (0, Plane.XZ) -> (0, Plane.XY)."), + ): + og3.compose(og4, mapping) From b2bcffa43d7094d7ae6845552a6b0d834f3148bc Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Tue, 2 Dec 2025 17:28:32 +0100 Subject: [PATCH 11/21] Fix #349: ensure flow for patterns transpiled from circuit (#362) This commit fixes domains in the transpiler so that every pattern transpiled from a circuit has a flow. Previously, some domains were incompatible with any flow, despite the patterns being deterministic, because some measurement angles were zero, causing the measurements to ignore certain signals. This commit also adds `optimization.remove_useless_domains` to remove the domains ignored by measurements with angle zero, to recover the same "optimized" patterns as before when needed. This commit also adds `rand_state_vector` to draw a random state vector. --- CHANGELOG.md | 7 +++ graphix/optimization.py | 23 +++++++++- graphix/random_objects.py | 21 +++++++++ graphix/transpiler.py | 84 +++++++++++++++------------------- tests/test_optimization.py | 18 +++++++- tests/test_random_utilities.py | 19 ++++++++ tests/test_transpiler.py | 64 ++++++++++++++++---------- 7 files changed, 162 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 015421049..06ed5e5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added + - #374: - Introduced new method `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs. - Added new method `isclose` to `graphix.fundamentals.AbstractMeasurement` which defaults to `==` comparison. ### Fixed +- #347: Adapted existing method `graphix.opengraph.OpenGraph.isclose` to the new API introduced in #358. + +- #349, #362: Patterns transpiled from circuits always have causal flow. + ### Changed + - #374: Adapted existing method `graphix.opengraph.OpenGraph.isclose` to the new API introduced in #358. + - #375: Adapted existing method `graphix.opengraph.OpenGraph.compose` to the new API introduced in #358. ## [0.3.3] - 2025-10-23 diff --git a/graphix/optimization.py b/graphix/optimization.py index 276919599..cc67f2833 100644 --- a/graphix/optimization.py +++ b/graphix/optimization.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses from copy import copy from dataclasses import dataclass from types import MappingProxyType @@ -16,7 +17,7 @@ from graphix import command from graphix.clifford import Clifford from graphix.command import CommandKind, Node -from graphix.fundamentals import Axis +from graphix.fundamentals import Axis, Plane from graphix.measurements import Domains, Outcome, PauliMeasurement if TYPE_CHECKING: @@ -460,3 +461,23 @@ def incorporate_pauli_results(pattern: Pattern) -> Pattern: result.add(cmd) result.reorder_output_nodes(pattern.output_nodes) return result + + +def remove_useless_domains(pattern: Pattern) -> Pattern: + """Return an equivalent pattern where measurement domains that are not used given the specific measurement angles and planes are removed.""" + new_pattern = graphix.pattern.Pattern(input_nodes=pattern.input_nodes) + new_pattern.results = pattern.results + for cmd in pattern: + if cmd.kind == CommandKind.M: + if cmd.angle == 0: + if cmd.plane == Plane.XY: + new_cmd = dataclasses.replace(cmd, s_domain=set()) + else: + new_cmd = dataclasses.replace(cmd, t_domain=set()) + else: + new_cmd = cmd + new_pattern.add(new_cmd) + else: + new_pattern.add(cmd) + new_pattern.reorder_output_nodes(pattern.output_nodes) + return new_pattern diff --git a/graphix/random_objects.py b/graphix/random_objects.py index bcd04757d..416778ed4 100644 --- a/graphix/random_objects.py +++ b/graphix/random_objects.py @@ -428,3 +428,24 @@ def rand_circuit( ind = rng.integers(len(gate_choice)) gate_choice[ind](j) return circuit + + +def rand_state_vector(nqubits: int, rng: Generator | None = None) -> npt.NDArray[np.complex128]: + """ + Generate a random normalized complex state vector of size 2^n. + + Parameters + ---------- + nqubits : int + The power of 2 for the vector size + + Returns + ------- + numpy.ndarray + Normalized complex vector of size 2^nqubits + """ + rng = ensure_rng(rng) + dim = 1 << nqubits # 2**nqubits is typed Any + real, imag = rng.random((2, dim)) - 0.5 + vec: npt.NDArray[np.complex128] = (real + 1j * imag).astype(np.complex128) + return vec / np.linalg.norm(vec) diff --git a/graphix/transpiler.py b/graphix/transpiler.py index 8b5d2fceb..9af57b4e7 100644 --- a/graphix/transpiler.py +++ b/graphix/transpiler.py @@ -499,7 +499,7 @@ def _cnot_command( E(nodes=(control_node, ancilla[0])), E(nodes=(ancilla[0], ancilla[1])), M(node=target_node), - M(node=ancilla[0]), + M(node=ancilla[0], s_domain={target_node}), X(node=ancilla[1], domain={ancilla[0]}), Z(node=ancilla[1], domain={target_node}), Z(node=control_node, domain={target_node}), @@ -592,7 +592,7 @@ def _s_command(cls, input_node: int, ancilla: Sequence[int]) -> tuple[int, list[ E(nodes=(input_node, ancilla[0])), E(nodes=(ancilla[0], ancilla[1])), M(node=input_node, angle=-0.5), - M(node=ancilla[0]), + M(node=ancilla[0], s_domain={input_node}), X(node=ancilla[1], domain={ancilla[0]}), Z(node=ancilla[1], domain={input_node}), ) @@ -624,7 +624,7 @@ def _x_command(cls, input_node: int, ancilla: Sequence[int]) -> tuple[int, list[ E(nodes=(input_node, ancilla[0])), E(nodes=(ancilla[0], ancilla[1])), M(node=input_node), - M(node=ancilla[0], angle=-1), + M(node=ancilla[0], angle=-1, s_domain={input_node}), X(node=ancilla[1], domain={ancilla[0]}), Z(node=ancilla[1], domain={input_node}), ) @@ -660,10 +660,10 @@ def _y_command(cls, input_node: int, ancilla: Sequence[int]) -> tuple[int, list[ E(nodes=(ancilla[2], ancilla[3])), M(node=input_node, angle=0.5), M(node=ancilla[0], angle=1.0, s_domain={input_node}), - M(node=ancilla[1], angle=-0.5, s_domain={input_node}), - M(node=ancilla[2]), - X(node=ancilla[3], domain={ancilla[0], ancilla[2]}), - Z(node=ancilla[3], domain={ancilla[0], ancilla[1]}), + M(node=ancilla[1], angle=-0.5, s_domain={ancilla[0]}, t_domain={input_node}), + M(node=ancilla[2], s_domain={ancilla[1]}, t_domain={ancilla[0]}), + X(node=ancilla[3], domain={ancilla[2]}), + Z(node=ancilla[3], domain={ancilla[1]}), ) ) return ancilla[3], seq @@ -693,7 +693,7 @@ def _z_command(cls, input_node: int, ancilla: Sequence[int]) -> tuple[int, list[ E(nodes=(input_node, ancilla[0])), E(nodes=(ancilla[0], ancilla[1])), M(node=input_node, angle=-1), - M(node=ancilla[0]), + M(node=ancilla[0], s_domain={input_node}), X(node=ancilla[1], domain={ancilla[0]}), Z(node=ancilla[1], domain={input_node}), ) @@ -765,10 +765,10 @@ def _ry_command(cls, input_node: int, ancilla: Sequence[int], angle: Angle) -> t E(nodes=(ancilla[2], ancilla[3])), M(node=input_node, angle=0.5), M(node=ancilla[0], angle=-angle / np.pi, s_domain={input_node}), - M(node=ancilla[1], angle=-0.5, s_domain={input_node}), - M(node=ancilla[2]), - X(node=ancilla[3], domain={ancilla[0], ancilla[2]}), - Z(node=ancilla[3], domain={ancilla[0], ancilla[1]}), + M(node=ancilla[1], angle=-0.5, s_domain={ancilla[0]}, t_domain={input_node}), + M(node=ancilla[2], s_domain={ancilla[1]}, t_domain={ancilla[0]}), + X(node=ancilla[3], domain={ancilla[2]}), + Z(node=ancilla[3], domain={ancilla[1]}), ) ) return ancilla[3], seq @@ -800,7 +800,7 @@ def _rz_command(cls, input_node: int, ancilla: Sequence[int], angle: Angle) -> t E(nodes=(input_node, ancilla[0])), E(nodes=(ancilla[0], ancilla[1])), M(node=input_node, angle=-angle / np.pi), - M(node=ancilla[0]), + M(node=ancilla[0], s_domain={input_node}), X(node=ancilla[1], domain={ancilla[0]}), Z(node=ancilla[1], domain={input_node}), ) @@ -869,49 +869,37 @@ def _ccx_command( E(nodes=(ancilla[16], ancilla[17])), M(node=target_node), M(node=ancilla[0], s_domain={target_node}), - M(node=ancilla[1], s_domain={ancilla[0]}), + M(node=ancilla[1], s_domain={ancilla[0]}, t_domain={target_node}), M(node=control_node1), - M(node=ancilla[2], angle=-1.75, s_domain={ancilla[1], target_node}), + M(node=ancilla[2], angle=-1.75, s_domain={ancilla[1]}, t_domain={ancilla[0]}), M(node=ancilla[14], s_domain={control_node1}), - M(node=ancilla[3], s_domain={ancilla[2], ancilla[0]}), - M(node=ancilla[5], angle=-0.25, s_domain={ancilla[3], ancilla[1], ancilla[14], target_node}), - M(node=control_node2, angle=-0.25), - M(node=ancilla[6], s_domain={ancilla[5], ancilla[2], ancilla[0]}), - M(node=ancilla[9], s_domain={control_node2, ancilla[5], ancilla[2]}), - M( - node=ancilla[7], - angle=-1.75, - s_domain={ancilla[6], ancilla[3], ancilla[1], ancilla[14], target_node}, - ), - M(node=ancilla[10], angle=-1.75, s_domain={ancilla[9], ancilla[14]}), - M(node=ancilla[4], angle=-0.25, s_domain={ancilla[14]}), - M(node=ancilla[8], s_domain={ancilla[7], ancilla[5], ancilla[2], ancilla[0]}), - M(node=ancilla[11], s_domain={ancilla[10], control_node2, ancilla[5], ancilla[2]}), + M(node=ancilla[3], s_domain={ancilla[2]}, t_domain={ancilla[1], ancilla[14]}), + M(node=ancilla[5], angle=-0.25, s_domain={ancilla[3]}, t_domain={ancilla[2]}), + M(node=control_node2, angle=-0.25, t_domain={ancilla[5], ancilla[0]}), + M(node=ancilla[6], s_domain={ancilla[5]}, t_domain={ancilla[3]}), + M(node=ancilla[9], s_domain={control_node2}, t_domain={ancilla[14]}), + M(node=ancilla[7], angle=-1.75, s_domain={ancilla[6]}, t_domain={ancilla[5]}), + M(node=ancilla[10], angle=-1.75, s_domain={ancilla[9]}, t_domain={control_node2}), M( - node=ancilla[12], + node=ancilla[4], angle=-0.25, - s_domain={ancilla[8], ancilla[6], ancilla[3], ancilla[1], target_node}, + s_domain={ancilla[14]}, + t_domain={control_node1, control_node2, ancilla[2], ancilla[7], ancilla[10]}, ), + M(node=ancilla[8], s_domain={ancilla[7]}, t_domain={ancilla[14], ancilla[6]}), + M(node=ancilla[11], s_domain={ancilla[10]}, t_domain={ancilla[9], ancilla[14]}), + M(node=ancilla[12], angle=-0.25, s_domain={ancilla[8]}, t_domain={ancilla[7]}), M( node=ancilla[16], - s_domain={ - ancilla[4], - control_node1, - ancilla[2], - control_node2, - ancilla[7], - ancilla[10], - ancilla[2], - control_node2, - ancilla[5], - }, + s_domain={ancilla[4]}, + t_domain={ancilla[14]}, ), - X(node=ancilla[17], domain={ancilla[14], ancilla[16]}), - X(node=ancilla[15], domain={ancilla[9], ancilla[11]}), - X(node=ancilla[13], domain={ancilla[0], ancilla[2], ancilla[5], ancilla[7], ancilla[12]}), - Z(node=ancilla[17], domain={ancilla[4], ancilla[5], ancilla[7], ancilla[10], control_node1}), - Z(node=ancilla[15], domain={control_node2, ancilla[2], ancilla[5], ancilla[10]}), - Z(node=ancilla[13], domain={ancilla[1], ancilla[3], ancilla[6], ancilla[8], target_node}), + X(node=ancilla[17], domain={ancilla[16]}), + X(node=ancilla[15], domain={ancilla[11]}), + X(node=ancilla[13], domain={ancilla[12]}), + Z(node=ancilla[17], domain={ancilla[4]}), + Z(node=ancilla[15], domain={ancilla[10]}), + Z(node=ancilla[13], domain={ancilla[8]}), ) ) return ancilla[17], ancilla[15], ancilla[13], seq diff --git a/tests/test_optimization.py b/tests/test_optimization.py index cb61051f8..7693845df 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -8,7 +8,7 @@ from graphix.command import C, Command, CommandKind, E, M, N, X, Z from graphix.fundamentals import Plane from graphix.gflow import gflow_from_pattern -from graphix.optimization import StandardizedPattern, incorporate_pauli_results +from graphix.optimization import StandardizedPattern, incorporate_pauli_results, remove_useless_domains from graphix.pattern import Pattern from graphix.random_objects import rand_circuit from graphix.states import PlanarState @@ -86,6 +86,22 @@ def test_flow_after_pauli_preprocessing(fx_bg: PCG64, jumps: int) -> None: assert f is not None +@pytest.mark.parametrize("jumps", range(1, 11)) +def test_remove_useless_domains(fx_bg: PCG64, jumps: int) -> None: + rng = Generator(fx_bg.jumped(jumps)) + nqubits = 3 + depth = 3 + circuit = rand_circuit(nqubits, depth, rng) + pattern = circuit.transpile().pattern + pattern.standardize() + pattern.shift_signals() + pattern.perform_pauli_measurements() + pattern2 = remove_useless_domains(pattern) + state = pattern.simulate_pattern(rng=rng) + state2 = pattern2.simulate_pattern(rng=rng) + assert np.abs(np.dot(state.flatten().conjugate(), state2.flatten())) == pytest.approx(1) + + def test_to_space_optimal_pattern() -> None: pattern = Pattern( cmds=[ diff --git a/tests/test_random_utilities.py b/tests/test_random_utilities.py index 4d8c0734e..978d91c22 100644 --- a/tests/test_random_utilities.py +++ b/tests/test_random_utilities.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools from typing import TYPE_CHECKING import numpy as np @@ -166,3 +167,21 @@ def test_random_pauli_channel_fail(self, fx_rng: Generator) -> None: with pytest.raises(ValueError): randobj.rand_pauli_channel_kraus(dim=2**nqb + 1, rank=rk, rng=fx_rng) + + def test_rand_state_vector(self, fx_rng: Generator) -> None: + count = 10 + nqubits = 4 + sample = [randobj.rand_state_vector(nqubits, rng=fx_rng) for _ in range(count)] + sample_array = np.array(sample) + # Sampled state vectors are pairwise distinct + for u, v in itertools.combinations(sample, 2): + assert not np.allclose(u, v) + # Every state vector is of the expected size + assert sample_array.shape == (count, 1 << nqubits) + # Every state vector is normalized + norms = np.linalg.norm(sample_array, axis=1) + assert np.allclose(norms, 1) + # Some real parts are negative + assert np.any(np.real(sample_array) < 0) + # Some imaginary parts are negative + assert np.any(np.imag(sample_array) < 0) diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py index a5767c8e7..e823c15d2 100644 --- a/tests/test_transpiler.py +++ b/tests/test_transpiler.py @@ -8,12 +8,35 @@ from graphix import instruction from graphix.fundamentals import Plane -from graphix.random_objects import rand_circuit, rand_gate +from graphix.gflow import flow_from_pattern +from graphix.random_objects import rand_circuit, rand_gate, rand_state_vector from graphix.transpiler import Circuit if TYPE_CHECKING: + from collections.abc import Callable + from typing import TypeAlias + from graphix.instruction import Instruction + InstructionTestCase: TypeAlias = Callable[[Generator], Instruction] + +INSTRUCTION_TEST_CASES: list[InstructionTestCase] = [ + lambda _rng: instruction.CCX(0, (1, 2)), + lambda rng: instruction.RZZ(0, 1, rng.random() * 2 * np.pi), + lambda _rng: instruction.CZ((0, 1)), + lambda _rng: instruction.CNOT(0, 1), + lambda _rng: instruction.SWAP((0, 1)), + lambda _rng: instruction.H(0), + lambda _rng: instruction.S(0), + lambda _rng: instruction.X(0), + lambda _rng: instruction.Y(0), + lambda _rng: instruction.Z(0), + lambda _rng: instruction.I(0), + lambda rng: instruction.RX(0, rng.random() * 2 * np.pi), + lambda rng: instruction.RY(0, rng.random() * 2 * np.pi), + lambda rng: instruction.RZ(0, rng.random() * 2 * np.pi), +] + class TestTranspilerUnitGates: def test_cz(self, fx_rng: Generator) -> None: @@ -165,28 +188,21 @@ def test_add_extend(self) -> None: circuit2 = Circuit(3, instr=circuit.instruction) assert circuit.instruction == circuit2.instruction - @pytest.mark.parametrize( - "instruction", - [ - instruction.CCX(0, (1, 2)), - instruction.RZZ(0, 1, np.pi / 4), - instruction.CNOT(0, 1), - instruction.SWAP((0, 1)), - instruction.CZ((0, 1)), - instruction.H(0), - instruction.S(0), - instruction.X(0), - instruction.Y(0), - instruction.Z(0), - instruction.I(0), - instruction.RX(0, 0), - instruction.RY(0, 0), - instruction.RZ(0, 0), - ], - ) - def test_instructions(self, fx_rng: Generator, instruction: Instruction) -> None: - circuit = Circuit(3, instr=[instruction]) + @pytest.mark.parametrize("instruction", INSTRUCTION_TEST_CASES) + def test_instruction_flow(self, fx_rng: Generator, instruction: InstructionTestCase) -> None: + circuit = Circuit(3, instr=[instruction(fx_rng)]) pattern = circuit.transpile().pattern - state = circuit.simulate_statevector(rng=fx_rng).statevec - state_mbqc = pattern.simulate_pattern(rng=fx_rng) + pattern.standardize() + f, _l = flow_from_pattern(pattern) + assert f is not None + + @pytest.mark.parametrize("jumps", range(1, 11)) + @pytest.mark.parametrize("instruction", INSTRUCTION_TEST_CASES) + def test_instructions(self, fx_bg: PCG64, jumps: int, instruction: InstructionTestCase) -> None: + rng = Generator(fx_bg.jumped(jumps)) + circuit = Circuit(3, instr=[instruction(rng)]) + pattern = circuit.transpile().pattern + input_state = rand_state_vector(3, rng=rng) + state = circuit.simulate_statevector(input_state=input_state).statevec + state_mbqc = pattern.simulate_pattern(input_state=input_state, rng=rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) From 941463d53753f85683012ee01a4eb5db6f08ac26 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 28 Nov 2025 15:37:31 +0100 Subject: [PATCH 12/21] Done added CZ gate to circuits and downstream components --- tests/test_transpiler.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py index e823c15d2..61e240e01 100644 --- a/tests/test_transpiler.py +++ b/tests/test_transpiler.py @@ -43,7 +43,11 @@ def test_cz(self, fx_rng: Generator) -> None: circuit = Circuit(2) circuit.cz(0, 1) pattern = circuit.transpile().pattern +<<<<<<< HEAD state = circuit.simulate_statevector(rng=fx_rng).statevec +======= + state = circuit.simulate_statevector().statevec +>>>>>>> b973e91 (added CZ gate to circuits and downstream components) state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) From 82d68aa55c2bd2fc59998bca5793981c6f94c94d Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Fri, 5 Dec 2025 11:38:11 +0100 Subject: [PATCH 13/21] Added rng to tests, and added cz to rand_circuit function in --- tests/test_transpiler.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py index 61e240e01..fb04434c8 100644 --- a/tests/test_transpiler.py +++ b/tests/test_transpiler.py @@ -43,11 +43,15 @@ def test_cz(self, fx_rng: Generator) -> None: circuit = Circuit(2) circuit.cz(0, 1) pattern = circuit.transpile().pattern +<<<<<<< HEAD <<<<<<< HEAD state = circuit.simulate_statevector(rng=fx_rng).statevec ======= state = circuit.simulate_statevector().statevec >>>>>>> b973e91 (added CZ gate to circuits and downstream components) +======= + state = circuit.simulate_statevector(rng=fx_rng).statevec +>>>>>>> f064035 (Added rng to tests, and added cz to rand_circuit function in) state_mbqc = pattern.simulate_pattern(rng=fx_rng) assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) From 06f31401d5af217ea76a719cbcc5cba3efaae101 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Tue, 9 Dec 2025 10:27:31 +0100 Subject: [PATCH 14/21] Fixed dep for qasm-parser --- .github/workflows/cov.yml | 3 --- requirements-dev.txt | 3 ++- requirements-no-deps.txt | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 requirements-no-deps.txt diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml index 0f5cf1bab..855e74250 100644 --- a/.github/workflows/cov.yml +++ b/.github/workflows/cov.yml @@ -30,9 +30,6 @@ jobs: - name: Install graphix with dev deps. run: pip install .[dev] - - name: Install graphix-openqasm-parser without deps. - run: pip install --no-deps .[no-deps] - - name: Run pytest run: pytest --cov=./graphix --cov-report=xml --cov-report=term diff --git a/requirements-dev.txt b/requirements-dev.txt index 82f0f784e..b4f99322c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -24,4 +24,5 @@ pytest-mpl qiskit>=1.0 qiskit-aer -openqasm-parser>=3.1.0 \ No newline at end of file +openqasm-parser>=3.1.0 +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@add_cz diff --git a/requirements-no-deps.txt b/requirements-no-deps.txt deleted file mode 100644 index e4b696a0e..000000000 --- a/requirements-no-deps.txt +++ /dev/null @@ -1 +0,0 @@ -graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@add_cz From 6a3088871d4fb115db457fd5b43484c2380a1be0 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Tue, 9 Dec 2025 10:45:12 +0100 Subject: [PATCH 15/21] Fixing deps --- pyproject.toml | 1 - requirements-dev.txt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e377cac6e..c3063748e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ dependencies = { file = ["requirements.txt"] } dev = { file = ["requirements-dev.txt"] } extra = { file = ["requirements-extra.txt"] } doc = { file = ["requirements-doc.txt"] } -no-deps = { file = ["requirements-no-deps.txt"] } [tool.setuptools.packages.find] include = ["graphix", "stubs"] diff --git a/requirements-dev.txt b/requirements-dev.txt index b4f99322c..c5f5a9ef5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -25,4 +25,4 @@ qiskit>=1.0 qiskit-aer openqasm-parser>=3.1.0 -graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@add_cz +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git From 90e126ed32f6574d4dd1939e5bba6d7e8656c187 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Tue, 9 Dec 2025 16:21:01 +0100 Subject: [PATCH 16/21] fix reqs --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c5f5a9ef5..8265bc195 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -25,4 +25,4 @@ qiskit>=1.0 qiskit-aer openqasm-parser>=3.1.0 -graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git +graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@revert-reqs From 4b4c959dd01d8eb6a7b7d688b59ca2cb1d5c5250 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 10 Dec 2025 11:34:35 +0100 Subject: [PATCH 17/21] Remove version pinning on graphix-qasm-parser --- graphix/random_objects.py | 12 ++++++++---- requirements-dev.txt | 2 +- tests/test_qasm3_exporter.py | 6 ++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/graphix/random_objects.py b/graphix/random_objects.py index 416778ed4..d18aae637 100644 --- a/graphix/random_objects.py +++ b/graphix/random_objects.py @@ -367,6 +367,7 @@ def rand_circuit( depth: int, rng: Generator | None = None, *, + use_cz: bool = True, use_rzz: bool = False, use_ccx: bool = False, parameters: Iterable[Parameter] | None = None, @@ -381,10 +382,12 @@ def rand_circuit( Number of alternating entangling and single-qubit layers. rng : numpy.random.Generator, optional Random number generator. A default generator is created if ``None``. + use_cz : bool, optional + If ``True`` add CZ gates in each layer (default: ``True``). use_rzz : bool, optional - If ``True`` add :math:`R_{ZZ}` gates in each layer. + If ``True`` add :math:`R_{ZZ}` gates in each layer (default: ``False``). use_ccx : bool, optional - If ``True`` add CCX gates in each layer. + If ``True`` add CCX gates in each layer (default: ``False``). parameters : Iterable[Parameter], optional Parameters used for randomly chosen rotation gates. @@ -414,8 +417,9 @@ def rand_circuit( for _ in range(depth): for j, k in _genpair(nqubits, 2, rng): circuit.cnot(j, k) - for j, k in _genpair(nqubits, 2, rng): - circuit.cz(j, k) + if use_cz: + for j, k in _genpair(nqubits, 2, rng): + circuit.cz(j, k) if use_rzz: for j, k in _genpair(nqubits, 2, rng): circuit.rzz(j, k, np.pi / 4) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8265bc195..a950cbb63 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -25,4 +25,4 @@ qiskit>=1.0 qiskit-aer openqasm-parser>=3.1.0 -graphix-qasm-parser @ git+https://github.com/TeamGraphix/graphix-qasm-parser.git@revert-reqs +graphix-qasm-parser diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index 49bf07860..52f6d9908 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -42,7 +42,8 @@ def test_circuit_to_qasm3(fx_bg: PCG64, jumps: int) -> None: rng = Generator(fx_bg.jumped(jumps)) nqubits = 5 depth = 4 - check_round_trip(rand_circuit(nqubits, depth, rng)) + # See https://github.com/TeamGraphix/graphix-qasm-parser/pull/5 + check_round_trip(rand_circuit(nqubits, depth, rng, use_cz=False)) @pytest.mark.parametrize( @@ -52,7 +53,8 @@ def test_circuit_to_qasm3(fx_bg: PCG64, jumps: int) -> None: instruction.RZZ(target=0, control=1, angle=pi / 4), instruction.CNOT(target=0, control=1), instruction.SWAP(targets=(0, 1)), - instruction.CZ(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), From e516d751876cfdf0b83ba0ad7ddccd3cbf35b708 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Wed, 10 Dec 2025 17:16:07 +0100 Subject: [PATCH 18/21] updated CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6633c74..69ba6fe97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- #379: Added a new instruction `CZ` which can be added as a circuit gate using `circuit.cz`. + - #374: - Introduced new method `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs. - Added new method `isclose` to `graphix.fundamentals.AbstractMeasurement` which defaults to `==` comparison. @@ -20,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- #379: Removed unnecessary `meas_index` from API for rotation instructions `RZ`, `RY` and `RX`. + - #347: Adapted existing method `graphix.opengraph.OpenGraph.isclose` to the new API introduced in #358. - #349, #362: Patterns transpiled from circuits always have causal flow. From 781b41dc0492dfc685a49e3def41008feb40b9c6 Mon Sep 17 00:00:00 2001 From: Emlyn <42484330+emlynsg@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:41:08 +0100 Subject: [PATCH 19/21] Update CHANGELOG.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ba6fe97..ba91880e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #379: Added a new instruction `CZ` which can be added as a circuit gate using `circuit.cz`. -- #374: - Introduced new method `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs. - Added new method `isclose` to `graphix.fundamentals.AbstractMeasurement` which defaults to `==` comparison. From dd2a131b493ee41730e7b6f74703cb206b52eedd Mon Sep 17 00:00:00 2001 From: Emlyn <42484330+emlynsg@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:41:20 +0100 Subject: [PATCH 20/21] Update CHANGELOG.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba91880e9..073e4f4d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added + - #379: Added a new instruction `CZ` which can be added as a circuit gate using `circuit.cz`. - Introduced new method `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs. From 951a20f52986a656583952931be784548c784a15 Mon Sep 17 00:00:00 2001 From: Emlyn Graham Date: Tue, 16 Dec 2025 09:24:32 +0100 Subject: [PATCH 21/21] fixed readme --- CHANGELOG.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e7f7594..b3396d617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added + - #385 - Introduced `graphix.flow.core.XZCorrections.check_well_formed` which verifies the correctness of an XZ-corrections instance and raises an exception if incorrect. - Added XZ-correction exceptions to module `graphix.flow.core.exceptions`. @@ -18,16 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Introduced new module `graphix.flow.exceptions` grouping flow exceptions. - Introduced new methods `graphix.flow.core.PauliFlow.get_measurement_label` and `graphix.flow.core.GFlow.get_measurement_label` which return the measurement label of a given node following same criteria employed in the flow-finding algorithms. - - #379: Added a new instruction `CZ` which can be added as a circuit gate using `circuit.cz`. - - Introduced new method `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs. - Added new method `isclose` to `graphix.fundamentals.AbstractMeasurement` which defaults to `==` comparison. -- #383: Simulators are now parameterized by `PrepareMethod` (which - defaults to `DefaultPrepareMethod`) to customize how `N` commands are - handled, and the class `BaseN` can be used as a base class for - custom preparation commands. +- #383: Simulators are now parameterized by `PrepareMethod` (which defaults to `DefaultPrepareMethod`) to customize how `N` commands are handled, and the class `BaseN` can be used as a base class for custom preparation commands. ### Fixed