# models/explanation.py
"""Typed explanations of why a result came out as it did (``result.explain()``).
:class:`NullResultReason` enumerates the conditions under which an analysis
yields a trivial (Φ = 0 / α = 0) result. :class:`Finding` and
:class:`Explanation` are the typed account ``.explain()`` returns;
:class:`RunnerUp` is the lightweight record of the second-best partition
retained at MIP selection.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from enum import auto
from enum import unique
from typing import Any
from pyphi import numerics
from pyphi.display import Description
from pyphi.display import Displayable
from pyphi.display import Row
from pyphi.display import Section
[docs]
@unique
class NullResultReason(Enum):
"""A condition under which an analysis returns a trivial null result."""
# System level
NO_SYSTEM = auto()
NO_STRONG_CONNECTIVITY = auto()
NO_WEAK_CONNECTIVITY = auto()
MONAD_WITH_NO_SELFLOOP = auto()
MONAD_WITH_SELFLOOP_DEFINED_TO_BE_ZERO_PHI = auto()
NO_VALID_PARTITIONS = auto()
NO_CAUSE = auto()
NO_EFFECT = auto()
NONUNIQUE_SYSTEM_STATE = auto()
EMPTY_CAUSE_EFFECT_STRUCTURE = auto()
# Mechanism level
NO_PURVIEWS = auto()
NO_POSITIVE_ALPHA = auto()
NO_PARTITIONS = auto()
EMPTY_PURVIEW = auto()
UNREACHABLE_STATE = auto()
REDUCIBLE_OVER_PARTITION = auto()
OTHER_DIRECTION_REDUCIBLE = auto()
@property
def level(self) -> str:
"""The structural level the reason arises at: ``"system"`` or
``"mechanism"``."""
return _LEVEL_OF[self]
_MECHANISM_REASONS = frozenset(
{
NullResultReason.NO_PURVIEWS,
NullResultReason.NO_POSITIVE_ALPHA,
NullResultReason.NO_PARTITIONS,
NullResultReason.EMPTY_PURVIEW,
NullResultReason.UNREACHABLE_STATE,
NullResultReason.REDUCIBLE_OVER_PARTITION,
NullResultReason.OTHER_DIRECTION_REDUCIBLE,
}
)
_LEVEL_OF: dict[NullResultReason, str] = {
reason: ("mechanism" if reason in _MECHANISM_REASONS else "system")
for reason in NullResultReason
}
[docs]
@dataclass(frozen=True)
class RunnerUp:
"""The second-best partition at MIP selection.
The candidate ranked next after the MIP by the same quantity that
selected the MIP. ``partition`` is the cut; ``phi`` is its (clamped)
integrated information. ``normalized_phi`` is set only when the
ranking quantity was normalized φ.
"""
partition: Any
phi: Any
normalized_phi: Any = None
_NORMALIZED_STRATEGIES = frozenset({"NORMALIZED_PHI", "NEGATIVE_NORMALIZED_PHI"})
_PHI_VALUED_STRATEGIES = _NORMALIZED_STRATEGIES | {"PHI", "NEGATIVE_PHI"}
[docs]
def sia_runner_up_key(strategy: Any) -> tuple[Any, bool]:
"""The ranking key matching an SIA tie-resolution strategy.
Returns ``(key, normalized)``: ``key`` is the strategy function of the
first φ-valued component of ``strategy`` (so the runner-up is ranked by
the same quantity that selected the MIP), and ``normalized`` is whether
that quantity is normalized φ. Falls back to raw φ when no component is
φ-valued (e.g. a bare ``"NONE"`` or ``"PARTITION_LEX"``).
"""
from pyphi.resolve_ties import phi_object_tie_resolution_strategies
components = (strategy,) if isinstance(strategy, str) else tuple(strategy)
for component in components:
if component in _PHI_VALUED_STRATEGIES:
return (
phi_object_tie_resolution_strategies[component],
component in _NORMALIZED_STRATEGIES,
)
return (lambda m: m.phi), False
[docs]
def runner_up_from_candidates(
candidates: Any,
mip_value: Any,
key: Any = None,
normalized: bool = False,
) -> RunnerUp | None:
"""The candidate ranked next after the MIP under ``key``.
``key`` maps a candidate to the quantity minimized at MIP selection
(raw φ by default; pass the result of :func:`sia_runner_up_key` to
match a configured tie-resolution strategy). ``mip_value`` is the
MIP's own key value. Candidates that tie the MIP (within
:func:`pyphi.numerics.eq`) are tied peers, not runners-up, so they are
excluded. Returns ``None`` when the MIP's value is unique. Candidates
tied for next-best are ordered by ``partition.lex_key()`` so the choice
does not depend on iteration order. Each candidate must expose
``.partition`` and whatever ``key`` reads (``.phi`` by default).
"""
if key is None:
key = lambda c: c.phi # noqa: E731
mip = float(mip_value)
best = None
for candidate in candidates:
value = float(key(candidate))
if value <= mip or numerics.eq(value, mip):
continue # the MIP itself or a tied peer, not a runner-up
if best is None:
best = candidate
continue
best_value = float(key(best))
if numerics.eq(value, best_value):
if candidate.partition.lex_key() < best.partition.lex_key():
best = candidate
elif value < best_value:
best = candidate
if best is None:
return None
return RunnerUp(
partition=best.partition,
phi=best.phi,
normalized_phi=getattr(best, "normalized_phi", None) if normalized else None,
)
[docs]
@dataclass(frozen=True)
class Finding:
"""One element of an explanation.
``kind`` is a stable machine key (``"null_result"``, ``"winning_partition"``,
``"runner_up"``, ``"gap"``, ``"binding_direction"``, ...); ``label`` is the
human-readable summary; ``value`` is the quantity it concerns; ``detail``
holds optional supporting fields; ``tone`` is an optional semantic accent
(``"cause"`` / ``"effect"``) that HTML rendering colors.
"""
kind: str
label: str
value: Any = None
detail: tuple[tuple[str, Any], ...] = ()
tone: str | None = None
[docs]
def binding_direction_finding(cause_phi: Any, effect_phi: Any) -> Finding:
"""The Finding naming which direction binds ``min(φ_c, φ_e)``.
Reports ``"TIED"`` (with no display tone) when the two values are equal up
to ``config.numerics.precision``; otherwise ``"CAUSE"`` or ``"EFFECT"`` for
the strictly smaller side.
"""
if numerics.eq(float(cause_phi), float(effect_phi)):
value, tone = "TIED", None
elif float(cause_phi) < float(effect_phi):
value, tone = "CAUSE", "cause"
else:
value, tone = "EFFECT", "effect"
return Finding(
kind="binding_direction",
label="Binding direction",
value=value,
detail=(("φ_cause", cause_phi), ("φ_effect", effect_phi)),
tone=tone,
)
[docs]
def requirement_binding_finding(
phi: Any,
intrinsic_information: Any,
specification: Mapping[Any, Any],
differentiation: Mapping[Any, Any],
) -> Finding | None:
"""The Finding naming which term of the intrinsic-information requirement
set φₛ, or ``None`` when the requirement does not bind.
Under Mayner et al. (2026, Eq. 23), φₛ = min{φ_c, φ_e, ii(s)} with
ii(s) = min over directions of min(i_spec, i_diff) (Eq. 13). When φₛ
equals ii(s) up to ``config.numerics.precision``, the finding reports
the direction and the term (``"specification"`` or
``"differentiation"``) whose rectified value equals ii(s).
"""
if intrinsic_information is None or not numerics.eq(
float(phi), float(intrinsic_information)
):
return None
for direction in specification:
for term_name, values in (
("differentiation", differentiation),
("specification", specification),
):
value = values.get(direction)
if value is None:
continue
if numerics.eq(max(0.0, float(value)), float(intrinsic_information)):
tone = "cause" if direction.name == "CAUSE" else "effect"
return Finding(
kind="requirement_binding",
label="Intrinsic-information requirement binds",
value=term_name,
detail=(
("direction", direction.name),
("ii", float(intrinsic_information)),
("φ_s", float(phi)),
),
tone=tone,
)
return None
def _reason_value(value: Any) -> Any:
"""Render a :class:`NullResultReason` by its name; pass other values
through unchanged."""
return value.name if isinstance(value, NullResultReason) else value
[docs]
@dataclass(frozen=True)
class Explanation(Displayable):
"""A typed account of why a result came out as it did.
``subject`` names the quantity being explained (*e.g.* ``"Φ_s = 0.0"``);
``level`` is ``"system"`` or ``"mechanism"``; ``findings`` is the ordered
account.
"""
subject: str
level: str
findings: tuple[Finding, ...] = ()
def _describe(self, verbosity: int) -> Description: # noqa: ARG002
rows = tuple(
Row(finding.label, _reason_value(finding.value), tone=finding.tone)
for finding in self.findings
)
return Description(
title=self.subject,
sections=(Section(label="Why", rows=rows),),
compact=self.subject,
)
[docs]
def to_pandas(self):
"""A tidy ``DataFrame`` with one row per finding.
Columns: ``level``, ``kind``, ``label``, ``value``. A
:class:`NullResultReason` value renders by its enum name.
"""
from pyphi.models.pandas import records_to_frame
return records_to_frame(
(
{
"level": self.level,
"kind": finding.kind,
"label": finding.label,
"value": _reason_value(finding.value),
}
for finding in self.findings
),
columns=["level", "kind", "label", "value"],
)