Source code for pyphi.models.distinction

# 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