Source code for pyphi.macro.system

"""MacroSystem: a system of macro units analyzed by the IIT pipeline.

``MacroSystem`` subclasses :class:`~pyphi.system.System` over a
synthetic macro-level :class:`~pyphi.substrate.Substrate` built from the
construction's effect TPM (all-ones connectivity, one binary node per
macro unit). The cause-side TPM properties are overridden with the
construction's cause TPM: the two directions differ in their treatment
of micro background units (Eqs. 33-34), so the cause TPM is not
derivable from the synthetic substrate. Everything else — nodes,
repertoires, partitions, ``sia``/``ces`` — is inherited unchanged, and
the pipeline consumes a ``MacroSystem`` exactly like a ``System``.

Once the macro TPMs are built there is no further reference to the
background units, the units' grains, or their micro constituents; macro
units are perturbed uniformly over their two states like any units.
"""

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from dataclasses import field
from functools import cached_property
from typing import Any

from pyphi.core.tpm.factored import FactoredTPM
from pyphi.macro.tpm import _normalize_history
from pyphi.macro.tpm import _validate_units
from pyphi.macro.tpm import macro_tpms
from pyphi.macro.units import MacroUnit
from pyphi.substrate import Substrate
from pyphi.system import System


def _macro_state(units, history):
    state = []
    for unit in units:
        window = tuple(
            tuple(s[u] for u in unit.micro_constituents)
            for s in history[len(history) - unit.micro_grain :]
        )
        state.append(unit.state_from(window))
    return tuple(state)


[docs] @dataclass(frozen=True, eq=False) class MacroSystem(System): """A system of macro units, consumed by the pipeline like a System. Construct with :meth:`from_micro`. The inherited ``substrate`` field holds the synthetic macro substrate; the micro universe lives in ``micro_substrate``. """ units: tuple[MacroUnit, ...] = () micro_substrate: Substrate | None = None micro_history: tuple[tuple[int, ...], ...] = () macro_cause_marginal: FactoredTPM | None = field(default=None, repr=False) def __post_init__(self) -> None: if self.micro_substrate is None or not self.units: raise TypeError("MacroSystem must be constructed via MacroSystem.from_micro") super().__post_init__()
[docs] @classmethod def from_micro( cls, substrate: Substrate, units, micro_history, node_labels=None, ) -> MacroSystem: """Build a MacroSystem from a micro substrate and macro units. Parameters ---------- substrate : Substrate The binary micro universe. units : sequence of MacroUnit The system's macro units (Eq. 19 must hold). micro_history : sequence of universe states Universe micro states, oldest first, of length ``max(tau_J)``. A bare state is accepted when every unit has micro grain 1. node_labels : optional Labels for the macro units. """ units = tuple(units) _validate_units(substrate, units) history = _normalize_history(units, substrate, micro_history) cause_marginal, effect_marginal = macro_tpms(substrate, units, history) macro_substrate = Substrate.from_factored( effect_marginal, node_labels=node_labels ) return cls( substrate=macro_substrate, state=_macro_state(units, history), units=units, micro_substrate=substrate, micro_history=history, macro_cause_marginal=cause_marginal, )
[docs] @classmethod def from_substrate(cls, *args: Any, **kwargs: Any) -> MacroSystem: # noqa: ARG003 raise TypeError( "MacroSystem cannot be built from a substrate alone; use " "MacroSystem.from_micro(substrate, units, micro_history)" )
@property def cause_marginal(self) -> FactoredTPM: # type: ignore[override] """The construction's cause TPM (Eqs. 26-40, cause weighting).""" assert self.macro_cause_marginal is not None return self.macro_cause_marginal @property def proper_cause_marginal(self) -> FactoredTPM: # type: ignore[override] """Identical to :attr:`cause_marginal`: there is no macro background.""" return self.cause_marginal def __eq__(self, other: object) -> bool: if not isinstance(other, MacroSystem): # A plain System over the macro substrate is a different analysis # (the macro construction overrides the cause TPM), so it is never # equal. Returning NotImplemented here would fall back to # System.__eq__, which compares only the shared fields and reports # equality with a different hash and a different phi. Python tries # the subclass's __eq__ first in both comparison directions, so # answering False here keeps the comparison symmetric. if isinstance(other, System): return False return NotImplemented return ( self.micro_substrate == other.micro_substrate and self.units == other.units and self.micro_history == other.micro_history and self.partition == other.partition ) def __hash__(self) -> int: return hash( ( self.micro_substrate, self.units, self.micro_history, self.partition, ) ) @cached_property def _fingerprint(self) -> bytes: # type: ignore[override] """blake2b-256 digest of the macro system's kernel inputs. The inherited :attr:`System._fingerprint` is unsound for a macro system: it hashes only the (effect-marginal) macro substrate and would conflate two groupings that share an effect marginal but differ in their cause marginal. This digests both derived marginals the kernel reads — the effect-side macro substrate and the overridden cause marginal (:attr:`macro_cause_marginal`) — plus the state, indices, and partition, so equal fingerprint implies identical repertoires. """ assert self.macro_cause_marginal is not None ccm = self.macro_cause_marginal h = hashlib.blake2b(digest_size=32) h.update(self.substrate._fingerprint) h.update(repr(ccm.alphabet_sizes).encode()) for i in range(ccm.n_nodes): h.update((ccm.factor(i) + 0.0).tobytes()) h.update(repr(tuple(self.state)).encode()) h.update(repr(tuple(self.node_indices)).encode()) h.update(repr(tuple(self.external_indices)).encode()) h.update(repr(tuple(sorted(self.partition.indices))).encode()) h.update(repr(sorted(self.partition.removed_edges())).encode()) return h.digest()