diff --git a/CHANGELOG.md b/CHANGELOG.md index 49160b5a..039e3d2f 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 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 104796fa..08deca83 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 540aab3d..f6b8b1e2 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 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 bb0955a5..bf2eb58c 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