Skip to content

Commit 347e312

Browse files
matulniEmlyn Graham
authored andcommitted
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.
1 parent 3ea4964 commit 347e312

File tree

7 files changed

+251
-44
lines changed

7 files changed

+251
-44
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## Unreleased
99

1010
### Added
11+
- #347:
12+
- Introduced new method `graphix.opengraph.OpenGraph.is_equal_structurally` which compares the underlying structure of two open graphs.
13+
- Added new method `isclose` to `graphix.fundamentals.AbstractMeasurement` which defaults to `==` comparison.
1114

1215
### Fixed
1316

1417
### Changed
1518

1619
## [0.3.3] - 2025-10-23
1720

21+
- #347: Adapted existing method `graphix.opengraph.OpenGraph.isclose` to the new API introduced in #358.
1822
### Added
1923

2024
- #343: Circuit exporter to OpenQASM3:

graphix/fundamentals.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,29 @@ def to_plane_or_axis(self) -> Plane | Axis:
235235
Plane | Axis
236236
"""
237237

238+
@abstractmethod
239+
def isclose(self, other: AbstractMeasurement, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool:
240+
"""Determine whether this measurement is close to another.
241+
242+
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.
243+
244+
Parameters
245+
----------
246+
other : AbstractMeasurement
247+
The measurement to compare against.
248+
rel_tol : float, optional
249+
Relative tolerance for determining closeness. Relevant for comparing angles in the `Measurement` subclass. Default is ``1e-9``.
250+
abs_tol : float, optional
251+
Absolute tolerance for determining closeness. Relevant for comparing angles in the `Measurement` subclass. Default is ``0.0``.
252+
253+
Returns
254+
-------
255+
bool
256+
``True`` if this measurement is considered close to ``other`` according
257+
to the subclass's comparison rules; ``False`` otherwise.
258+
"""
259+
return self == other
260+
238261

239262
class AbstractPlanarMeasurement(AbstractMeasurement):
240263
"""Abstract base class for planar measurement objects.

graphix/measurements.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
TypeAlias,
1212
)
1313

14+
# override introduced in Python 3.12
15+
from typing_extensions import override
16+
1417
from graphix import utils
15-
from graphix.fundamentals import AbstractPlanarMeasurement, Axis, Plane, Sign
18+
from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane, Sign
1619

1720
# Ruff suggests to move this import to a type-checking block, but dataclass requires it here
1821
from graphix.parameter import ExpressionOrFloat # noqa: TC001
@@ -44,7 +47,7 @@ class Measurement(AbstractPlanarMeasurement):
4447
4548
Attributes
4649
----------
47-
angle : Expressionor Float
50+
angle : ExpressionOrFloat
4851
The angle of the measurement in units of :math:`\pi`. Should be between [0, 2).
4952
plane : graphix.fundamentals.Plane
5053
The measurement plane.
@@ -53,11 +56,31 @@ class Measurement(AbstractPlanarMeasurement):
5356
angle: ExpressionOrFloat
5457
plane: Plane
5558

56-
def isclose(self, other: Measurement, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool:
57-
"""Compare if two measurements have the same plane and their angles are close.
59+
@override
60+
def isclose(self, other: AbstractMeasurement, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool:
61+
"""Determine whether two measurements are close in angle and share the same plane.
62+
63+
This method compares the angle of the current measurement with that of
64+
another measurement, using :func:`math.isclose` when both angles are floats.
65+
The planes must match exactly for the measurements to be considered close.
66+
67+
Parameters
68+
----------
69+
other : AbstractMeasurement
70+
The measurement to compare against.
71+
rel_tol : float, optional
72+
Relative tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``1e-9``.
73+
abs_tol : float, optional
74+
Absolute tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``0.0``.
5875
59-
Example
76+
Returns
6077
-------
78+
bool
79+
``True`` if both measurements lie in the same plane and their angles
80+
are equal or close within the given tolerances; ``False`` otherwise.
81+
82+
Examples
83+
--------
6184
>>> from graphix.measurements import Measurement
6285
>>> from graphix.fundamentals import Plane
6386
>>> 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
6891
False
6992
"""
7093
return (
71-
math.isclose(self.angle, other.angle, rel_tol=rel_tol, abs_tol=abs_tol)
72-
if isinstance(self.angle, float) and isinstance(other.angle, float)
73-
else self.angle == other.angle
74-
) and self.plane == other.plane
94+
isinstance(other, Measurement)
95+
and (
96+
math.isclose(self.angle, other.angle, rel_tol=rel_tol, abs_tol=abs_tol)
97+
if isinstance(self.angle, float) and isinstance(other.angle, float)
98+
else self.angle == other.angle
99+
)
100+
and self.plane == other.plane
101+
)
75102

76103
def to_plane_or_axis(self) -> Plane | Axis:
77104
"""Return the measurements's plane or axis.

graphix/opengraph.py

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -69,45 +69,19 @@ def __post_init__(self) -> None:
6969
outputs = set(self.output_nodes)
7070

7171
if not set(self.measurements).issubset(all_nodes):
72-
raise ValueError("All measured nodes must be part of the graph's nodes.")
72+
raise OpenGraphError("All measured nodes must be part of the graph's nodes.")
7373
if not inputs.issubset(all_nodes):
74-
raise ValueError("All input nodes must be part of the graph's nodes.")
74+
raise OpenGraphError("All input nodes must be part of the graph's nodes.")
7575
if not outputs.issubset(all_nodes):
76-
raise ValueError("All output nodes must be part of the graph's nodes.")
76+
raise OpenGraphError("All output nodes must be part of the graph's nodes.")
7777
if outputs & self.measurements.keys():
78-
raise ValueError("Output nodes cannot be measured.")
78+
raise OpenGraphError("Output nodes cannot be measured.")
7979
if all_nodes - outputs != self.measurements.keys():
80-
raise ValueError("All non-output nodes must be measured.")
80+
raise OpenGraphError("All non-output nodes must be measured.")
8181
if len(inputs) != len(self.input_nodes):
82-
raise ValueError("Input nodes contain duplicates.")
82+
raise OpenGraphError("Input nodes contain duplicates.")
8383
if len(outputs) != len(self.output_nodes):
84-
raise ValueError("Output nodes contain duplicates.")
85-
86-
# TODO: Up docstrings and generalise to any type
87-
def isclose(
88-
self: OpenGraph[Measurement], other: OpenGraph[Measurement], rel_tol: float = 1e-09, abs_tol: float = 0.0
89-
) -> bool:
90-
"""Return `True` if two open graphs implement approximately the same unitary operator.
91-
92-
Ensures the structure of the graphs are the same and all
93-
measurement angles are sufficiently close.
94-
95-
This doesn't check they are equal up to an isomorphism.
96-
97-
"""
98-
if not nx.utils.graphs_equal(self.graph, other.graph):
99-
return False
100-
101-
if self.input_nodes != other.input_nodes or self.output_nodes != other.output_nodes:
102-
return False
103-
104-
if set(self.measurements.keys()) != set(other.measurements.keys()):
105-
return False
106-
107-
return all(
108-
m.isclose(other.measurements[node], rel_tol=rel_tol, abs_tol=abs_tol)
109-
for node, m in self.measurements.items()
110-
)
84+
raise OpenGraphError("Output nodes contain duplicates.")
11185

11286
def to_pattern(self: OpenGraph[Measurement]) -> Pattern:
11387
"""Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists.
@@ -140,6 +114,63 @@ def to_pattern(self: OpenGraph[Measurement]) -> Pattern:
140114

141115
raise OpenGraphError("The open graph does not have flow. It does not support a deterministic pattern.")
142116

117+
def isclose(self, other: OpenGraph[_M_co], rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool:
118+
"""Check if two open graphs are equal within a given tolerance.
119+
120+
Parameters
121+
----------
122+
other : OpenGraph[_M_co]
123+
rel_tol : float
124+
Relative tolerance. Optional, defaults to ``1e-09``.
125+
abs_tol : float
126+
Absolute tolerance. Optional, defaults to ``0.0``.
127+
128+
Returns
129+
-------
130+
bool
131+
``True`` if the two open graphs are approximately equal.
132+
133+
Notes
134+
-----
135+
This method verifies the open graphs have:
136+
- Truly equal underlying graphs (not up to an isomorphism).
137+
- Equal input and output nodes.
138+
- Same measurement planes or axes and approximately equal measurement angles if the open graph is of parametric type `Measurement`.
139+
140+
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`.
141+
"""
142+
return self.is_equal_structurally(other) and all(
143+
m.isclose(other.measurements[node], rel_tol=rel_tol, abs_tol=abs_tol)
144+
for node, m in self.measurements.items()
145+
)
146+
147+
def is_equal_structurally(self, other: OpenGraph[AbstractMeasurement]) -> bool:
148+
"""Compare the underlying structure of two open graphs.
149+
150+
Parameters
151+
----------
152+
other : OpenGraph[AbstractMeasurement]
153+
154+
Returns
155+
-------
156+
bool
157+
``True`` if ``self`` and ``og`` have the same structure.
158+
159+
Notes
160+
-----
161+
This method verifies the open graphs have:
162+
- Truly equal underlying graphs (not up to an isomorphism).
163+
- Equal input and output nodes.
164+
It assumes the open graphs are well formed.
165+
166+
The static typer allows comparing the structure of two open graphs with different parametric type.
167+
"""
168+
return (
169+
nx.utils.graphs_equal(self.graph, other.graph)
170+
and self.input_nodes == other.input_nodes
171+
and other.output_nodes == other.output_nodes
172+
)
173+
143174
def neighbors(self, nodes: Collection[int]) -> set[int]:
144175
"""Return the set containing the neighborhood of a set of nodes in the open graph.
145176

tests/test_fundamentals.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,18 @@ def test_from_axes_ng(self) -> None:
161161
Plane.from_axes(Axis.Y, Axis.Y)
162162
with pytest.raises(ValueError):
163163
Plane.from_axes(Axis.Z, Axis.Z)
164+
165+
def test_isclose(self) -> None:
166+
for p1, p2 in itertools.combinations(Plane, 2):
167+
assert not p1.isclose(p2)
168+
169+
for a1, a2 in itertools.combinations(Axis, 2):
170+
assert not a1.isclose(a2)
171+
172+
for p in Plane:
173+
assert p.isclose(p)
174+
for a in Axis:
175+
assert not p.isclose(a)
176+
177+
for a in Axis:
178+
assert a.isclose(a)

tests/test_measurements.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
from graphix.fundamentals import Plane
4+
from graphix.measurements import Measurement
5+
6+
7+
class TestMeasurement:
8+
def test_isclose(self) -> None:
9+
m1 = Measurement(0.1, Plane.XY)
10+
m2 = Measurement(0.15, Plane.XY)
11+
12+
assert not m1.isclose(m2)
13+
assert not m1.isclose(Plane.XY)
14+
assert m1.isclose(m2, abs_tol=0.1)

tests/test_opengraph.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pytest
1414

1515
from graphix.command import E
16-
from graphix.fundamentals import Plane
16+
from graphix.fundamentals import Axis, Plane
1717
from graphix.measurements import Measurement
1818
from graphix.opengraph import OpenGraph, OpenGraphError
1919
from graphix.pattern import Pattern
@@ -633,8 +633,101 @@ def test_from_to_pattern(self, fx_rng: Generator) -> None:
633633
state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha))
634634
assert np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten())) == pytest.approx(1)
635635

636+
def test_isclose_measurement(self) -> None:
637+
og_1 = OpenGraph(
638+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
639+
input_nodes=[0],
640+
output_nodes=[3],
641+
measurements=dict.fromkeys(range(3), Measurement(0.1, Plane.XY)),
642+
)
643+
og_2 = OpenGraph(
644+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
645+
input_nodes=[0],
646+
output_nodes=[3],
647+
measurements=dict.fromkeys(range(3), Measurement(0.15, Plane.XY)),
648+
)
649+
og_3 = OpenGraph(
650+
graph=nx.Graph([(0, 1), (1, 2), (2, 3), (0, 3)]),
651+
input_nodes=[0],
652+
output_nodes=[3],
653+
measurements=dict.fromkeys(range(3), Measurement(0.15, Plane.XY)),
654+
)
655+
assert og_1.isclose(og_2, abs_tol=0.1)
656+
assert not og_1.isclose(og_2)
657+
assert not og_2.isclose(og_3)
658+
659+
def test_isclose_plane(self) -> None:
660+
og_1 = OpenGraph(
661+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
662+
input_nodes=[0],
663+
output_nodes=[3],
664+
measurements=dict.fromkeys(range(3), Plane.XY),
665+
)
666+
og_2 = OpenGraph(
667+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
668+
input_nodes=[0],
669+
output_nodes=[3],
670+
measurements=dict.fromkeys(range(3), Plane.XZ),
671+
)
672+
673+
assert not og_1.isclose(og_2)
674+
assert og_1.isclose(og_1)
675+
676+
def test_isclose_axis(self) -> None:
677+
og_1 = OpenGraph(
678+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
679+
input_nodes=[0],
680+
output_nodes=[3],
681+
measurements=dict.fromkeys(range(3), Axis.X),
682+
)
683+
og_2 = OpenGraph(
684+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
685+
input_nodes=[0],
686+
output_nodes=[3],
687+
measurements=dict.fromkeys(range(3), Axis.Y),
688+
)
689+
690+
assert not og_1.isclose(og_2)
691+
assert og_1.isclose(og_1)
692+
assert og_2.isclose(og_2)
693+
694+
def test_is_equal_structurally(self) -> None:
695+
og_1 = OpenGraph(
696+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
697+
input_nodes=[0],
698+
output_nodes=[3],
699+
measurements=dict.fromkeys(range(3), Measurement(0.15, Plane.XY)),
700+
)
701+
og_2 = OpenGraph(
702+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
703+
input_nodes=[0],
704+
output_nodes=[3],
705+
measurements=dict.fromkeys(range(3), Measurement(0.1, Plane.XY)),
706+
)
707+
og_3 = OpenGraph(
708+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
709+
input_nodes=[0],
710+
output_nodes=[3],
711+
measurements=dict.fromkeys(range(3), Plane.XY),
712+
)
713+
og_4 = OpenGraph(
714+
graph=nx.Graph([(0, 1), (1, 2), (2, 3)]),
715+
input_nodes=[0],
716+
output_nodes=[3],
717+
measurements=dict.fromkeys(range(3), Axis.X),
718+
)
719+
og_5 = OpenGraph(
720+
graph=nx.Graph([(0, 1), (1, 2), (2, 3), (0, 3)]),
721+
input_nodes=[0],
722+
output_nodes=[3],
723+
measurements=dict.fromkeys(range(3), Axis.X),
724+
)
725+
assert og_1.is_equal_structurally(og_2)
726+
assert og_1.is_equal_structurally(og_3)
727+
assert og_1.is_equal_structurally(og_4)
728+
assert not og_1.is_equal_structurally(og_5)
729+
636730

637-
# TODO: Add test `OpenGraph.is_close`
638731
# TODO: rewrite as parametric tests
639732

640733
# Tests composition of two graphs

0 commit comments

Comments
 (0)