Skip to content
23 changes: 23 additions & 0 deletions graphix/fundamentals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 35 additions & 10 deletions graphix/measurements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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))
Expand All @@ -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.
Expand Down
107 changes: 69 additions & 38 deletions graphix/opengraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -140,6 +114,65 @@ 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.

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())

def neighbors(self, nodes: Collection[int]) -> set[int]:
"""Return the set containing the neighborhood of a set of nodes in the open graph.

Expand Down Expand Up @@ -329,25 +362,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
-----
Expand All @@ -368,13 +398,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

Expand Down
15 changes: 15 additions & 0 deletions tests/test_fundamentals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
14 changes: 14 additions & 0 deletions tests/test_measurements.py
Original file line number Diff line number Diff line change
@@ -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)
Loading