From 45187eb70da9391b04a613647ab0f242a95f7073 Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 5 Nov 2025 17:28:03 +0100 Subject: [PATCH 1/6] Add OG comparison --- graphix/measurements.py | 2 +- graphix/opengraph.py | 93 +++++++++++++++++++++++++++++------------ tests/test_opengraph.py | 36 +++++++++++++++- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/graphix/measurements.py b/graphix/measurements.py index 104796fa..f1f41c2d 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -44,7 +44,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. diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 540aab3d..2b993033 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -83,32 +83,6 @@ def __post_init__(self) -> None: 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() - ) - 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 __eq__(self, other: object) -> bool: + """Check if two open graphs are equal. + + Parameters + ---------- + other : object + + Returns + ------- + bool + ``True`` if the two open graphs are 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 axis. It does not compare measurement angles (for that, see :func:`OpenGraph.isclose`). + """ + if isinstance(other, OpenGraph): + return _compare_opengraph_structure(self, other) and all( + m1.to_plane_or_axis() == m2.to_plane_or_axis() + for m1, m2 in zip(self.measurements.values(), other.measurements.values(), strict=False) + ) + + return False + + def isclose( + self: OpenGraph[Measurement], other: OpenGraph[Measurement], rel_tol: float = 1e-09, abs_tol: float = 0.0 + ) -> bool: + """Check if two open graphs of `Measurement` type are similar. + + Parameters + ---------- + other : OpenGraph[Measurement] + 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 and approximately equal measurement angles. + """ + return _compare_opengraph_structure(self, other) and all( + m.isclose(other.measurements[node], rel_tol=rel_tol, abs_tol=abs_tol) + for node, m in self.measurements.items() + ) + def neighbors(self, nodes: Collection[int]) -> set[int]: """Return the set containing the neighborhood of a set of nodes in the open graph. @@ -405,5 +436,15 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: return OpenGraph(g, inputs, outputs, measurements), mapping_complete +def _compare_opengraph_structure(og_1: OpenGraph[_M_co], og_2: OpenGraph[_M_co]) -> bool: + if not nx.utils.graphs_equal(og_1.graph, og_2.graph): + return False + + if og_1.input_nodes != og_2.input_nodes or og_1.output_nodes != og_2.output_nodes: + return False + + return set(og_1.measurements.keys()) == set(og_2.measurements.keys()) + + class OpenGraphError(Exception): """Exception subclass to handle open graphs errors.""" diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index bb0955a5..cfea8575 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,40 @@ 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_eq(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), Axis.X), + ) + assert og_1 == og_1 # noqa: PLR0124 + assert og_1 != og_2 + assert og_2 == og_2 # noqa: PLR0124 + + def test_isclose(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)), + ) + assert og_1.isclose(og_2, abs_tol=0.1) + assert not og_1.isclose(og_2) + -# TODO: Add test `OpenGraph.is_close` # TODO: rewrite as parametric tests # Tests composition of two graphs From 877be37fd7aa1e1edf8d9f9b93268841a225847f Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 25 Nov 2025 10:12:09 +0100 Subject: [PATCH 2/6] Add tests og compare --- graphix/opengraph.py | 14 ++++++++- tests/test_opengraph.py | 66 +++++++++++++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 2b993033..b4480456 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -136,7 +136,7 @@ def __eq__(self, other: object) -> bool: if isinstance(other, OpenGraph): return _compare_opengraph_structure(self, other) and all( m1.to_plane_or_axis() == m2.to_plane_or_axis() - for m1, m2 in zip(self.measurements.values(), other.measurements.values(), strict=False) + for m1, m2 in zip(self.measurements.values(), other.measurements.values(), strict=True) ) return False @@ -437,6 +437,18 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: def _compare_opengraph_structure(og_1: OpenGraph[_M_co], og_2: OpenGraph[_M_co]) -> bool: + """Compare the underlying structure of two open graphs. + + Parameters + ---------- + og_1 : OpenGraph[_M_co] + og_2 : OpenGraph[_M_co] + + Returns + ------- + bool + ``True`` if both open graphs have the same underlying structure. + """ if not nx.utils.graphs_equal(og_1.graph, og_2.graph): return False diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index cfea8575..6ddce5bb 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -15,13 +15,13 @@ from graphix.command import E from graphix.fundamentals import Axis, Plane from graphix.measurements import Measurement -from graphix.opengraph import OpenGraph, OpenGraphError +from graphix.opengraph import OpenGraph, OpenGraphError, _M_co from graphix.pattern import Pattern from graphix.random_objects import rand_circuit from graphix.states import PlanarState if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence from numpy.random import Generator @@ -633,19 +633,55 @@ 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_eq(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), Axis.X), - ) + @pytest.mark.parametrize( + "test_case", + [ + ( + OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Plane.XY), + ), + OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Axis.X), + ), + ), + ( + OpenGraph( + graph=nx.Graph([(0, 1)]), + input_nodes=[0], + output_nodes=[], + measurements=dict.fromkeys(range(2), Plane.XY), + ), + OpenGraph( + graph=nx.Graph([(0, 1)]), + input_nodes=[0, 1], + output_nodes=[], + measurements=dict.fromkeys(range(2), Plane.XY), + ), + ), + ( + OpenGraph( + graph=nx.Graph([(0, 1)]), + input_nodes=[0], + output_nodes=[1], + measurements={0: Measurement(0.6, Plane.XY)}, + ), + OpenGraph( + graph=nx.Graph([(0, 2)]), + input_nodes=[0], + output_nodes=[2], + measurements={0: Measurement(0.6, Plane.XY)}, + ), + ), + ], + ) + def test_eq(self, test_case: Sequence[tuple[OpenGraph[_M_co], OpenGraph[_M_co]]]) -> None: + og_1, og_2 = test_case assert og_1 == og_1 # noqa: PLR0124 assert og_1 != og_2 assert og_2 == og_2 # noqa: PLR0124 From b3c30042771bd6b28396f0e03337dac6a8ef68da Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 26 Nov 2025 17:39:43 +0100 Subject: [PATCH 3/6] Add comments Thierry's review --- graphix/opengraph.py | 55 ++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index b4480456..32a444ce 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -134,7 +134,7 @@ def __eq__(self, other: object) -> bool: - Same measurement planes or axis. It does not compare measurement angles (for that, see :func:`OpenGraph.isclose`). """ if isinstance(other, OpenGraph): - return _compare_opengraph_structure(self, other) and all( + return self.is_equal_structurally(other) and all( m1.to_plane_or_axis() == m2.to_plane_or_axis() for m1, m2 in zip(self.measurements.values(), other.measurements.values(), strict=True) ) @@ -144,7 +144,7 @@ def __eq__(self, other: object) -> bool: def isclose( self: OpenGraph[Measurement], other: OpenGraph[Measurement], rel_tol: float = 1e-09, abs_tol: float = 0.0 ) -> bool: - """Check if two open graphs of `Measurement` type are similar. + """Check if two open graphs of `Measurement` type are equal within a given tolerance. Parameters ---------- @@ -166,11 +166,38 @@ def isclose( - Equal input and output nodes. - Same measurement planes and approximately equal measurement angles. """ - return _compare_opengraph_structure(self, other) and all( + 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[_M_co]) -> bool: + """Compare the underlying structure of two open graphs. + + Parameters + ---------- + other : OpenGraph[_M_co] + + 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. + """ + if ( + not nx.utils.graphs_equal(self.graph, other.graph) + or self.input_nodes != other.input_nodes + or other.output_nodes != other.output_nodes + ): + return False + + return set(self.measurements.keys()) == set(other.measurements.keys()) + def neighbors(self, nodes: Collection[int]) -> set[int]: """Return the set containing the neighborhood of a set of nodes in the open graph. @@ -436,27 +463,5 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: return OpenGraph(g, inputs, outputs, measurements), mapping_complete -def _compare_opengraph_structure(og_1: OpenGraph[_M_co], og_2: OpenGraph[_M_co]) -> bool: - """Compare the underlying structure of two open graphs. - - Parameters - ---------- - og_1 : OpenGraph[_M_co] - og_2 : OpenGraph[_M_co] - - Returns - ------- - bool - ``True`` if both open graphs have the same underlying structure. - """ - if not nx.utils.graphs_equal(og_1.graph, og_2.graph): - return False - - if og_1.input_nodes != og_2.input_nodes or og_1.output_nodes != og_2.output_nodes: - return False - - return set(og_1.measurements.keys()) == set(og_2.measurements.keys()) - - class OpenGraphError(Exception): """Exception subclass to handle open graphs errors.""" From ecbbacbfa746929bd855e0f52ffb6aead52972a3 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 27 Nov 2025 10:21:48 +0100 Subject: [PATCH 4/6] Up meas is close and og comparison --- graphix/fundamentals.py | 23 +++++++ graphix/measurements.py | 43 +++++++++--- graphix/opengraph.py | 45 +++--------- tests/test_fundamentals.py | 15 ++++ tests/test_measurements.py | 14 ++++ tests/test_opengraph.py | 137 ++++++++++++++++++++++--------------- 6 files changed, 177 insertions(+), 100 deletions(-) create mode 100644 tests/test_measurements.py diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index 08e92fdd..4d4f2ca7 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 f1f41c2d..f6a7bd56 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 @@ -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)) @@ -67,11 +90,13 @@ def isclose(self, other: Measurement, rel_tol: float = 1e-09, abs_tol: float = 0 >>> Measurement(0.1, Plane.XY).isclose(Measurement(0.0, Plane.XY)) 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 + if isinstance(other, Measurement): + 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 + return False 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 32a444ce..24346e85 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -114,41 +114,12 @@ def to_pattern(self: OpenGraph[Measurement]) -> Pattern: raise OpenGraphError("The open graph does not have flow. It does not support a deterministic pattern.") - def __eq__(self, other: object) -> bool: - """Check if two open graphs are equal. + 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 : object - - Returns - ------- - bool - ``True`` if the two open graphs are 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 axis. It does not compare measurement angles (for that, see :func:`OpenGraph.isclose`). - """ - if isinstance(other, OpenGraph): - return self.is_equal_structurally(other) and all( - m1.to_plane_or_axis() == m2.to_plane_or_axis() - for m1, m2 in zip(self.measurements.values(), other.measurements.values(), strict=True) - ) - - return False - - def isclose( - self: OpenGraph[Measurement], other: OpenGraph[Measurement], rel_tol: float = 1e-09, abs_tol: float = 0.0 - ) -> bool: - """Check if two open graphs of `Measurement` type are equal within a given tolerance. - - Parameters - ---------- - other : OpenGraph[Measurement] + other : OpenGraph[_M_co] rel_tol : float Relative tolerance. Optional, defaults to ``1e-09``. abs_tol : float @@ -164,19 +135,21 @@ def isclose( This method verifies the open graphs have: - Truly equal underlying graphs (not up to an isomorphism). - Equal input and output nodes. - - Same measurement planes and approximately equal measurement angles. + - 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 comparing the structure of two open graphs with different parametric type. """ 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[_M_co]) -> bool: + def is_equal_structurally(self, other: OpenGraph[AbstractMeasurement]) -> bool: """Compare the underlying structure of two open graphs. Parameters ---------- - other : OpenGraph[_M_co] + other : OpenGraph[AbstractMeasurement] Returns ------- @@ -188,6 +161,8 @@ def is_equal_structurally(self, other: OpenGraph[_M_co]) -> bool: This method verifies the open graphs have: - Truly equal underlying graphs (not up to an isomorphism). - Equal input and output nodes. + + The static typer allows comparing the structure of two open graphs with different parametric type. """ if ( not nx.utils.graphs_equal(self.graph, other.graph) diff --git a/tests/test_fundamentals.py b/tests/test_fundamentals.py index a127a83e..74a81d4d 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 00000000..a4386b88 --- /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 6ddce5bb..bf2eb58c 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -15,13 +15,13 @@ from graphix.command import E from graphix.fundamentals import Axis, Plane from graphix.measurements import Measurement -from graphix.opengraph import OpenGraph, OpenGraphError, _M_co +from graphix.opengraph import OpenGraph, OpenGraphError from graphix.pattern import Pattern from graphix.random_objects import rand_circuit from graphix.states import PlanarState if TYPE_CHECKING: - from collections.abc import Callable, Sequence + from collections.abc import Callable from numpy.random import Generator @@ -633,60 +633,7 @@ 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) - @pytest.mark.parametrize( - "test_case", - [ - ( - OpenGraph( - graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), - input_nodes=[0], - output_nodes=[3], - measurements=dict.fromkeys(range(3), Plane.XY), - ), - OpenGraph( - graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), - input_nodes=[0], - output_nodes=[3], - measurements=dict.fromkeys(range(3), Axis.X), - ), - ), - ( - OpenGraph( - graph=nx.Graph([(0, 1)]), - input_nodes=[0], - output_nodes=[], - measurements=dict.fromkeys(range(2), Plane.XY), - ), - OpenGraph( - graph=nx.Graph([(0, 1)]), - input_nodes=[0, 1], - output_nodes=[], - measurements=dict.fromkeys(range(2), Plane.XY), - ), - ), - ( - OpenGraph( - graph=nx.Graph([(0, 1)]), - input_nodes=[0], - output_nodes=[1], - measurements={0: Measurement(0.6, Plane.XY)}, - ), - OpenGraph( - graph=nx.Graph([(0, 2)]), - input_nodes=[0], - output_nodes=[2], - measurements={0: Measurement(0.6, Plane.XY)}, - ), - ), - ], - ) - def test_eq(self, test_case: Sequence[tuple[OpenGraph[_M_co], OpenGraph[_M_co]]]) -> None: - og_1, og_2 = test_case - assert og_1 == og_1 # noqa: PLR0124 - assert og_1 != og_2 - assert og_2 == og_2 # noqa: PLR0124 - - def test_isclose(self) -> None: + def test_isclose_measurement(self) -> None: og_1 = OpenGraph( graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), input_nodes=[0], @@ -699,8 +646,86 @@ def test_isclose(self) -> None: 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: rewrite as parametric tests From 5ca8698a7569162ef1a0c17cf08a0ec504bf5f2e Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 27 Nov 2025 10:24:50 +0100 Subject: [PATCH 5/6] Fix docstring --- graphix/opengraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 24346e85..39f5cdf5 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -137,7 +137,7 @@ def isclose(self, other: OpenGraph[_M_co], rel_tol: float = 1e-09, abs_tol: floa - 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 comparing the structure of two open graphs with different parametric type. + 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) From 5cd51d61e955e21c5e0a1f7f5315b53cbff35e9b Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 1 Dec 2025 10:32:11 +0100 Subject: [PATCH 6/6] Up opengraph is_equal_structurally --- graphix/opengraph.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 39f5cdf5..f6b8b1e2 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -69,19 +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.") + raise OpenGraphError("Output nodes contain duplicates.") def to_pattern(self: OpenGraph[Measurement]) -> Pattern: """Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists. @@ -161,17 +161,15 @@ def is_equal_structurally(self, other: OpenGraph[AbstractMeasurement]) -> bool: 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. """ - if ( - not nx.utils.graphs_equal(self.graph, other.graph) - or self.input_nodes != other.input_nodes - or other.output_nodes != other.output_nodes - ): - return False - - return set(self.measurements.keys()) == set(other.measurements.keys()) + 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.