# models/sia.py
"""IIT 3.0 ``SystemIrreducibilityAnalysis`` (system-level Φ result).
The system irreducibility analysis of IIT is the class of the same name in
:mod:`pyphi.formalism.iit4`. The class here is the result type that
:func:`pyphi.formalism.iit3.sia` produces, used to reproduce results
published under IIT 3.0 (see :doc:`/howto/earlier-versions`).
"""
from __future__ import annotations
from typing import Any
from pyphi import numerics
from pyphi.display import PROVENANCE
from pyphi.display import Description
from pyphi.display import Displayable
from pyphi.display import Row
from pyphi.display import Section
from pyphi.display.numbers import format_value
from pyphi.provenance import HasProvenance
from pyphi.serializable import Serializable
from . import cmp
from .diff import ResultDiff
from .diff import _diff_common
from .distinctions import Distinctions
from .distinctions import _null_ces
from .explanation import Explanation
from .explanation import Finding
from .pandas import ToPandasMixin
from .partitions import _cut_grid
from .partitions import concise_partition
# PyPhi 1.x attributes of the IIT 3.0 result that 2.0 renamed.
_RENAMED_ATTRIBUTES = {
"cut": "partition",
"ces": "distinctions",
"partitioned_ces": "partitioned_distinctions",
}
[docs]
class IIT3SystemIrreducibilityAnalysis(
HasProvenance, Displayable, cmp.OrderableByPhi, ToPandasMixin, Serializable
):
"""An analysis of system irreducibility (Φ).
Contains the Φ value of the :class:`~pyphi.system.System` and the
intermediate results obtained in the course of computing it.
These can be compared with the built-in Python comparison operators (``<``,
``>``, etc.). Ordering is by :meth:`order_by`: raw Φ values are compared
first, then the minimum-information partition's lexicographic key.
Precision-tolerant clustering is applied at decision sites by
:mod:`pyphi.resolve_ties`, not by these operators.
Attributes
----------
phi : float
The Φ value for the system, i.e. the distance between the
unpartitioned and partitioned cause-effect structures under the
minimum-information partition.
distinctions : Distinctions
The cause-effect structure of the unpartitioned system, computed in
the course of the Φ analysis. ``None`` when the analysis
short-circuited to zero Φ before the cause-effect structure was
computed (e.g. the system is not strongly connected); retrieve it via
:func:`pyphi.formalism.iit3.ces` in that case.
partitioned_distinctions : Distinctions
The cause-effect structure when the system is partitioned according to
the MIP.
partition : DirectedBipartition
The minimum-information partition.
node_indices : tuple[int, ...]
Indices of the nodes the analysis was computed over.
node_labels : NodeLabels
Labels corresponding to ``node_indices``.
current_state : tuple[int, ...]
The state of the system's units at the time of analysis, one entry
per unit in ``node_indices`` (not the state of the whole
substrate).
"""
phi: float # Override parent to allow None during init
def __init__(
self,
phi=None,
distinctions: Distinctions | None = None,
partitioned_distinctions=None,
partition=None,
node_indices=None,
node_labels=None,
current_state=None,
config=None,
provenance=None,
reasons=None,
runner_up=None,
):
if phi is None:
self.phi = phi # type: ignore[assignment]
else:
from pyphi.measures.distribution import DistanceResult
if isinstance(phi, DistanceResult):
self.phi = phi # type: ignore[assignment]
else:
self.phi = float(phi) # type: ignore[assignment]
self.distinctions = distinctions
self.partitioned_distinctions = partitioned_distinctions
self.partition = partition
self.node_indices = node_indices
self.node_labels = node_labels
self.current_state = current_state
self.reasons = reasons or []
self.runner_up = runner_up
if config is None:
from pyphi.conf import config as _global
config = _global.snapshot()
self.config = config
if provenance is None:
from pyphi.provenance import Provenance
provenance = Provenance.capture()
self.provenance = provenance
def _system_label(self) -> str | None:
node_indices = self.node_indices
node_labels = self.node_labels
if node_labels is not None and node_indices is not None:
return ",".join(
str(label) for label in node_labels.coerce_to_labels(node_indices)
)
if node_indices is not None:
return ",".join(str(i) for i in node_indices)
return None
def _pandas_record(self):
return {
"phi": float(self.phi),
"subset": None
if self.node_indices is None
else tuple(int(i) for i in self.node_indices),
"current_state": self.current_state,
"partition": concise_partition(self.partition)
if self.partition is not None
else None,
}
def _describe(self, verbosity: int) -> Description:
cls = type(self).__name__
sections = [
Section(
rows=(
Row("Φ", self.phi),
Row("System", self._system_label()),
Row("Current state", self.current_state),
)
)
]
if self.partition is not None:
mip_rows = (Row("Partition", concise_partition(self.partition)),)
mip_body = (
(_cut_grid(self.partition),)
if self.partition.num_connections_cut()
else ()
)
sections.append(Section(label="MIP", rows=mip_rows, body=mip_body))
if verbosity >= PROVENANCE and self.provenance is not None:
from pyphi.display.provenance import provenance_section
sections.append(provenance_section(self.provenance))
return Description(
title=cls,
sections=tuple(sections),
compact=f"{cls}(Φ={format_value(self.phi)})",
)
def _findings(self) -> tuple[Finding, ...]:
findings = [
Finding(kind="null_result", label="Null result", value=reason)
for reason in (self.reasons or [])
]
# The MIP is named whenever the runner-up gap is reported, since the
# gap is measured from it, even when Φ is 0.
if (
self.partition is not None
and self.phi is not None
and (bool(self) or self.runner_up is not None)
):
findings.append(
Finding(
kind="winning_partition",
label="MIP",
value=concise_partition(self.partition),
)
)
if self.runner_up is not None:
findings.append(
Finding(
kind="runner_up",
label="Runner-up partition",
value=concise_partition(self.runner_up.partition),
)
)
findings.append(
Finding(
kind="gap",
label="Φ-gap to runner-up",
value=float(self.runner_up.phi) - float(self.phi),
)
)
return tuple(findings)
[docs]
def explain(self) -> Explanation:
"""A typed account of why this Φ value came out as it did."""
return Explanation(
subject=f"Φ = {format_value(self.phi)}",
level="system",
findings=self._findings(),
)
[docs]
def diff(self, other) -> ResultDiff:
"""Structured delta from this SIA to ``other`` (``a.diff(b)``)."""
if not isinstance(other, IIT3SystemIrreducibilityAnalysis):
raise TypeError(
f"cannot diff {type(self).__name__} against {type(other).__name__}"
)
common = _diff_common(self, other)
return ResultDiff(
subject=f"ΔΦ = {format_value(common['delta_phi'])}",
level="system",
delta_phi=common["delta_phi"],
mip_changed=common["mip_changed"],
config_diff=common["config_diff"],
substrate_note=common["substrate_note"],
)
[docs]
def print(self):
"""Print this SystemIrreducibilityAnalysis."""
import sys
sys.stdout.write(str(self) + "\n")
def __eq__(self, other: object) -> bool: # noqa: PLR0911
if not isinstance(other, IIT3SystemIrreducibilityAnalysis):
return NotImplemented
if self.distinctions != other.distinctions:
return False
if self.partitioned_distinctions != other.partitioned_distinctions:
return False
if self.partition != other.partition:
return False
if self.node_indices != other.node_indices:
return False
if self.node_labels != other.node_labels:
return False
if self.current_state != other.current_state:
return False
return numerics.eq(self.phi, other.phi)
def __bool__(self):
"""A SystemIrreducibilityAnalysis is ``True`` if it has Φ > 0."""
return not numerics.eq(self.phi, 0)
def __hash__(self) -> int:
# ``distinctions`` is excluded: the compute path backfills it on tie
# peers after construction, and a hash must not change over an
# object's lifetime. ``__eq__`` still compares it; hashing only the
# stable fields it also compares keeps eq-implies-equal-hash.
return hash(
(
self.partitioned_distinctions,
self.partition,
self.node_indices,
self.node_labels,
self.current_state,
)
)
def __getattr__(self, name: str) -> Any:
# Called only when normal attribute lookup fails.
message = f"{type(self).__name__!r} object has no attribute {name!r}"
if name in _RENAMED_ATTRIBUTES:
message += f". PyPhi 2.0 renamed it `{_RENAMED_ATTRIBUTES[name]}`"
raise AttributeError(message)
[docs]
def order_by(self) -> tuple[Any, bytes]:
"""Sort key: ``(phi, partition.lex_key())``."""
if self.partition is None:
return (self.phi, b"")
return (self.phi, self.partition.lex_key())
@property
def ties(self) -> list[Any]:
"""The full tied set this SIA belongs to (winner first, peers after).
For an SIA without ties, returns a single-element list containing self.
Populated by ``set_ties`` after partition evaluation; preserved across
JSON round-trips via ``_tie_peers``.
"""
try:
return self._ties
except AttributeError:
self._ties = [self]
return self._ties
def set_ties(self, ties: list[Any]) -> None:
self._ties = list(ties)
def _null_sia(system, phi=0.0, reasons=None):
"""Return an IIT3SystemIrreducibilityAnalysis with zero phi.
This is the analysis result for a reducible system. ``reasons`` records
why (a list of :class:`~pyphi.models.explanation.NullResultReason`).
"""
return IIT3SystemIrreducibilityAnalysis(
phi=phi,
partitioned_distinctions=_null_ces(),
partition=system.partition,
node_indices=system.node_indices,
node_labels=system.substrate.node_labels,
current_state=system.proper_state,
reasons=reasons,
)