# models/ces.py
"""Cause-effect structure: distinctions + relations (Albantakis et al. 2023).
Albantakis et al. (2023) distinguish two terms:
- *Cause-effect structure* — the distinctions plus relations specified by
*any* candidate system (reducible or not).
- *Φ-structure* — the cause-effect structure of a *complex* (a maximally
irreducible substrate). The paper (p. 11) reserves the Greek-Φ
spelling for that complex-specific reading.
PyPhi exposes only :class:`CauseEffectStructure` as a runtime type;
the "this is a Φ-structure" reading is communicated by context (the
substrate that specified the CES is a maximal substrate, i.e. a
complex). The bag-of-distinctions side (without relations) is
:class:`pyphi.models.distinctions.Distinctions` — see that module's
docstring.
The algorithm that computes cause-effect structures is ``ces()`` in
:mod:`pyphi.formalism.iit4`.
Notes
-----
For results computed under an earlier version of IIT (see
:doc:`/howto/earlier-versions`): IIT 3.0 has no relations, so
``ces()`` in :mod:`pyphi.formalism.iit3` returns the distinctions only.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from dataclasses import field
from typing import TYPE_CHECKING
from typing import Any
from pyphi.display import FULL
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 import system_phi_label
from pyphi.display.numbers import format_value
from pyphi.display.tables import capped_table
from pyphi.provenance import HasProvenance
from pyphi.serializable import Serializable
from . import cmp
from .diff import Change
from .diff import ResultDiff
from .diff import _diff_common
from .distinctions import DISTINCTION_HEADER_TONES
from .distinctions import DISTINCTION_HEADERS
from .distinctions import ResolvedDistinctions
from .distinctions import distinction_table_row
from .pandas import ToPandasMixin
if TYPE_CHECKING:
from pyphi.relations import Relations
[docs]
@dataclass(frozen=True, eq=False, repr=False)
class CauseEffectStructure(
HasProvenance, Displayable, cmp.Orderable, ToPandasMixin, Serializable
):
"""A Φ-structure: SIA + distinctions + relations.
System-level quantities are reached through the wrapped
:attr:`sia`: the system integrated information value via ``ps.sia.phi``,
the system partition via ``ps.sia.partition``, and the specified system
state via ``ps.sia.system_state``.
"""
sia: Any # SystemIrreducibilityAnalysis from formalism.iit4
distinctions: ResolvedDistinctions
relations: Relations
config: Any = None # ConfigSnapshot from pyphi.conf.snapshot
provenance: Any = None # Provenance from pyphi.provenance
def __post_init__(self) -> None:
if self.config is None:
from pyphi.conf import config as _global
object.__setattr__(self, "config", _global.snapshot())
if self.provenance is None:
from pyphi.provenance import Provenance
object.__setattr__(self, "provenance", Provenance.capture())
@property
def components(self) -> Iterable[Any]:
yield from self.distinctions
# Relations is not iterable in base class but subclasses (ConcreteRelations) are
yield from list(self.relations) # pyright: ignore[reportArgumentType]
@property
def relation_closed(self) -> bool:
"""Whether every relation's relata are members of ``distinctions``.
True for complete structures and induced substructures; False for
folds, whose incident relations may reference distinctions outside
the seed set.
"""
return True
[docs]
def order_by(self) -> float:
return self.sia.phi
def __hash__(self) -> int:
return hash((self.sia, self.distinctions, self.relations))
def __bool__(self) -> bool:
return bool(self.sia)
def __eq__(self, other: object) -> bool:
# Exact-type equality: a view (InducedSubstructure, PhiFold) is not
# interchangeable with the structure it views — views cannot be
# saved, and a fold's relations are incident rather than closed —
# so views never compare equal to full structures (or to views of
# another kind), even when their contents coincide.
if type(other) is not type(self):
return NotImplemented
if self.sia != other.sia:
return False
if self.distinctions != other.distinctions:
return False
return self.relations == other.relations
def _to_pandas(self):
return self.distinctions.to_pandas()
def _describe(self, verbosity: int) -> Description:
cls = type(self).__name__
num_d = len(self.distinctions)
sum_phi_d = self.sum_phi_distinctions
num_r = (
self.relations.num_relations()
if hasattr(self.relations, "num_relations")
else None
)
sum_phi_r = (
self.relations.sum_phi() if hasattr(self.relations, "sum_phi") else None
)
sia_phi = getattr(self.sia, "phi", None)
phi_label = system_phi_label(self.config)
summary_rows = [
Row(phi_label, sia_phi),
Row("Distinctions", num_d),
Row("Σφ_d", sum_phi_d),
Row("Relations", num_r),
Row("Σφ_r", sum_phi_r),
]
if phi_label != "Φ" and sum_phi_r is not None:
# Under IIT 4.0, Φ is the structure integrated information — the
# sum of the two rows above it — and is distinct from φₛ.
summary_rows.insert(0, Row("Φ", sum_phi_d + sum_phi_r))
distinctions_body = (
capped_table(
DISTINCTION_HEADERS,
self.distinctions,
distinction_table_row,
total=num_d,
header_tones=DISTINCTION_HEADER_TONES,
),
)
sections = [
Section(rows=tuple(summary_rows)),
Section(label="Distinctions", body=distinctions_body),
]
if num_r:
from pyphi.relations import relations_table
table = relations_table(self.relations)
if table is not None:
sections.append(Section(label="Relations", body=(table,)))
if verbosity >= FULL and self.sia is not None:
# Embed the SIA's sections flat (the unlabeled summary becomes a
# "System irreducibility" section), matching how every other card
# embeds a sub-object — no nested boxes. Cap the embedded SIA at
# FULL so the CES card carries a single Provenance section (its
# own) rather than also surfacing the embedded SIA's.
sections.extend(
Section(
label=sec.label or "System irreducibility",
rows=sec.rows,
body=sec.body,
tone=sec.tone,
)
for sec in self.sia._describe(min(verbosity, FULL)).sections
)
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}({phi_label}={format_value(sia_phi)})",
)
@property
def sum_phi_relations(self):
return self.relations.sum_phi()
@property
def sum_phi_distinctions(self):
return self.distinctions.sum_phi()
@property
def big_phi(self):
"""float: Φ, the sum of distinction and relation φ."""
return self.sum_phi_distinctions + self.sum_phi_relations
def _resolve_members(self, items) -> list:
"""Resolve an iterable of distinctions or mechanism index-tuples to
this structure's own distinction objects, raising ``ValueError`` for
mechanisms not in the structure."""
from .distinction import Distinction
by_mechanism = {tuple(d.mechanism): d for d in self.distinctions}
members = []
for item in items:
mechanism = (
tuple(item.mechanism) # pyright: ignore[reportArgumentType] # Distinction.mechanism is an index tuple
if isinstance(item, Distinction)
else tuple(item)
)
if mechanism not in by_mechanism:
raise ValueError(
f"mechanism {mechanism} not in this cause-effect structure"
)
members.append(by_mechanism[mechanism])
return members
[docs]
def fold(self, distinctions) -> PhiFold:
"""Return the Φ-fold seeded by the given distinctions.
``distinctions`` is an iterable of :class:`Distinction` objects or
mechanism index-tuples drawn from this structure. The fold contains
those distinctions and every relation incident to at least one of
them.
"""
from pyphi.relations import AnalyticalRelations
from pyphi.relations import ConcreteRelations
from pyphi.relations import NullRelations
seeds = self._resolve_members(distinctions)
if isinstance(self.relations, NullRelations):
raise ValueError(
"folding requires relations; this cause-effect structure has "
"none (e.g. IIT 3.0)"
)
seed_set = set(seeds)
if isinstance(self.relations, ConcreteRelations):
incident = ConcreteRelations(
r for r in self.relations if not seed_set.isdisjoint(r)
)
elif isinstance(self.relations, AnalyticalRelations):
from pyphi.relations import AnalyticalFoldRelations
incident = AnalyticalFoldRelations(
self.distinctions, ResolvedDistinctions(seeds)
)
else:
raise TypeError(
f"cannot fold a structure with {type(self.relations).__name__} relations"
)
return PhiFold(
sia=self.sia,
distinctions=ResolvedDistinctions(seeds),
relations=incident,
config=self.config,
parent=self,
)
[docs]
def distinction_folds(self):
"""Yield the single-distinction Φ-fold of each distinction, in order."""
for distinction in self.distinctions:
yield self.fold([distinction])
[docs]
def distinction_importance(self):
"""Rank the distinctions by their additive contribution to Φ.
Each distinction's importance is its single-distinction Φ-fold
contribution: its own φ plus its share of each incident relation's
φ (``φ_r / |r|`` per bound seed). These contributions tile Φ —
summing over all distinctions recovers ``big_phi`` exactly.
Returns
-------
list[tuple[Distinction, float]]
``(distinction, contribution)`` pairs, sorted by descending
contribution; ties are broken by mechanism for determinism. The
removal cost of a distinction (everything its relations carry,
not just its share) is the ``big_phi`` of its fold:
``self.fold([distinction]).big_phi``.
"""
pairs = [
(distinction, fold.big_phi_contribution)
for distinction, fold in zip(
self.distinctions, self.distinction_folds(), strict=True
)
]
# numerics: exact — deterministic total order for a ranking display;
# selection among near-ties is the caller's concern.
return sorted(pairs, key=lambda pair: (-pair[1], tuple(pair[0].mechanism)))
[docs]
def induce(self, distinctions) -> InducedSubstructure:
"""Return the induced substructure on the given distinctions: those
distinctions plus exactly the relations whose relata are all among
them.
``distinctions`` is an iterable of :class:`Distinction` objects or
mechanism index-tuples drawn from this structure. Because a
relation's φ depends only on its relata, the induced relation set
equals what computing relations over the subset from scratch would
produce. The result is relation-closed (no dangling relata), so it
can be displayed, aggregated, and projected as a self-contained
object — but it is a view of this structure, not the cause-effect
structure of any system.
"""
from pyphi.relations import AnalyticalRelations
from pyphi.relations import ConcreteRelations
from pyphi.relations import NullRelations
members = self._resolve_members(distinctions)
member_set = set(members)
bag = ResolvedDistinctions(members)
if isinstance(self.relations, NullRelations):
relations = self.relations
elif isinstance(self.relations, ConcreteRelations):
relations = ConcreteRelations(
r for r in self.relations if member_set.issuperset(r)
)
elif isinstance(self.relations, AnalyticalRelations):
relations = AnalyticalRelations(bag)
else:
raise TypeError(
f"cannot induce a substructure of a structure with "
f"{type(self.relations).__name__} relations"
)
return InducedSubstructure(
sia=self.sia,
distinctions=bag,
relations=relations,
config=self.config,
parent=self,
)
def _check_same_frame(self, other: CauseEffectStructure) -> None:
"""Raise unless ``other`` has the same candidate-system node indices
and current state.
Value-based distinction identity is only meaningful within one
frame; combining structures across frames would silently produce
empty results instead of raising. A structure does not record its
substrate, so two structures from *different substrates* with
identical node indices and state cannot be detected here; for such
pairs the combination degrades safely to an empty intersection,
because distinction equality also compares purviews, φ, and
repertoires. The config snapshots are not compared.
"""
from pyphi.condensation import _sia_node_indices
mine = (
_sia_node_indices(self.sia),
getattr(self.sia, "current_state", None),
)
theirs = (
_sia_node_indices(other.sia),
getattr(other.sia, "current_state", None),
)
if mine != theirs:
raise ValueError(
"structures are not in the same frame: "
f"(node_indices, state) {mine} != {theirs}"
)
[docs]
def meet(self, other: CauseEffectStructure) -> InducedSubstructure:
"""The induced substructure on the distinctions common to both
structures (value equality).
Because a relation's φ depends only on its relata, the result's
relation set equals the intersection of the two structures'
relation sets. Requires both structures to be in the same frame;
raises ``ValueError`` otherwise. The result is a view of ``self``.
"""
self._check_same_frame(other)
common = set(self.distinctions) & set(other.distinctions)
return self.induce(d.mechanism for d in common)
[docs]
def relabel(self, mapping, node_labels=None) -> CauseEffectStructure:
"""Return this structure rewritten through the node-index bijection
``mapping``. See :func:`pyphi.relabel.relabel_ces`."""
from pyphi.relabel import relabel_ces
return relabel_ces(self, mapping, node_labels=node_labels)
def _changes(self, other) -> tuple[Change, ...]:
from pyphi import numerics
changes: list[Change] = []
a_by_mech = {d.mechanism: d for d in self.distinctions}
b_by_mech = {d.mechanism: d for d in other.distinctions}
changes.extend(
Change("distinction_lost", mech, a_value=a_by_mech[mech].phi)
for mech in a_by_mech.keys() - b_by_mech.keys()
)
changes.extend(
Change("distinction_gained", mech, b_value=b_by_mech[mech].phi)
for mech in b_by_mech.keys() - a_by_mech.keys()
)
for mech in a_by_mech.keys() & b_by_mech.keys():
da, db = a_by_mech[mech], b_by_mech[mech]
changed = (
not numerics.eq(float(da.phi), float(db.phi))
or da.cause.purview != db.cause.purview
or da.effect.purview != db.effect.purview
)
if changed:
changes.append(
Change("distinction_changed", mech, a_value=da.phi, b_value=db.phi)
)
a_rels, b_rels = self.relations, other.relations
if not numerics.eq(float(a_rels.sum_phi()), float(b_rels.sum_phi())):
changes.append(
Change(
"relation_sum_phi",
None,
a_value=a_rels.sum_phi(),
b_value=b_rels.sum_phi(),
)
)
if a_rels.num_relations() != b_rels.num_relations():
changes.append(
Change(
"relation_count",
None,
a_value=a_rels.num_relations(),
b_value=b_rels.num_relations(),
)
)
a_spec, b_spec = a_rels.degree_spectrum(), b_rels.degree_spectrum()
for degree in sorted(a_spec.keys() | b_spec.keys()):
av, bv = a_spec.get(degree), b_spec.get(degree)
same = (
av is not None
and bv is not None
and av[0] == bv[0]
and numerics.eq(av[1], bv[1])
)
if not same:
changes.append(Change("relation_degree", degree, a_value=av, b_value=bv))
from pyphi.relations import AnalyticalRelations
if not isinstance(a_rels, AnalyticalRelations) and not isinstance(
b_rels, AnalyticalRelations
):
a_set = set(a_rels) # pyright: ignore[reportArgumentType] # non-analytical relations are iterable
b_set = set(b_rels) # pyright: ignore[reportArgumentType] # non-analytical relations are iterable
changes.extend(
Change("relation_lost", tuple(sorted(r.mechanisms)), a_value=r.phi)
for r in a_set - b_set
)
changes.extend(
Change("relation_gained", tuple(sorted(r.mechanisms)), b_value=r.phi)
for r in b_set - a_set
)
return tuple(changes)
[docs]
def diff(self, other) -> ResultDiff:
"""Structured delta from this cause-effect structure to ``other``."""
if not isinstance(other, CauseEffectStructure):
raise TypeError(
f"cannot diff {type(self).__name__} against {type(other).__name__}"
)
common = _diff_common(self.sia, other.sia)
return ResultDiff(
subject=f"ΔΦ = {format_value(common['delta_phi'])}",
level="system",
delta_phi=common["delta_phi"],
mip_changed=common["mip_changed"],
changes=self._changes(other),
config_diff=(
self.config.diff(other.config) if self.config and other.config else {}
),
substrate_note=common["substrate_note"],
)
[docs]
@dataclass(frozen=True, eq=False, repr=False)
class StructureView(CauseEffectStructure):
"""A part of a cause-effect structure, carrying the structure it was
taken from as ``parent``. Concrete views are :class:`PhiFold`
(seeds + incident relations) and :class:`InducedSubstructure`
(distinction subset + the relations contained in it)."""
parent: CauseEffectStructure = field(kw_only=True)
[docs]
def save(self, target: Any, **kwargs: Any) -> None:
"""Views have no serialized form; save the parent structure."""
raise NotImplementedError(
f"{type(self).__name__} is a view into its parent structure and "
f"has no serialized form; save the parent CauseEffectStructure "
f"instead"
)
[docs]
@dataclass(frozen=True, eq=False, repr=False)
class InducedSubstructure(StructureView):
"""A relation-closed slice of a cause-effect structure: a subset of its
distinctions together with exactly the relations whose relata all
belong to the subset.
Every relation endpoint is present (``relation_closed`` is True), so
aggregation and projection treat it as self-contained. It is not the
cause-effect structure of any system: its distinctions were computed
and congruence-resolved in the parent structure's frame.
"""
[docs]
@dataclass(frozen=True, eq=False, repr=False)
class PhiFold(StructureView):
"""A slice of a cause-effect structure: a set of seed distinctions and
the relations incident to them.
``distinctions`` holds the seeds; ``relations`` holds every relation that
binds at least one seed; ``sia`` and ``config`` come from the structure the
fold was taken from, available as ``parent``. A fold is not a self-contained
cause-effect structure — its relations may reference distinctions outside
``distinctions`` — so it is not accepted by ``plot_ces``/``project_ces``;
use ``highlight_phi_fold`` to visualize it.
"""
@property
def relation_closed(self) -> bool:
"""False: incident relations may reference non-seed distinctions."""
return False
def _describe(self, verbosity: int) -> Description: # noqa: ARG002
cls = type(self).__name__
num_d = len(self.distinctions)
sum_phi_d = self.sum_phi_distinctions
num_r = (
self.relations.num_relations()
if hasattr(self.relations, "num_relations")
else None
)
sum_phi_r_contrib = self.sum_phi_relations_contribution
big_phi_contrib = self.big_phi_contribution
sia_phi = getattr(self.sia, "phi", None)
summary_rows = [
Row(system_phi_label(self.config), sia_phi),
Row("Seed distinctions", num_d),
Row("Σφ_d", sum_phi_d),
Row("Incident relations", num_r),
Row("Σφ_r (apportioned)", sum_phi_r_contrib),
Row("Φ_d (contribution)", big_phi_contrib),
]
distinctions_body = (
capped_table(
DISTINCTION_HEADERS,
self.distinctions,
distinction_table_row,
total=num_d,
header_tones=DISTINCTION_HEADER_TONES,
),
)
sections = [
Section(rows=tuple(summary_rows)),
Section(label="Seed distinctions", body=distinctions_body),
]
if num_r:
from pyphi.relations import relations_table
table = relations_table(self.relations)
if table is not None:
sections.append(Section(label="Incident relations", body=(table,)))
return Description(
title=cls,
sections=tuple(sections),
compact=f"{cls}(Φ_d={format_value(big_phi_contrib)})",
)
@property
def sum_phi_relations_contribution(self):
"""Σ over incident relations of ``φ_r · |r ∩ F| / |r|``, where ``F``
is the set of seed distinctions — the seeds' share of each incident
relation's φ.
"""
from pyphi.relations import AnalyticalFoldRelations
if isinstance(self.relations, AnalyticalFoldRelations):
return self.relations.share_weighted_sum_phi()
seeds = set(self.distinctions)
return sum(
relation.phi * len(seeds & set(relation)) / len(relation)
for relation in self.relations # pyright: ignore[reportGeneralTypeIssues] # Relations base lacks __iter__; folds hold ConcreteRelations here
)
@property
def big_phi_contribution(self):
"""The fold's additive contribution to the structure's Φ: the seed
distinctions' full φ plus the seeds' share of each incident
relation's φ (``φ_r · |r ∩ F| / |r|``). Summing this over the folds
of any partition of the structure's distinctions recovers
``big_phi``.
"""
return self.sum_phi_distinctions + self.sum_phi_relations_contribution