Search across grains#

A substrate’s cause-effect power need not be maximal at the grain of its smallest parts. The intrinsic-units search asks which units — at which spatial grouping and over which temporal window — are the ones that actually exist for a substrate in a given state. The search is combinatorial: it builds candidate macro units, assembles them into candidate systems, and compares those systems in an exclusion cascade. Because the number of candidates grows quickly, the first step of any real analysis is to pre-flight its cost. For the theory behind macro units and grains, see Macro units and grains; for a guided walkthrough, see the intrinsic-units tutorial.

The examples reproduce values from Marshall et al. (2026), which were computed before the system’s intrinsic information became part of the definition of \(\varphi_s\), so this page pins the version of IIT that paper used (see Reproduce results from earlier versions of IIT).

import numpy as np

import pyphi
from pyphi import config
from pyphi.conf import presets
from pyphi.macro.search import SearchBounds
from pyphi.substrate import Substrate

pyphi.config.progress_bars = False

# Reproduce the paper's values under the version of IIT it used.
config.iit = presets.iit4_2023["iit"]

The demos use a two-unit substrate. Each unit is nearly silent on its own and strongly ON only when both are already ON. The rows are the little-endian states 00, 10, 01, 11; the columns give each unit’s probability of being ON at the next update:

tpm = np.array(
    [
        [0.05, 0.05],
        [0.05, 0.06],
        [0.06, 0.05],
        [0.95, 0.95],
    ]
)
substrate = Substrate(tpm, node_labels=("A", "B"))

Pre-flight the cost#

SearchBounds.estimate counts the candidate systems a set of bounds would visit, without constructing a single macro TPM or computing any φₛ. Run it before the search itself:

bounds = SearchBounds()
estimate = bounds.estimate(substrate)
estimate.distinct_systems_upper_bound
8

The number of distinct candidate systems is the count to compare against the search’s own record of what it evaluated. Three flags qualify it:

  • is_exact is True only when the enumeration is not an upper bound but the exact number of candidates. That happens only at max_depth=0, where no macro grains are built and the count is a plain enumeration. With any macro levels the count is a worst case, so is_exact is False.

  • truncated is True if the counting itself hit its internal limit and stopped early, in which case the reported counts are lower bounds of the true bound.

  • partitions_capped is True if some candidate has more macro units than the partition-count cap, so the partition-sweep total covers only the counted candidates.

estimate.is_exact, estimate.truncated, estimate.partitions_capped
(False, False, False)

Every count is a worst case that assumes each candidate decomposition passes the intrinsic-unit criteria. The search can only do less work than the estimate, never more.

Supply a micro history for temporal grains#

A macro unit at update grain τ reads its constituents over τ micro updates, so its state is defined by a window of past states, not a single one. As soon as the bounds admit any grain above 1, the search needs a micro history rather than a bare state. Passing a bare state raises:

try:
    pyphi.analyze(substrate, (0, 0), grains=SearchBounds(max_update_grain=2))
except ValueError as error:
    print(error)
micro_history must be a sequence of 2 universe states (oldest first); got a bare state

Supply the history as a sequence of universe states, oldest first. Its required length is max_update_grain ** max_depth — here 2 ** 1 == 2:

temporal = pyphi.analyze(
    substrate, [(0, 0), (0, 0)], grains=SearchBounds(max_update_grain=2)
)
len(temporal.complexes)
1

Read the result#

pyphi.analyze with grains returns a ComplexesResult. Its complexes are the winners of the exclusion cascade, each a Complex with its micro footprint (node_indices), its φₛ, and its macro units. Iterate them to see which units won and at which grain (each unit’s update_grain is its temporal window):

for complex_ in result.complexes:
    grains = tuple(unit.update_grain for unit in complex_.units)
    print(complex_.node_indices, round(complex_.phi, 6), grains)
(0, 1) 0.788334 (1,)

Every winner reports its exclusion_margin — how far it finished ahead of the best rival it beat, in φₛ — and whether that margin is small enough to count as an effective tie at the configured precision:

winner = result.maximal_complex
round(winner.exclusion_margin, 6), winner.effectively_tied
(0.535037, False)

Each winner also records the candidates it excluded, in excluded. Here the winner is a coarse-graining of both units, and its strongest beaten rival is a blackboxing of the same two units:

rival = max(winner.excluded, key=lambda candidate: candidate.phi)
rival.node_indices, round(rival.phi, 6)
((0, 1), 0.253297)

In a condensation with several complexes, excluded can also hold candidates whose own φₛ is higher than the complex they appear under, kept out not by that complex but by a different one that overlapped their footprint. There is a single complex here, so that does not arise. Exclusion is recursive: an excluded candidate cannot in turn exclude anything. For how the cascade resolves overlapping candidates, see the recursive-exclusion tutorial.

records holds every candidate system the search considered, so its length is the realized version of the pre-flight estimate. Because a system’s φₛ is at most its intrinsic information, the search may skip some candidates’ partition sweeps; those records carry gated=True and an upper bound in place of an exact φₛ. (The version of IIT pinned on this page does not bound φₛ this way, so every record here is exact.) Here the lengths match: the worst case of eight candidate systems was reached exactly.

len(result.records), estimate.distinct_systems_upper_bound
(8, 8)

ties holds cliques of overlapping candidates that stayed tied even through Φ escalation and so failed exclusion — none of them is a complex. It is empty here:

result.ties
()

Parallelize#

The search evaluates candidate systems independently, so it parallelizes over them. Forward a parallel_kwargs dictionary through analyze and it reaches the candidate sweep:

result = pyphi.analyze(
    substrate,
    (0, 0),
    grains=True,
    parallel_kwargs={"parallel": True, "chunksize": 4},
)

For the parallel backend and its options, see Run computations in parallel.