diff --git a/CHANGELOG.md b/CHANGELOG.md index 039e3d2f..01542104 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 f6b8b1e2..93d235ec 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 bf2eb58c..f86eced4 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)