# models/distinction.py
"""Distinction: the maximally irreducible cause and effect specified by a
mechanism (Albantakis et al. 2023). The IIT 3.0 paper terminology calls
the same object a *concept*; the alias :data:`Concept` below preserves
that vocabulary for callers using the IIT 3.0 idiom."""
from __future__ import annotations
from functools import cached_property
from typing import Any
import numpy as np
from pyphi import numerics
from pyphi import validate
from pyphi.direction import Direction
from pyphi.display import Description
from pyphi.display import Displayable
from pyphi.display import Row
from pyphi.display import Section
from pyphi.display.mixin import FULL
from pyphi.display.numbers import format_value
from . import cmp
from .diff import ResultDiff
from .diff import _diff_common
from .diff import _mip_changed
from .explanation import Explanation
from .explanation import binding_direction_finding
from .pandas import ToDictFromExplicitAttrsMixin
from .pandas import ToPandasMixin
_distinction_attributes = [
"phi",
"mechanism",
"mechanism_state",
"mechanism_label",
"cause",
"effect",
]
# TODO: make mechanism a property
# TODO: make phi a property
[docs]
class Distinction(
Displayable, cmp.OrderableByPhi, ToDictFromExplicitAttrsMixin, ToPandasMixin
):
"""The maximally irreducible cause and effect specified by a mechanism.
These can be compared with the built-in Python comparison operators (``<``,
``>``, etc.). Comparison is by φ value (:meth:`order_by`).
Attributes
----------
mechanism : tuple[int]
The mechanism that the distinction consists of.
cause : MaximallyIrreducibleCause
The MIC representing the maximally-irreducible cause of this
distinction.
effect : MaximallyIrreducibleEffect
The MIE representing the maximally-irreducible effect of this
distinction.
"""
def __init__(
self,
mechanism=None,
cause=None,
effect=None,
):
self.mechanism = mechanism
self.cause = cause
self.effect = effect
# Attach references to this object on the cause and effect
# TODO: document this
assert self.cause is not None
assert self.effect is not None
self.cause.parent = self
self.effect.parent = self
@property
def effectively_tied(self) -> bool:
"""Whether any selection behind this distinction's cause or effect
(purview, mechanism partition, or specified state) is within
``config.numerics.precision`` of a tie."""
assert self.cause is not None
assert self.effect is not None
return self.cause.effectively_tied or self.effect.effectively_tied
def _describe(self, verbosity: int) -> Description:
cls = type(self).__name__
mechanism_label = getattr(self, "mechanism_label", None) or str(
getattr(self, "mechanism", "")
)
cause_phi = getattr(self.cause, "phi", None) if self.cause is not None else None
effect_phi = (
getattr(self.effect, "phi", None) if self.effect is not None else None
)
# Extract just the state tuple (not the full StateSpecification card)
_cause_spec = (
getattr(self.cause, "specified_state", None)
if self.cause is not None
else None
)
_effect_spec = (
getattr(self.effect, "specified_state", None)
if self.effect is not None
else None
)
cause_state = getattr(_cause_spec, "state", _cause_spec)
effect_state = getattr(_effect_spec, "state", _effect_spec)
def _margin_rows(mice) -> tuple[Row, ...]:
if verbosity < FULL or mice is None:
return ()
rows = []
if mice.purview_margin is not None:
rows.append(Row("Purview margin", mice.purview_margin))
if mice.state_margin is not None:
rows.append(Row("State margin", mice.state_margin))
return tuple(rows)
return Description(
title=cls,
sections=(
Section(
rows=(
Row("Mechanism", mechanism_label),
Row("φ_d", self.phi),
)
),
Section(
label="Cause",
tone="cause",
rows=(
Row("Purview", self.cause_purview_label),
Row("φ", cause_phi),
Row("Specified state", str(cause_state)),
*_margin_rows(self.cause),
),
),
Section(
label="Effect",
tone="effect",
rows=(
Row("Purview", self.effect_purview_label),
Row("φ", effect_phi),
Row("Specified state", str(effect_state)),
*_margin_rows(self.effect),
),
),
),
compact=f"{cls}({mechanism_label}, φ_d={format_value(self.phi)})",
)
# TODO use cached_property
@property
def phi(self) -> float: # type: ignore[override]
"""float: The size of the distinction.
This is the minimum of the φ values of the distinction's MIC and MIE.
"""
assert self.cause is not None
assert self.effect is not None
# numerics: exact — φ is the minimum of the MIC and MIE φ.
return min(self.cause.phi, self.effect.phi)
[docs]
def explain(self) -> Explanation:
"""A typed account of why this distinction's φ came out as it did:
which direction (cause or effect) binds, plus that direction's own
findings."""
assert self.cause is not None
assert self.effect is not None
binding = (
self.cause
if numerics.le(float(self.cause.phi), float(self.effect.phi))
else self.effect
)
findings = [
binding_direction_finding(self.cause.phi, self.effect.phi),
*binding.explain().findings,
]
return Explanation(
subject=f"φ = {format_value(self.phi)}",
level="mechanism",
findings=tuple(findings),
)
[docs]
def diff(self, other) -> ResultDiff:
"""Structured delta from this distinction to ``other`` (``a.diff(b)``).
A distinction carries no :class:`ConfigSnapshot`, so ``config_diff`` is
always empty.
"""
if not isinstance(other, Distinction):
raise TypeError(
f"cannot diff {type(self).__name__} against {type(other).__name__}"
)
common = _diff_common(self, other)
# A distinction has no partition of its own; the MIP comparison is
# per-direction over the underlying repertoire analyses.
assert self.cause is not None and self.effect is not None
assert other.cause is not None and other.effect is not None
mip_changed = _mip_changed(self.cause.ria, other.cause.ria) or _mip_changed(
self.effect.ria, other.effect.ria
)
return ResultDiff(
subject=f"Δφ = {format_value(common['delta_phi'])}",
level="mechanism",
delta_phi=common["delta_phi"],
mip_changed=mip_changed,
changes=(),
config_diff=common["config_diff"],
substrate_note=common["substrate_note"],
)
# TODO: rename?
def mice(self, direction):
if direction is Direction.CAUSE:
return self.cause
if direction is Direction.EFFECT:
return self.effect
validate.direction(direction)
return None
@property
def cause_purview(self):
"""tuple[int]: The cause purview."""
return getattr(self.cause, "purview", None)
@property
def effect_purview(self):
"""tuple[int]: The effect purview."""
return getattr(self.effect, "purview", None)
@property
def cause_purview_label(self):
"""str: The cause purview node labels, cased by the specified cause
state (see :meth:`pyphi.labels.NodeLabels.label_string`)."""
return getattr(self.cause, "purview_label", None) or str(self.cause_purview)
@property
def effect_purview_label(self):
"""str: The effect purview node labels, cased by the specified effect
state."""
return getattr(self.effect, "purview_label", None) or str(self.effect_purview)
@cached_property
def both_purview_unit_sets(self):
return [
set(self.mice(direction).purview_units) # type: ignore[union-attr]
for direction in Direction.both()
]
@cached_property
def purview_union(self):
return set.union(*self.both_purview_unit_sets)
@cached_property
def purview_intersection(self):
return set.intersection(*self.both_purview_unit_sets)
@property
def cause_repertoire(self):
"""np.ndarray: The cause repertoire."""
return getattr(self.cause, "repertoire", None)
@property
def effect_repertoire(self):
"""np.ndarray: The effect repertoire."""
return getattr(self.effect, "repertoire", None)
@property
def mechanism_state(self):
"""tuple(int): The state of this mechanism."""
assert self.cause is not None
assert self.effect is not None
if self.cause.mechanism_state != self.effect.mechanism_state:
raise ValueError("Inconsistent cause and effect mechanism states!")
return self.cause.mechanism_state
[docs]
@cached_property
def mechanism_label(self):
"""tuple[str]: The labels of the mechanism nodes."""
return self.node_labels.label_string(self.mechanism, self.mechanism_state) # type: ignore[arg-type]
[docs]
def purview(self, direction):
"""Return the purview in the given direction."""
assert self.cause is not None
assert self.effect is not None
if direction == Direction.CAUSE:
return self.cause.purview
if direction == Direction.EFFECT:
return self.effect.purview
raise ValueError("invalid direction")
@property
def node_labels(self):
assert self.cause is not None
assert self.effect is not None
if self.cause.node_labels != self.effect.node_labels:
raise ValueError("Inconsistent cause and effect node labels!")
return self.cause.node_labels
def _specified_state_keys(self) -> tuple:
"""Strict-equality key for the specified cause/effect purview states.
``None`` where a MICE (or its state specification) is absent, e.g.
on null distinctions.
"""
def key(mice: Any) -> tuple[int, ...] | None:
spec = getattr(mice, "specified_state", None) if mice is not None else None
state = getattr(spec, "state", None) if spec is not None else None
return None if state is None else tuple(int(unit) for unit in state)
return (key(self.cause), key(self.effect))
def __eq__(self, other: object) -> bool: # noqa: PLR0911
if not isinstance(other, Distinction):
return NotImplemented
if self.mechanism != other.mechanism:
return False
if self.mechanism_state != other.mechanism_state:
return False
if self.cause_purview != other.cause_purview:
return False
if self.effect_purview != other.effect_purview:
return False
# The specified purview states are part of a distinction's identity:
# two readings of the same purview specifying different states carry
# different cause-effect power (they support different relations and
# different structure Phi), matching the RIA layer, which compares
# and hashes its specified state.
if self._specified_state_keys() != other._specified_state_keys():
return False
if not numerics.eq(self.phi, other.phi):
return False
if not cmp.numpy_aware_eq(self.cause_repertoire, other.cause_repertoire):
return False
return cmp.numpy_aware_eq(self.effect_repertoire, other.effect_repertoire)
def __hash__(self) -> int:
# Hash uses only strict-equality attrs from __eq__; phi and repertoires
# are tolerance-compared in __eq__ so they cannot appear here without
# violating the a == b -> hash(a) == hash(b) contract.
return hash(
(
self.mechanism,
self.mechanism_state,
self.cause_purview,
self.effect_purview,
self._specified_state_keys(),
)
)
def __bool__(self):
"""A distinction is ``True`` if φ > 0."""
return numerics.is_positive(self.phi)
def is_congruent(self, system_state):
return all(
self.mice(direction).is_congruent(system_state[direction]) # type: ignore[union-attr]
for direction in Direction.both()
)
[docs]
def eq_repertoires(self, other):
"""Return whether this distinction has the same repertoires as another.
.. warning::
This only checks if the cause and effect repertoires are equal as
arrays; mechanisms, purviews, or even the nodes that the mechanism
and purview indices refer to, might be different.
"""
return np.array_equal(
self.cause_repertoire, # pyright: ignore[reportArgumentType]
other.cause_repertoire, # type: ignore[arg-type]
) and np.array_equal(
self.effect_repertoire, # pyright: ignore[reportArgumentType]
other.effect_repertoire, # type: ignore[arg-type]
)
[docs]
def emd_eq(self, other):
"""Return whether this distinction is equal to another in the context of
an EMD calculation.
"""
return (
# Structural identity for EMD grouping: bitwise-equal φ alongside
# identical mechanism and repertoires, not a magnitude selection.
# numerics: exact — structural identity, not a selection.
self.phi == other.phi
and self.mechanism == other.mechanism
and self.eq_repertoires(other)
)
_dict_attrs = _distinction_attributes
def _pandas_record(self):
# Structured data for analysis: node sets as plain label tuples and
# every state as its own column. Display formatting (state casing) is
# the card's job, not the DataFrame's — see ``_describe`` and
# ``distinction_table_row``.
labels = self.node_labels
def labelled(nodes):
if nodes is None:
return None
return (
tuple(labels.coerce_to_labels(nodes))
if labels is not None
else (tuple(nodes))
)
def specified_state(mice):
spec = getattr(mice, "specified_state", None)
state = getattr(spec, "state", None)
return None if state is None else tuple(state)
return {
"phi": float(self.phi),
"mechanism": labelled(self.mechanism),
"mechanism_state": (
None if self.mechanism_state is None else tuple(self.mechanism_state)
),
"cause_purview": labelled(self.cause_purview),
"cause_state": specified_state(self.cause),
"effect_purview": labelled(self.effect_purview),
"effect_state": specified_state(self.effect),
}
def __setstate__(self, state):
self.__dict__.update(state)
# Restore parent references to MICEs
assert self.cause is not None
assert self.effect is not None
self.cause.parent = self
self.effect.parent = self
# IIT 3.0 paper terminology calls a distinction a "concept". The alias
# preserves that vocabulary for callers using the IIT 3.0 idiom; the
# runtime class is identical.
Concept = Distinction