Source code for pyphi.cost

"""Analytic workload counting for single-system analyses.

Counts the work a :func:`pyphi.analyze` call would perform — system
partitions swept by the system irreducibility analysis, candidate
mechanisms, connectivity-pruned purview evaluations, and mechanism
partitions per (mechanism, purview) pair — without computing any φ.
Counts are produced by driving the same enumeration machinery the
analysis uses under the active configuration, so the partition schemes,
the connectivity, and the alphabet are all reflected exactly.

Counts are turned into work units by weighting each axis by its measured
relative cost, so a unit is the same amount of work whatever mix of
purview evaluations and partition sweeps produces it. One further
constant, :data:`SECONDS_PER_UNIT`, converts units to CPU seconds on
reference hardware; :func:`units_for_runtime` inverts it, which is how a
per-shard runtime target becomes a ``units_per_job`` budget.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Any

from pyphi.display import Description
from pyphi.display import Displayable
from pyphi.display import Row
from pyphi.display import Section
from pyphi.models.pandas import ToPandasMixin

if TYPE_CHECKING:
    from pyphi.substrate import Substrate

__all__ = [
    "AnalysisEstimate",
    "MechanismWorkload",
    "estimate_analysis",
    "mechanism_workloads",
    "partition_sweep_count",
    "round_memory_bytes",
    "runtime_seconds",
    "shard_cache_budget_bytes",
    "shard_memory_bytes",
    "units_for_runtime",
]

_PARTITION_COUNT_CAP = 6

PURVIEW_EVALUATION_UNITS = 12
"""Work units one purview evaluation costs, taking one partition as the unit.

Before any partition is swept, a (mechanism, direction, purview) pair
computes its unpartitioned repertoire and searches the candidate specified
states. Regressing per-pair CPU time on the pair's partition count over
mechanism orders 1 to 6 and purview orders 1 to 3 puts that fixed cost at
about twelve partition evaluations (524 µs against 41.8 µs). Charging it as
one, as a plain operation count does, undercounts a scope whose purviews are
small enough that few partitions amortize it, by up to 2.5× on the smallest
pairs.

The measurement is in ``experiments/units_runtime_model``.
"""

SECONDS_PER_UNIT = 4.4e-5
"""CPU seconds one work unit costs on reference hardware.

Calibrated against per-shard CPU time over eleven shards spanning 16- and
21-unit Ising substrates, both payload kinds, 0.2 M to 20 M units, 1 to 66
distinct mechanisms packed, and cache ceilings from unlimited to fully
binding. Cost per unit held between 41 and 51 µs across all of them; the
widest departures are the largest shard (+15%, part of it plausibly
memory-bandwidth contention) and a starved cache (+12%).

Hardware differs, so treat this as the reference for
:func:`units_for_runtime` and re-derive it from a campaign's own recorded
metrics (``CampaignTaskOutput.metrics``) when planning against a hard
runtime deadline.
"""

REPERTOIRE_FACTOR = 4
"""Repertoires concurrently alive during a mechanism-partition sweep."""

BASE_MEMORY_BYTES = 1 << 30
"""Per-task overhead: interpreter, imports, substrate TPM, task payload."""

CACHE_HEADROOM_BYTES = 1 << 30
"""Memory a shard's request grants its repertoire caches.

A shard evaluates every mechanism it carries against one long-lived
``System``, whose cached repertoires are released only when that ``System``
is collected, so cache occupancy grows with the number of mechanisms packed
into a shard rather than with the size of any one repertoire. Granting the
allowance in the request and enforcing it during execution (see
:func:`shard_cache_budget_bytes`) bounds that growth whatever the shard
packs.
"""

_CACHE_RESERVE_BYTES = 256 * 1024**2

_MEMORY_STEP_BYTES = 512 * 1024**2


[docs] @dataclass(frozen=True) class MechanismWorkload: """One mechanism's scoped workload and peak-memory driver. ``units`` counts purview evaluations plus mechanism-partition sweeps; ``max_repertoire_cells`` is the state-space size of the largest scoped purview (the product of its units' state counts), which sets the mechanism's peak repertoire memory. """ units: int max_repertoire_cells: int
[docs] def shard_memory_bytes(max_repertoire_cells: int) -> int: """Estimated peak memory of a shard from its largest repertoire. ``REPERTOIRE_FACTOR × 8 bytes × max_repertoire_cells + BASE_MEMORY_BYTES + CACHE_HEADROOM_BYTES``. The factor and base are calibration constants; the headroom is the cache allowance that :func:`shard_cache_budget_bytes` enforces during execution. Requests derived from this estimate are rounded with :func:`round_memory_bytes`. """ return ( REPERTOIRE_FACTOR * 8 * max_repertoire_cells + BASE_MEMORY_BYTES + CACHE_HEADROOM_BYTES )
[docs] def shard_cache_budget_bytes(memory_bytes: int) -> int: """Cache ceiling for a shard whose memory request is ``memory_bytes``. The request less a reserve for the allocations in flight when the ceiling is reached, since the caches stop storing at the ceiling but the computation continues to allocate above it. """ return max(0, memory_bytes - _CACHE_RESERVE_BYTES)
[docs] def round_memory_bytes(n: int) -> int: """Round a byte count up to the next 512 MB request boundary.""" return max(1, math.ceil(n / _MEMORY_STEP_BYTES)) * _MEMORY_STEP_BYTES
[docs] def runtime_seconds(units: float) -> float: """Estimated CPU seconds to compute ``units`` work units. Examples -------- >>> round(runtime_seconds(1e6)) 44 """ return units * SECONDS_PER_UNIT
[docs] def units_for_runtime(seconds: float) -> float: """The ``units_per_job`` budget targeting ``seconds`` of CPU per shard. Pass the result to :func:`pyphi.campaign.prepare_ces` to plan shards against a runtime deadline rather than an abstract work count. The estimate holds while a shard's caches fit its memory request; a starved cache costs roughly a further 20%. Examples -------- >>> round(units_for_runtime(3600)) # a one-hour shard 81818182 """ return seconds / SECONDS_PER_UNIT
class _LimitReached(Exception): pass class _Counter: def __init__(self, limit: int) -> None: self.limit = limit self.spent = 0 def charge(self, amount: int) -> None: self.spent += amount if self.spent > self.limit: raise _LimitReached # Partition counts keyed by (system partition scheme name, m, # system_partition_include_total). Enumerating the partitions of m # elements is the same regardless of substrate, so the count is memoized # across calls at module scope. ``system_partition_include_total`` must # be part of the key: it changes the count returned by ``system_partitions`` # for m > 1 (the complete edge cut is included only when it's set), so # omitting it from the key would answer a query made under one setting with # a count memoized under the other. The default scheme's counts are seeded # for the default ``system_partition_include_total=False``, from direct # enumeration of ``system_partitions``; the seed-verification tests # re-enumerate them. _PARTITION_COUNT_MEMO: dict[tuple[str, int, bool], int] = { ("DIRECTED_SET_PARTITION", 1, False): 1, ("DIRECTED_SET_PARTITION", 2, False): 3, ("DIRECTED_SET_PARTITION", 3, False): 22, ("DIRECTED_SET_PARTITION", 4, False): 150, ("DIRECTED_SET_PARTITION", 5, False): 1_061, ("DIRECTED_SET_PARTITION", 6, False): 7_896, ("DIRECTED_SET_PARTITION", 7, False): 61_888, ("DIRECTED_SET_PARTITION", 8, False): 510_313, ("DIRECTED_SET_PARTITION", 9, False): 4_419_572, } # Mechanism-partition counts keyed by (mechanism partition scheme name, # |mechanism|, |purview|); the count depends only on the two sizes. The # default scheme's counts are seeded from direct enumeration of # ``mechanism_partitions``; the seed-verification tests re-enumerate them. _MECHANISM_PARTITION_COUNT_MEMO: dict[tuple[str, int, int], int] = { ("JOINT_PARTITION_ALL", 1, 1): 1, ("JOINT_PARTITION_ALL", 1, 2): 1, ("JOINT_PARTITION_ALL", 1, 3): 1, ("JOINT_PARTITION_ALL", 1, 4): 1, ("JOINT_PARTITION_ALL", 1, 5): 1, ("JOINT_PARTITION_ALL", 1, 6): 1, ("JOINT_PARTITION_ALL", 1, 7): 1, ("JOINT_PARTITION_ALL", 2, 1): 3, ("JOINT_PARTITION_ALL", 2, 2): 9, ("JOINT_PARTITION_ALL", 2, 3): 27, ("JOINT_PARTITION_ALL", 2, 4): 81, ("JOINT_PARTITION_ALL", 2, 5): 243, ("JOINT_PARTITION_ALL", 2, 6): 729, ("JOINT_PARTITION_ALL", 2, 7): 2_187, ("JOINT_PARTITION_ALL", 3, 1): 7, ("JOINT_PARTITION_ALL", 3, 2): 31, ("JOINT_PARTITION_ALL", 3, 3): 121, ("JOINT_PARTITION_ALL", 3, 4): 451, ("JOINT_PARTITION_ALL", 3, 5): 1_657, ("JOINT_PARTITION_ALL", 3, 6): 6_091, ("JOINT_PARTITION_ALL", 3, 7): 22_561, ("JOINT_PARTITION_ALL", 4, 1): 15, ("JOINT_PARTITION_ALL", 4, 2): 93, ("JOINT_PARTITION_ALL", 4, 3): 459, ("JOINT_PARTITION_ALL", 4, 4): 2_085, ("JOINT_PARTITION_ALL", 4, 5): 9_195, ("JOINT_PARTITION_ALL", 4, 6): 40_293, ("JOINT_PARTITION_ALL", 4, 7): 177_339, ("JOINT_PARTITION_ALL", 5, 1): 31, ("JOINT_PARTITION_ALL", 5, 2): 271, ("JOINT_PARTITION_ALL", 5, 3): 1_681, ("JOINT_PARTITION_ALL", 5, 4): 9_211, ("JOINT_PARTITION_ALL", 5, 5): 48_001, ("JOINT_PARTITION_ALL", 5, 6): 245_491, ("JOINT_PARTITION_ALL", 5, 7): 1_251_001, ("JOINT_PARTITION_ALL", 6, 1): 63, ("JOINT_PARTITION_ALL", 6, 2): 789, ("JOINT_PARTITION_ALL", 6, 3): 6_147, ("JOINT_PARTITION_ALL", 6, 4): 40_341, ("JOINT_PARTITION_ALL", 6, 5): 245_523, ("JOINT_PARTITION_ALL", 6, 6): 1_444_149, ("JOINT_PARTITION_ALL", 6, 7): 8_379_987, ("JOINT_PARTITION_ALL", 7, 1): 127, ("JOINT_PARTITION_ALL", 7, 2): 2_311, ("JOINT_PARTITION_ALL", 7, 3): 22_681, ("JOINT_PARTITION_ALL", 7, 4): 177_451, ("JOINT_PARTITION_ALL", 7, 5): 1_251_097, ("JOINT_PARTITION_ALL", 7, 6): 8_380_051, ("JOINT_PARTITION_ALL", 7, 7): 54_762_961, } def _partition_counts(ms) -> dict[int, int]: """System-partition counts per unit count, for m up to the cap.""" from pyphi.conf import config from pyphi.partition import system_partitions scheme = config.formalism.iit.system_partition_scheme include_total = config.formalism.iit.system_partition_include_total counts = {} for m in ms: if m > _PARTITION_COUNT_CAP: continue key = (scheme, m, include_total) count = _PARTITION_COUNT_MEMO.get(key) if count is None: count = sum(1 for _ in system_partitions(tuple(range(m)))) _PARTITION_COUNT_MEMO[key] = count counts[m] = count return counts def _system_partition_count(m: int, counter: _Counter) -> int: """Count the system partitions of ``m`` units under the active scheme. A memoized count is free; a fresh enumeration charges the counter one unit per partition, so an unmemoized (scheme, size) pair cannot exceed the walk's work budget. """ from pyphi.conf import config from pyphi.partition import system_partitions scheme = config.formalism.iit.system_partition_scheme include_total = config.formalism.iit.system_partition_include_total key = (scheme, m, include_total) count = _PARTITION_COUNT_MEMO.get(key) if count is None: count = 0 for _ in system_partitions(tuple(range(m))): counter.charge(1) count += 1 _PARTITION_COUNT_MEMO[key] = count return count def _mechanism_partition_count(msize: int, psize: int, counter: _Counter) -> int: """Count the mechanism partitions of a (``msize``, ``psize``) pair under the active scheme, with the same budget behavior as :func:`_system_partition_count`. """ from pyphi.conf import config from pyphi.partition import mechanism_partitions scheme = config.formalism.iit.mechanism_partition_scheme key = (scheme, msize, psize) count = _MECHANISM_PARTITION_COUNT_MEMO.get(key) if count is None: count = 0 mechanism = tuple(range(msize)) purview = tuple(range(msize, msize + psize)) for _ in mechanism_partitions(mechanism, purview): counter.charge(1) count += 1 _MECHANISM_PARTITION_COUNT_MEMO[key] = count return count def _fmt(value: int) -> str: """Format a count for display; huge counts as a power of ten.""" if value < 10**15: return f"{value:,}" return f"~10^{len(str(value)) - 1}"
[docs] @dataclass(frozen=True) class AnalysisEstimate(Displayable, ToPandasMixin): """The workload of a single-system analysis, before running it. Work axes are counted by driving the analysis's own enumeration machinery under the active configuration. ``None`` marks an axis outside the estimate's scope: excluded by ``compute``, not applicable under the active configuration, or not reached before the work budget (``capped=True``). Attributes ---------- n_units : int Number of units in the candidate system. state_space_size : int Product of the candidate units' alphabet sizes — the scale of one repertoire evaluation. Reported as a weight, never multiplied into the counts. compute : str ``"full"``, ``"sia"``, ``"ces"``, or ``"distinctions"``. system_partitions : int or None Partitions the system irreducibility analysis sweeps, under the active system partition scheme. Counted for a ``"ces"`` analysis, whose cause-effect structure embeds a system irreducibility analysis. specified_state_evaluations : int or None Forward-repertoire evaluations the specified-state search performs (Albantakis et al. 2023, Eqs. 12-13). The search maximizes intrinsic information over the whole system as both mechanism and purview, so it evaluates one repertoire per system state per direction: twice the state space, each evaluation over an array of that same size. Unlike the other axes this one grows with the size of the system rather than of any mechanism. Counted for a ``"sia"``, ``"ces"``, or full analysis. A ``"distinctions"`` analysis performs no search, though filtering those distinctions for congruence (:meth:`~pyphi.system.System.distinctions` with ``congruent=True``) performs one. mechanisms : int or None Candidate mechanisms: 2ⁿ − 1 for n units. purview_evaluations : int or None Connectivity-pruned (mechanism, direction, purview) triples — the repertoire-computation axis. mechanism_partition_sweeps : int or None Mechanism partitions summed over all counted triples, under the active mechanism partition scheme — the dominant cost of unfolding a cause-effect structure. relations_closed_form : bool or None Whether the active relation backend computes relations in closed form (``ANALYTICAL``) rather than by enumeration (``CONCRETE``). ``None`` when relations are outside the estimate's scope. possible_distinctions : int or None Candidate distinctions (2ⁿ − 1) — the size ceiling of the cause-effect structure. Present only for binary units. possible_relations : int or None Candidate relations (2^(2ⁿ−1) − 1) — the size ceiling of the relation set, and the enumeration worst case when ``relations_closed_form`` is ``False``. Present only for binary units. capped : bool The counting walk hit its work budget; walked counts are lower bounds (rendered with a ``≥`` qualifier) and axes never reached are ``None``. Notes ----- For an estimate made under an earlier version of IIT (see :doc:`/howto/earlier-versions`): IIT 3.0 has no specified state and its cause-effect structure is the bare set of distinctions, so under it ``specified_state_evaluations``, ``relations_closed_form``, ``possible_distinctions``, and ``possible_relations`` are ``None``, and ``system_partitions`` is not counted for a ``"ces"`` analysis. """ n_units: int state_space_size: int compute: str system_partitions: int | None specified_state_evaluations: int | None mechanisms: int | None purview_evaluations: int | None mechanism_partition_sweeps: int | None relations_closed_form: bool | None possible_distinctions: int | None possible_relations: int | None capped: bool @property def distinction_units(self) -> int | None: """Work units on the distinction axis, or ``None`` if not counted. The weighted sum :func:`mechanism_workloads` charges — purview evaluations at :data:`PURVIEW_EVALUATION_UNITS` each plus every mechanism partition — so :func:`runtime_seconds` applies to it. The system-partition axis is excluded: its cost per partition has not been calibrated against this unit. """ if self.purview_evaluations is None or self.mechanism_partition_sweeps is None: return None return ( PURVIEW_EVALUATION_UNITS * self.purview_evaluations + self.mechanism_partition_sweeps ) def _qualifier(self) -> str: return "≥" if self.capped else "=" def _pandas_record(self) -> dict: return { "n_units": self.n_units, "state_space_size": self.state_space_size, "compute": self.compute, "system_partitions": self.system_partitions, "specified_state_evaluations": self.specified_state_evaluations, "mechanisms": self.mechanisms, "purview_evaluations": self.purview_evaluations, "mechanism_partition_sweeps": self.mechanism_partition_sweeps, "relations_closed_form": self.relations_closed_form, "possible_distinctions": self.possible_distinctions, "possible_relations": self.possible_relations, "capped": self.capped, } def _describe(self, verbosity: int) -> Description: # noqa: ARG002 q = self._qualifier() rows = [ Row("Units", str(self.n_units)), Row("State space", _fmt(self.state_space_size)), ] if self.system_partitions is not None: rows.append(Row("System partitions", f"{q} {_fmt(self.system_partitions)}")) if self.specified_state_evaluations is not None: rows.append( Row( "Specified-state evaluations", _fmt(self.specified_state_evaluations), ) ) if self.mechanisms is not None: rows.append(Row("Mechanisms", _fmt(self.mechanisms))) if self.purview_evaluations is not None: rows.append( Row("Purview evaluations", f"{q} {_fmt(self.purview_evaluations)}") ) if self.mechanism_partition_sweeps is not None: rows.append( Row( "Mechanism partition sweeps", f"{q} {_fmt(self.mechanism_partition_sweeps)}", ) ) if self.relations_closed_form is not None: rows.append( Row( "Relations", "closed form" if self.relations_closed_form else "enumerated", ) ) if self.possible_distinctions is not None: rows.append(Row("Possible distinctions", _fmt(self.possible_distinctions))) if self.possible_relations is not None: rows.append(Row("Possible relations", _fmt(self.possible_relations))) rows.append(Row("Capped", self.capped)) return Description( title="AnalysisEstimate", subtitle=f"{self.n_units} units, {self.compute}", sections=(Section(rows=tuple(rows)),), compact=( f"AnalysisEstimate(n_units={self.n_units}, compute={self.compute!r})" ), )
[docs] def estimate_analysis( substrate: Substrate, *, subset: Any = None, compute: str | None = None, limit: int = 1_000_000, scope: Any | None = None, ) -> AnalysisEstimate: """Count the workload of a single-system analysis, without running it. Drives the same enumeration machinery :func:`pyphi.analyze` would use under the active configuration: the system partition scheme, the connectivity-pruned purview sets, and the mechanism partition scheme. No φ is computed and no state is needed — every counted quantity is state-independent. Parameters ---------- substrate : Substrate The substrate to analyze. subset : optional Node indices (or labels) of the candidate system; ``None`` uses the whole substrate. compute : str or None, optional ``None`` estimates the full analysis; ``"sia"`` only the system-partition axis; ``"distinctions"`` only the distinction axis; ``"ces"`` the distinction axis plus the system-partition axis, since unfolding a cause-effect structure computes a system irreducibility analysis first. limit : int, optional Work budget for the counting walk itself: purview evaluations and fresh partition enumerations each cost one unit, while memoized partition counts are free. A walk that exceeds the budget stops immediately and reports ``capped=True``. scope : :class:`~pyphi.campaign.scope.CESScope`, optional Restrict the counted mechanisms and purviews to the scope's feasibility surface. Affects only the distinction axis; the system-partition count and the structural ceilings (``possible_distinctions``, ``possible_relations``) are properties of the full system. Returns ------- AnalysisEstimate The counted workload. Raises ------ ValueError If ``compute`` is not ``"sia"``, ``"ces"``, ``"distinctions"``, or ``None``. Examples -------- >>> from pyphi import examples >>> est = estimate_analysis(examples.basic_substrate()) >>> est.mechanisms 7 >>> est.system_partitions 22 """ if compute not in (None, "sia", "ces", "distinctions"): raise ValueError( f"unknown compute: {compute!r}; expected 'sia', 'ces', " "'distinctions', or None for the full analysis" ) from pyphi import utils from pyphi.conf import config from pyphi.direction import Direction from pyphi.system import System cs = System.from_substrate(substrate, (0,) * substrate.size, subset) indices = cs.node_indices m = len(indices) alphabet = substrate.factored_tpm.alphabet_sizes state_space_size = 1 for i in indices: state_space_size *= int(alphabet[i]) unit_scope = scope scope = "full" if compute is None else compute version = config.formalism.iit.version # Under IIT 4.0 a cause-effect structure embeds its own system # irreducibility analysis (Eq. 58), so it pays the system-partition axis # too; under IIT 3.0 the structure is the bare distinctions. Only # ``"distinctions"`` skips that axis under every formalism. counts_system_partitions = scope in ("full", "sia") or ( scope == "ces" and version.startswith("IIT_4_0") ) counts_distinctions = scope in ("full", "ces", "distinctions") # The specified-state search runs wherever a system irreducibility # analysis does, and only under IIT 4.0. Its cost follows from the state # space alone — one forward repertoire per system state per direction — # so no enumeration walk is needed and the work budget never caps it. specified_state_evaluations = ( 2 * state_space_size if counts_system_partitions and version.startswith("IIT_4_0") else None ) counter = _Counter(limit) capped = False system_partition_count = None mechanisms = None purview_evaluations = None sweeps = None try: if counts_system_partitions: system_partition_count = _system_partition_count(m, counter) if counts_distinctions: mechanism_iter: Any = utils.powerset(indices, nonempty=True) if unit_scope is not None: mechanism_iter = unit_scope.mechanisms.select(mechanism_iter) mechanisms = 0 purview_evaluations = 0 sweeps = 0 for mechanism in mechanism_iter: mechanisms += 1 for direction in (Direction.CAUSE, Direction.EFFECT): if unit_scope is not None: axis = unit_scope.purview_axis(direction, mechanism) purviews = list( axis.select( cs.potential_purviews( direction, mechanism, max_order=axis.order_bound() ) ) ) else: purviews = cs.potential_purviews(direction, mechanism) for purview in purviews: counter.charge(1) purview_evaluations += 1 sweeps += _mechanism_partition_count( len(mechanism), len(purview), counter ) except _LimitReached: capped = True relations_closed_form = None possible_distinctions = None possible_relations = None if version.startswith("IIT_4_0") and counts_distinctions: from pyphi.formalism.iit4 import bounds # A ``"distinctions"`` analysis stops before relations. unfolds_relations = scope != "distinctions" if unfolds_relations: relations_closed_form = ( config.formalism.iit.relation_computation == "ANALYTICAL" ) if all(int(alphabet[i]) == 2 for i in indices): possible_distinctions = bounds.number_of_possible_distinctions(m) if unfolds_relations: possible_relations = bounds.number_of_possible_relations(m) return AnalysisEstimate( n_units=m, state_space_size=state_space_size, compute=scope, system_partitions=system_partition_count, specified_state_evaluations=specified_state_evaluations, mechanisms=mechanisms, purview_evaluations=purview_evaluations, mechanism_partition_sweeps=sweeps, relations_closed_form=relations_closed_form, possible_distinctions=possible_distinctions, possible_relations=possible_relations, capped=capped, )
[docs] def partition_sweep_count(mechanism_size: int, purview_size: int) -> int: """Memoized mechanism-partition count for one (mechanism, purview) pair under the active mechanism partition scheme.""" counter = _Counter(2**63) return _mechanism_partition_count(mechanism_size, purview_size, counter)
[docs] def mechanism_workloads( substrate: Substrate, subset: Any = None, scope: Any | None = None, limit: int = 10_000_000, ) -> dict[tuple[int, ...], MechanismWorkload]: """Per-mechanism workload under a scope, keyed by mechanism. Each mechanism's :class:`MechanismWorkload` weighs its scoped purview evaluations against its mechanism-partition sweeps by their measured relative cost, and records the state-space size of its largest scoped purview. Summed over all mechanisms, the units equal ``PURVIEW_EVALUATION_UNITS`` × the scoped :attr:`AnalysisEstimate.purview_evaluations` plus the scoped :attr:`AnalysisEstimate.mechanism_partition_sweeps`. Parameters ---------- substrate : Substrate The substrate to analyze. subset : optional Node indices (or labels) of the candidate system; ``None`` uses the whole substrate. scope : :class:`~pyphi.campaign.scope.CESScope`, optional Restrict the counted mechanisms and purviews. limit : int, optional Work budget for the counting walk. Raises ------ ValueError If the counting walk exceeds ``limit`` — the workload is then too large to plan; narrow the scope or raise the limit. """ from pyphi import utils from pyphi.direction import Direction from pyphi.system import System cs = System.from_substrate(substrate, (0,) * substrate.size, subset) alphabet = substrate.factored_tpm.alphabet_sizes counter = _Counter(limit) workloads: dict[tuple[int, ...], MechanismWorkload] = {} mechanism_iter: Any = utils.powerset(cs.node_indices, nonempty=True) if scope is not None: mechanism_iter = scope.mechanisms.select(mechanism_iter) try: for mechanism in mechanism_iter: units = 0 max_cells = 0 for direction in (Direction.CAUSE, Direction.EFFECT): if scope is not None: axis = scope.purview_axis(direction, mechanism) purviews = list( axis.select( cs.potential_purviews( direction, mechanism, max_order=axis.order_bound() ) ) ) else: purviews = cs.potential_purviews(direction, mechanism) for purview in purviews: counter.charge(1) units += PURVIEW_EVALUATION_UNITS + _mechanism_partition_count( len(mechanism), len(purview), counter ) max_cells = max(max_cells, math.prod(alphabet[u] for u in purview)) workloads[tuple(mechanism)] = MechanismWorkload( units=units, max_repertoire_cells=max_cells ) except _LimitReached: raise ValueError( f"mechanism workload walk exceeded limit={limit}; narrow the " "scope or raise the limit" ) from None return workloads