Source code for pyphi.formalism.queries

"""Formalism queries — operations whose math depends on the active formalism.

These are free functions taking a ``System`` as the first argument.
The kernel (``pyphi.core.repertoire_algebra``) holds pure repertoire math;
this module holds the operations whose definition is *formalism-policy*
(each version of IIT defines the MIP, MICE, and SIA differently).

The dispatch path is::

    queries.X(cs, ...)
        → FORMALISM_REGISTRY[config.formalism.iit.version].evaluate_X(cs, ...)
        → concrete formalism's algorithm

The kernel never imports this module.
"""

from __future__ import annotations

import contextlib
from typing import TYPE_CHECKING
from typing import Any

import numpy as np
from tqdm.auto import tqdm

from pyphi import conf as _conf
from pyphi import numerics
from pyphi import resolve_ties
from pyphi import utils as _utils
from pyphi import validate as _validate
from pyphi.conf import config
from pyphi.conf import fallback
from pyphi.core import repertoire_algebra as _ra
from pyphi.direction import Direction
from pyphi.models import Concept
from pyphi.models import MaximallyIrreducibleCause
from pyphi.models import MaximallyIrreducibleEffect
from pyphi.models import UnresolvedDistinctions
from pyphi.models import _null_ria
from pyphi.models.explanation import NullResultReason
from pyphi.parallel import map_reduce
from pyphi.partition import mechanism_partitions

from .base import FORMALISM_REGISTRY

if TYPE_CHECKING:
    from pyphi.system import System


def _never_shortcircuit(_result: Any) -> bool:
    """Predicate that never short-circuits a partition sweep.

    Distinct from :func:`pyphi.parallel.false`: backends collect results in
    completion order when no predicate is set, while an installed predicate
    preserves enumeration order, which MIP tie resolution requires.
    """
    return False


# ---- mechanism partition evaluation ----


[docs] def evaluate_partition( cs: System, direction: Direction, mechanism: tuple[int, ...], purview: tuple[int, ...], partition: Any, repertoire: Any = None, partitioned_repertoire: Any = None, partitioned_repertoire_kwargs: dict[str, Any] | None = None, **kwargs: Any, ) -> Any: """Evaluate a mechanism partition's φ. Dispatches to the active formalism's ``evaluate_mechanism_partition``. The caller is expected to thread ``mechanism_measure`` (and any other measure kwargs) through ``**kwargs``; the active formalism's method forwards them to its internal helper. """ formalism = FORMALISM_REGISTRY[config.formalism.iit.version] # pyright: ignore[reportAttributeAccessIssue] return formalism.evaluate_mechanism_partition( cs, direction, mechanism, purview, partition, repertoire=repertoire, partitioned_repertoire=partitioned_repertoire, partitioned_repertoire_kwargs=partitioned_repertoire_kwargs, **kwargs, )
def _partition_total( known: int | None, mechanism: tuple[int, ...], purview: tuple[int, ...] ) -> int: """The number of partitions a complete sweep would evaluate. ``known`` is the length of a caller-supplied partition list; ``None`` means the partitions came from the active scheme, whose count depends only on the mechanism and purview sizes and is memoized, so a lazily consumed sweep can still tell a complete pass from one the short-circuit truncated. """ if known is not None: return known from pyphi.cost import partition_sweep_count return partition_sweep_count(len(mechanism), len(purview)) def _find_mip_single_state( cs: System, specified_state: Any, direction: Direction, mechanism: tuple[int, ...], purview: tuple[int, ...], repertoire: Any, partitions: Any, parallel_kwargs: dict[str, Any], **kwargs: Any, ) -> Any: """Find the MIP for a single specified-state pin. Used by formalism MIP-search routines to evaluate all candidate mechanism partitions for a (state, direction, mechanism, purview) combination. """ # The sweep below stops at the first reducible partition, so the scheme's # partitions are consumed lazily and their construction stops with it. A # caller-supplied set is a concrete collection and is taken as given. if partitions is None: partitions = mechanism_partitions(mechanism, purview, cs.node_labels) n_partitions = None else: partitions = list(partitions) n_partitions = len(partitions) def _eval(partition: Any) -> Any: return evaluate_partition( cs, direction, mechanism, purview, partition, repertoire=repertoire, state=specified_state, **kwargs, ) candidate_mips = map_reduce( _eval, partitions, shortcircuit_func=( _utils.is_falsy if config.formalism.iit.shortcircuit_sia else _never_shortcircuit ), desc="Evaluating mechanism partitions", **parallel_kwargs, ) assert candidate_mips is not None, "map_reduce() should not return None" candidates = list(candidate_mips) ties = tuple( resolve_ties.partitions( candidates, # type: ignore[arg-type] default=_null_ria( direction, mechanism, purview, phi=0, specified_state=specified_state, node_labels=cs.node_labels, mechanism_state=_utils.state_of(mechanism, cs.state), ), ) ) for tie in ties: tie.set_partition_ties(ties) winner = ties[0] # The margin is only meaningful when every partition was evaluated: a # short-circuited sweep yields a truncated prefix whose gap says nothing # about the full partition set. others = [c for c in candidates if c is not winner] if ( others and winner.normalized_phi is not None and all(c.normalized_phi is not None for c in others) and len(candidates) == _partition_total(n_partitions, mechanism, purview) ): # numerics: exact — reported margin, not a selection. gap = min(float(c.normalized_phi) for c in others) - float(winner.normalized_phi) winner.partition_margin = max(0.0, gap) return winner # ---- mechanism MIP search ----
[docs] def find_mip( cs: System, direction: Direction, mechanism: tuple[int, ...], purview: tuple[int, ...], partitions: Any | None = None, state: Any | None = None, **kwargs: Any, ) -> Any: """Return the minimum partition for a mechanism over a purview.""" def null_mip(**kw: Any) -> Any: # noqa: ARG001 return _null_ria( direction, mechanism, purview, specified_state=state, node_labels=cs.node_labels, mechanism_state=_utils.state_of(mechanism, cs.state), ) if not purview: return null_mip(reasons=(NullResultReason.EMPTY_PURVIEW,)) rep = _ra.repertoire(cs, direction, mechanism, purview) if direction == Direction.CAUSE and np.all(rep == 0): return null_mip(reasons=(NullResultReason.UNREACHABLE_STATE,)) if partitions is not None: partitions = list(partitions) parallel_kwargs = _conf.parallel_kwargs( dict(config.infrastructure.parallel_mechanism_partition_evaluation), # pyright: ignore[reportAttributeAccessIssue] **kwargs, ) formalism = FORMALISM_REGISTRY[config.formalism.iit.version] # pyright: ignore[reportAttributeAccessIssue] return formalism._find_mechanism_mip( # pyright: ignore[reportAttributeAccessIssue] cs, direction, mechanism, purview, repertoire=rep, partitions=partitions, state=state, parallel_kwargs=parallel_kwargs, **kwargs, )
[docs] def cause_mip( cs: System, mechanism: tuple[int, ...], purview: tuple[int, ...], **kwargs: Any, ) -> Any: return find_mip(cs, Direction.CAUSE, mechanism, purview, **kwargs)
[docs] def effect_mip( cs: System, mechanism: tuple[int, ...], purview: tuple[int, ...], **kwargs: Any, ) -> Any: return find_mip(cs, Direction.EFFECT, mechanism, purview, **kwargs)
[docs] def phi_cause_mip( cs: System, mechanism: tuple[int, ...], purview: tuple[int, ...], **kwargs: Any, ) -> float: mip = cause_mip(cs, mechanism, purview, **kwargs) return mip.phi if mip else 0
[docs] def phi_effect_mip( cs: System, mechanism: tuple[int, ...], purview: tuple[int, ...], **kwargs: Any, ) -> float: mip = effect_mip(cs, mechanism, purview, **kwargs) return mip.phi if mip else 0
[docs] def phi( cs: System, mechanism: tuple[int, ...], purview: tuple[int, ...], **kwargs: Any, ) -> float: """Return the φ of a mechanism over a purview: the minimum of the cause and effect MIP φ values. The effect MIP is not evaluated when the cause MIP's φ is already 0, since φ values are non-negative.""" cause_phi = phi_cause_mip(cs, mechanism, purview, **kwargs) if not numerics.is_positive(float(cause_phi)): return cause_phi return min(cause_phi, phi_effect_mip(cs, mechanism, purview, **kwargs))
# ---- MICE / MIE search ----
[docs] def find_mice( cs: System, direction: Direction, mechanism: tuple[int, ...], purviews: Any | None = None, **kwargs: Any, ) -> Any: """Return the maximally irreducible cause or effect for a mechanism. The result is a :class:`~pyphi.models.MaximallyIrreducibleCause` or :class:`~pyphi.models.MaximallyIrreducibleEffect`. Over the given ``direction``, searches the candidate purviews for the one whose φ (irreducibility) is maximal, resolving ties via :mod:`pyphi.resolve_ties`. When ``config.infrastructure.validate_phi_bounds`` is set and a purview is found, the winning φ is checked against the distinction and partition upper bounds. """ purviews_list = _ra.potential_purviews(cs, direction, mechanism, purviews) if direction == Direction.CAUSE: mice_class = MaximallyIrreducibleCause elif direction == Direction.EFFECT: mice_class = MaximallyIrreducibleEffect else: _validate.direction(direction) mice_class = MaximallyIrreducibleCause # unreachable no_purviews = mice_class( _null_ria( direction, mechanism, (), reasons=(NullResultReason.NO_PURVIEWS,), node_labels=cs.node_labels, mechanism_state=_utils.state_of(mechanism, cs.state), ) ) if not purviews_list: return no_purviews def _find_mip(purview: tuple[int, ...]) -> Any: return find_mip(cs, direction, mechanism, purview) parallel_kwargs = _conf.parallel_kwargs( dict(config.infrastructure.parallel_purview_evaluation), # pyright: ignore[reportAttributeAccessIssue] **kwargs, ) mip_results = map_reduce( _find_mip, purviews_list, total=len(purviews_list), desc="Evaluating purviews", # Cost ~ repertoire state space over the purview (2**|purview|); # heterogeneous across purviews, cheap and exact. size_func=lambda purview: 2 ** len(purview), **parallel_kwargs, ) # Parallel evaluation returns results in completion / cost-bin order; # restore the canonical purview enumeration order so tie resolution # selects the same winner as sequential evaluation. order = {purview: index for index, purview in enumerate(purviews_list)} mip_results = sorted(mip_results, key=lambda ria: order[ria.purview]) all_mice = [mice_class(result) for result in mip_results] # type: ignore[arg-type] ties = tuple(resolve_ties.purviews(all_mice, default=no_purviews)) # type: ignore[arg-type] for tie in ties: tie.set_purview_ties(ties) winner = ties[0] # The purview sweep is always exhaustive, so the margin is exact # whenever a competing purview exists. other_mice = [m for m in all_mice if m is not winner] if other_mice: # numerics: exact — reported margin, not a selection. best_rival_phi = max(float(m.phi) for m in other_mice) # numerics: exact — reported margin, not a selection. winner.purview_margin = max(0.0, float(winner.phi) - best_rival_phi) if config.infrastructure.validate_phi_bounds and winner.purview: from pyphi.formalism.iit4 import bounds bounds.check_phi_bound( winner.phi, lambda: bounds.distinction_phi_upper_bound(mechanism, winner.purview), system=cs, label=f"MICE phi ({direction.name}, mechanism={mechanism}, " f"purview={winner.purview})", ) bounds.check_phi_bound( winner.phi, lambda: bounds.partition_phi_upper_bound(winner.partition), system=cs, label=f"MICE MIP phi ({direction.name}, mechanism={mechanism}, " f"purview={winner.purview})", ) return winner
[docs] def mic(cs: System, mechanism: tuple[int, ...], **kwargs: Any) -> Any: return find_mice(cs, Direction.CAUSE, mechanism, **kwargs)
[docs] def mie(cs: System, mechanism: tuple[int, ...], **kwargs: Any) -> Any: return find_mice(cs, Direction.EFFECT, mechanism, **kwargs)
[docs] def phi_max(cs: System, mechanism: tuple[int, ...]) -> float: # numerics: exact — φ is the minimum of the MIC and MIE φ. return min(mic(cs, mechanism).phi, mie(cs, mechanism).phi)
# ---- distinctions ---- # # IIT 4.0 paper terminology: the irreducible mechanism with cause-effect # power is a *distinction*. The IIT 3.0 *concept*, which has the same # mathematical role under that formalism, lives in :mod:`pyphi.formalism.iit3` # along with the rest of IIT 3.0's algorithms.
[docs] def distinction( cs: System, mechanism: tuple[int, ...], purviews: Any | None = None, cause_purviews: Any | None = None, effect_purviews: Any | None = None, **kwargs: Any, ) -> Any: """Return the distinction specified by a mechanism. A distinction pairs the mechanism's maximally irreducible cause (its MIC) with its maximally irreducible effect (its MIE). The empty mechanism specifies the null distinction. Parameters ---------- cs : System The system the mechanism belongs to. mechanism : tuple[int] The mechanism for which to determine the distinction. purviews : tuple[tuple[int]], optional A list of purviews to consider in both directions. cause_purviews : tuple[tuple[int]], optional A list of cause purviews to consider, overriding ``purviews``. effect_purviews : tuple[tuple[int]], optional A list of effect purviews to consider, overriding ``purviews``. Returns ------- Concept The distinction specified by the mechanism. Notes ----- When ``config.formalism.iit.shortcircuit_distinctions`` is set, evaluation stops as soon as the distinction is known to be reducible: if the effect direction has no candidate purviews, or once the cause MICE comes out with φ = 0, the remaining search is skipped and the unevaluated direction is a null MICE carrying the :attr:`~pyphi.models.explanation.NullResultReason.OTHER_DIRECTION_REDUCIBLE` reason. The distinction's φ (the minimum across directions) is unaffected. """ if not mechanism: return _ra.null_concept(cs) cause_purviews = cause_purviews if cause_purviews is not None else purviews effect_purviews = effect_purviews if effect_purviews is not None else purviews shortcircuit = config.formalism.iit.shortcircuit_distinctions # pyright: ignore[reportAttributeAccessIssue] if shortcircuit and not _ra.potential_purviews( cs, Direction.EFFECT, mechanism, effect_purviews ): # The effect side is trivially reducible, so the distinction's φ is 0 # no matter the cause; the cause search is skipped. effect = find_mice( cs, Direction.EFFECT, mechanism, purviews=effect_purviews, **kwargs ) cause = MaximallyIrreducibleCause( _null_ria( Direction.CAUSE, mechanism, (), reasons=(NullResultReason.OTHER_DIRECTION_REDUCIBLE,), node_labels=cs.node_labels, mechanism_state=_utils.state_of(mechanism, cs.state), ) ) return Concept(mechanism=mechanism, cause=cause, effect=effect) cause = find_mice(cs, Direction.CAUSE, mechanism, purviews=cause_purviews, **kwargs) if shortcircuit and not numerics.is_positive(float(cause.phi)): effect = MaximallyIrreducibleEffect( _null_ria( Direction.EFFECT, mechanism, (), reasons=(NullResultReason.OTHER_DIRECTION_REDUCIBLE,), node_labels=cs.node_labels, mechanism_state=_utils.state_of(mechanism, cs.state), ) ) return Concept(mechanism=mechanism, cause=cause, effect=effect) effect = find_mice( cs, Direction.EFFECT, mechanism, purviews=effect_purviews, **kwargs ) return Concept(mechanism=mechanism, cause=cause, effect=effect)
[docs] def all_distinctions(cs: System, **kwargs: Any) -> Any: # noqa: ARG001 """Iterate non-empty mechanisms and return the resulting Distinctions.""" mechanisms: Any = _utils.powerset(cs.node_indices, nonempty=True) total = 2 ** len(cs.node_indices) - 1 if fallback(config.infrastructure.progress_bars): with contextlib.suppress(TypeError): total = len(mechanisms) mechanisms = tqdm(mechanisms, total=total) distinctions = filter(None, (distinction(cs, mechanism) for mechanism in mechanisms)) # The active formalism's find_mice may return tied specified states # (IIT 4.0). Conservative default: return UnresolvedDistinctions; the # caller resolves against a SIA system_state if it needs to flow into # relations() or CauseEffectStructure. return UnresolvedDistinctions(distinctions)
# ---- system irreducibility ----
[docs] def sia(cs: System, **kwargs: Any) -> Any: """Run system irreducibility analysis via the active formalism.""" import time from pyphi.provenance import stamp_wall_time formalism = FORMALISM_REGISTRY[config.formalism.iit.version] # pyright: ignore[reportAttributeAccessIssue] start = time.perf_counter() result = formalism.evaluate_system(cs, **kwargs) return stamp_wall_time(result, time.perf_counter() - start)