# models/cmp.py
"""Utilities for comparing phi-objects."""
import functools
import math
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Mapping
from typing import Any
from typing import TypeVar
import numpy as np
# Rich comparison (ordering) helpers
# =============================================================================
T = TypeVar("T")
[docs]
def sametype(func: Callable[[T, T], bool]) -> Callable[[T, object], bool | Any]:
"""Method decorator to return ``NotImplemented`` if the args of the wrapped
method are of different types.
When wrapping a rich model comparison method this will delegate (reflect)
the comparison to the right-hand-side object, or fallback by passing it up
the inheritance tree.
"""
@functools.wraps(func)
def wrapper(self: T, other: object) -> bool | Any: # pylint: disable=missing-docstring
if type(other) is not type(self):
return NotImplemented
return func(self, other) # type: ignore[arg-type]
return wrapper
[docs]
class Orderable:
"""Base mixin for implementing rich object comparisons on phi-objects.
Both ``__eq__`` and ``order_by`` need to be implemented on the subclass.
The ``order_by`` method returns a list of attributes which are compared
to implement the ordering.
Subclasses can optionally override ``is_orderable_with`` to enforce
constraints (for example, ``AcSystemIrreducibilityAnalysis`` requires
both operands to have the same ``direction``).
Ordering (``<``/``>``) compares ``order_by()`` values exactly, while ``__eq__`` on
phi-objects is typically tolerance-aware (see :func:`numpy_aware_eq`). Exact ordering
keeps selection (max/min/sort) deterministic — with genuine ties handled explicitly
by :mod:`pyphi.resolve_ties` — while tolerant equality absorbs float drift in
structural comparison. A consequence is that ``<=``/``>=`` (defined as strict
ordering *or* tolerant equality) can both hold for two objects whose values differ by
less than the equality tolerance.
"""
[docs]
def order_by(self) -> Any:
"""Return a list of values to compare for ordering.
The first value in the list has the greatest priority; if the first
objects are equal the second object is compared, etc.
"""
raise NotImplementedError
[docs]
def is_orderable_with(self, other: object) -> bool: # noqa: ARG002
"""Whether ``self`` and ``other`` are mutually orderable.
Default: any two instances are orderable. Override in subclasses
that need cross-instance guards.
"""
return True
def _check_orderable(self, other: object) -> None:
if not self.is_orderable_with(other):
raise TypeError(
f"Unorderable: {type(self).__name__} instances do not satisfy "
f"the orderability constraint of this type."
)
def __lt__(self, other: object) -> bool:
if not isinstance(other, Orderable):
return NotImplemented
self._check_orderable(other)
return self.order_by() < other.order_by()
def __le__(self, other: object) -> bool:
if not isinstance(other, Orderable):
return NotImplemented
self._check_orderable(other)
return self.order_by() < other.order_by() or self == other
def __gt__(self, other: object) -> bool:
if not isinstance(other, Orderable):
return NotImplemented
self._check_orderable(other)
return self.order_by() > other.order_by()
def __ge__(self, other: object) -> bool:
if not isinstance(other, Orderable):
return NotImplemented
self._check_orderable(other)
return self.order_by() > other.order_by() or self == other
def __eq__(self, other: object) -> bool:
raise NotImplementedError
def __ne__(self, other: object) -> bool:
return not self == other
[docs]
class OrderableByPhi(Orderable):
"""Mixin for implementing rich object comparisons on phi-objects that are
ordered solely by their phi values.
Inherits from Orderable.
"""
phi: float # Must be provided by subclass
[docs]
def order_by(self) -> Any:
return self.phi
# Equality helpers
# =============================================================================
EQUALITY_TOLERANCE = 1e-13
"""Tolerance for structural equality on IIT quantities. Absorbs op-order
drift in float64 arithmetic on IIT measures while distinguishing real
math regressions. Used by `numpy_aware_eq` (model `__eq__`) and by
golden-fixture comparisons in the test suite. Independent of
`config.numerics.precision`, which governs user-configurable phi
comparison via `numerics.eq`."""
[docs]
def numpy_aware_eq(a: Any, b: Any) -> bool: # noqa: PLR0911
"""Return whether two objects are equal via recursion, with float
leaves compared up to ``EQUALITY_TOLERANCE``.
Arrays compare via :func:`numpy.allclose`; float scalars via
:func:`math.isclose`; sets and frozensets by set equality; mappings by
key-set equality with values compared recursively; other ordered
iterables element-wise; remaining types via ``==``. Shape-mismatched or
non-numeric arrays compare unequal rather than raising.
"""
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
try:
if np.shape(a) != np.shape(b):
return False
return np.allclose(a, b, rtol=EQUALITY_TOLERANCE, atol=EQUALITY_TOLERANCE)
except (ValueError, TypeError):
return False
# Sets are unordered, so a positional ``zip`` is meaningless; compare by set
# equality (which delegates element comparison to the elements' own ``__eq__``).
if isinstance(a, (set, frozenset)) or isinstance(b, (set, frozenset)):
return a == b
# Mappings iterate over keys, so the positional ``zip`` below would compare
# keys only and ignore values entirely; compare keys by set equality and
# values recursively.
if isinstance(a, Mapping) or isinstance(b, Mapping):
if not (isinstance(a, Mapping) and isinstance(b, Mapping)):
return False
if set(a.keys()) != set(b.keys()):
return False
return all(numpy_aware_eq(value, b[key]) for key, value in a.items())
if (
(isinstance(a, Iterable) and isinstance(b, Iterable))
and not isinstance(a, str)
and not isinstance(b, str)
):
if len(a) != len(b): # type: ignore[arg-type]
return False
return all(numpy_aware_eq(x, y) for x, y in zip(a, b, strict=False))
if isinstance(a, (float, np.floating)) or isinstance(b, (float, np.floating)):
a_any: Any = a
b_any: Any = b
try:
return math.isclose(
float(a_any),
float(b_any),
rel_tol=EQUALITY_TOLERANCE,
abs_tol=EQUALITY_TOLERANCE,
)
except (TypeError, ValueError):
return False
return a == b