Source code for pyphi.campaign.scope

"""Declarative feasibility surfaces for scoped cause-effect analyses.

A scope states which mechanisms and purviews a computation considers.
Exclusions are explicit and certified — scope changes *what* is computed;
it never silently approximates. Constraint fields are named data (no
callables), so scopes serialize, ship to batch jobs, and land in
provenance. Partition sweeps cannot be scoped: a partial sweep would turn
φ into an upper bound.
"""

from __future__ import annotations

from collections.abc import Iterable
from collections.abc import Iterator
from dataclasses import dataclass
from dataclasses import field

from pyphi.direction import Direction

__all__ = ["AxisScope", "CESScope", "resolve_scope"]


[docs] @dataclass(frozen=True) class AxisScope: """A constraint on one axis of unit sets (mechanisms or purviews). Constraint fields combine by intersection. ``explicit`` is exclusive: an explicit list *is* the axis, so combining it with any other field raises :class:`ValueError`. The default (all fields ``None``) admits every candidate. """ explicit: tuple[tuple[int, ...], ...] | None = None min_order: int | None = None max_order: int | None = None containing: tuple[int, ...] | None = None within: tuple[int, ...] | None = None def __post_init__(self) -> None: others = (self.min_order, self.max_order, self.containing, self.within) if self.explicit is not None and any(o is not None for o in others): raise ValueError( "explicit is exclusive: an explicit list is the axis and " "cannot combine with other constraint fields" ) @property def unconstrained(self) -> bool: return ( self.explicit is None and self.min_order is None and self.max_order is None and self.containing is None and self.within is None ) def admits(self, units: tuple[int, ...]) -> bool: if self.explicit is not None: return tuple(sorted(units)) in {tuple(sorted(e)) for e in self.explicit} if self.min_order is not None and len(units) < self.min_order: return False if self.max_order is not None and len(units) > self.max_order: return False if self.containing is not None and not set(self.containing) <= set(units): return False return self.within is None or set(units) <= set(self.within)
[docs] def order_bound(self) -> int | None: """The tightest order bound derivable from this axis, or None. Every admitted tuple has at most this many units, so an enumeration restricted to this bound followed by :meth:`select` yields exactly what an unbounded enumeration would — without constructing the larger candidates. """ if self.explicit is not None: return max((len(e) for e in self.explicit), default=0) bounds = [ b for b in ( self.max_order, len(self.within) if self.within is not None else None, ) if b is not None ] return min(bounds, default=None)
[docs] def select(self, candidates: Iterable[tuple[int, ...]]) -> Iterator[tuple[int, ...]]: """Yield the candidates this scope admits, preserving their order.""" if self.explicit is not None: allowed = {tuple(sorted(e)) for e in self.explicit} for candidate in candidates: if tuple(sorted(candidate)) in allowed: yield candidate return for candidate in candidates: if self.admits(candidate): yield candidate
[docs] @dataclass(frozen=True) class CESScope: """The feasibility surface of a cause-effect structure computation. ``max_purview_order_by_mechanism_order`` is an explicit table of ``(mechanism order, max purview order)`` pairs applying to both purview directions on top of the static axes; mechanism orders absent from the table fall back to the static constraints alone. This expresses order-tied purview bounds (e.g. purview order ≤ 2·order + 1) exactly while the scope remains callable-free named data. """ mechanisms: AxisScope = field(default_factory=AxisScope) cause_purviews: AxisScope = field(default_factory=AxisScope) effect_purviews: AxisScope = field(default_factory=AxisScope) max_purview_order_by_mechanism_order: tuple[tuple[int, int], ...] | None = None def __post_init__(self) -> None: table = self.max_purview_order_by_mechanism_order if table is None: return orders = [mech_order for mech_order, _ in table] if len(set(orders)) != len(orders): raise ValueError("mechanism orders in the cap table must be unique") if any(m < 1 or p < 1 for m, p in table): raise ValueError("cap table orders must be positive") def purviews(self, direction: Direction) -> AxisScope: if direction == Direction.CAUSE: return self.cause_purviews return self.effect_purviews
[docs] def purview_axis( self, direction: Direction, mechanism: tuple[int, ...] ) -> AxisScope: """The effective purview constraint for one mechanism. The static axis for ``direction``, intersected with the cap table's bound for ``len(mechanism)`` when one is listed. Every purview selection — planning, counting, execution, and collection — goes through this method, so they cannot disagree about the scope. """ axis = self.purviews(direction) if self.max_purview_order_by_mechanism_order is None: return axis cap = dict(self.max_purview_order_by_mechanism_order).get(len(mechanism)) if cap is None: return axis if axis.explicit is not None: return AxisScope(explicit=tuple(e for e in axis.explicit if len(e) <= cap)) return AxisScope( min_order=axis.min_order, max_order=cap if axis.max_order is None else min(axis.max_order, cap), containing=axis.containing, within=axis.within, )
def _units_to_indices(units: tuple, node_labels) -> tuple[int, ...]: return tuple(sorted(node_labels.coerce_to_indices(units))) def _resolve_axis(scope: AxisScope, node_labels) -> AxisScope: return AxisScope( explicit=None if scope.explicit is None else tuple(_units_to_indices(e, node_labels) for e in scope.explicit), min_order=scope.min_order, max_order=scope.max_order, containing=None if scope.containing is None else _units_to_indices(scope.containing, node_labels), within=None if scope.within is None else _units_to_indices(scope.within, node_labels), )
[docs] def resolve_scope(scope: CESScope, node_labels) -> CESScope: """Return the scope with every unit reference normalized to indices.""" return CESScope( mechanisms=_resolve_axis(scope.mechanisms, node_labels), cause_purviews=_resolve_axis(scope.cause_purviews, node_labels), effect_purviews=_resolve_axis(scope.effect_purviews, node_labels), max_purview_order_by_mechanism_order=( scope.max_purview_order_by_mechanism_order ), )