# partition.py
"""Utilities for generating partitions."""
from __future__ import annotations
import functools
import inspect
import itertools
from collections.abc import Generator, Iterable, Iterator, Sequence
from itertools import chain, product
from typing import Any
import numpy as np
from more_itertools import distinct_permutations
from more_itertools import unique_everseen
from . import combinatorics
from .cache import cache
from .conf import config, fallback
from .direction import Direction
from .models.partitions import (
JointBipartition,
TotalCut,
EdgeCut,
DirectedSetPartition,
JointPartition,
Part,
DirectedBipartition,
JointTripartition,
)
from .registry import Registry
# TODO: move purely combinatorial functions to `combinatorics`
[docs]
@cache()
def bipartition_indices(N: int) -> list[tuple[tuple[int, ...], tuple[int, ...]]]:
"""Return indices for undirected bipartitions of a sequence.
Parameters
----------
N : int
The length of the sequence.
Returns
-------
list
A list of tuples containing the indices for each of the two parts.
Examples
--------
>>> N = 3
>>> bipartition_indices(N)
[((), (0, 1, 2)), ((0,), (1, 2)), ((1,), (0, 2)), ((0, 1), (2,))]
"""
result = []
if N <= 0:
return result
for i in range(2 ** (N - 1)):
part = [[], []]
for n in range(N):
bit = (i >> n) & 1
part[bit].append(n)
result.append((tuple(part[1]), tuple(part[0])))
return result
# TODO? rename to `bipartitions`
[docs]
def bipartition(seq: Sequence, nontrivial: bool = False) -> list[tuple[tuple, tuple]]:
"""Return a list of bipartitions for a sequence.
Parameters
----------
seq : Sequence
The sequence to partition.
nontrivial : bool, optional
If ``True``, omit the first bipartition (the one with an empty part).
Returns
-------
list[tuple[tuple, tuple]]
A list of tuples containing each of the two parts.
Examples
--------
>>> bipartition((1,2,3))
[((), (1, 2, 3)), ((1,), (2, 3)), ((2,), (1, 3)), ((1, 2), (3,))]
"""
bipartitions = [
(tuple(seq[i] for i in part0_idx), tuple(seq[j] for j in part1_idx))
for part0_idx, part1_idx in bipartition_indices(len(seq))
]
if nontrivial:
return bipartitions[1:]
return bipartitions
[docs]
@cache()
def directed_bipartition_indices(
N: int,
) -> list[tuple[tuple[int, ...], tuple[int, ...]]]:
"""Return indices for directed bipartitions of a sequence.
Parameters
----------
N : int
The length of the sequence.
Returns
-------
list
A list of tuples containing the indices for each of the two parts.
Examples
--------
>>> N = 3
>>> directed_bipartition_indices(N) # doctest: +NORMALIZE_WHITESPACE
[((), (0, 1, 2)),
((0,), (1, 2)),
((1,), (0, 2)),
((0, 1), (2,)),
((2,), (0, 1)),
((0, 2), (1,)),
((1, 2), (0,)),
((0, 1, 2), ())]
"""
indices = bipartition_indices(N)
# Reverse each bipartition to create directed versions
# Type annotation: tuple slicing [::-1] creates proper 2-element tuples
reversed_indices: list[tuple[tuple[int, ...], tuple[int, ...]]] = [
(idx[1], idx[0]) for idx in indices[::-1]
]
return indices + reversed_indices
# TODO? [optimization] optimize this to use indices rather than nodes
[docs]
def directed_bipartition(
seq: Sequence, nontrivial: bool = False
) -> list[tuple[tuple, tuple]]:
"""Return a list of directed bipartitions for a sequence.
Parameters
----------
seq : Sequence
The sequence to partition.
nontrivial : bool, optional
If ``True``, omit the two bipartitions that have an empty part (the
first and last).
Returns
-------
list[tuple[tuple, tuple]]
A list of tuples containing each of the two parts.
Examples
--------
>>> directed_bipartition((1, 2, 3)) # doctest: +NORMALIZE_WHITESPACE
[((), (1, 2, 3)),
((1,), (2, 3)),
((2,), (1, 3)),
((1, 2), (3,)),
((3,), (1, 2)),
((1, 3), (2,)),
((2, 3), (1,)),
((1, 2, 3), ())]
"""
bipartitions = [
(tuple(seq[i] for i in part0_idx), tuple(seq[j] for j in part1_idx))
for part0_idx, part1_idx in directed_bipartition_indices(len(seq))
]
if nontrivial:
# The first and last partitions have a part that is empty; skip them.
# NOTE: This depends on the implementation of
# `directed_partition_indices`.
return bipartitions[1:-1]
return bipartitions
[docs]
def bipartition_of_one(seq):
"""Generate bipartitions where one part contains exactly one element.
Parameters
----------
seq : Iterable
Sequence to partition.
Yields
------
tuple[tuple, tuple]
Bipartitions ``(single, remainder)`` covering all elements of ``seq``.
"""
seq = list(seq)
for i, elt in enumerate(seq):
yield ((elt,), tuple(seq[:i] + seq[(i + 1) :]))
[docs]
def reverse_elements(seq):
"""Yield each element of ``seq`` reversed."""
for elt in seq:
yield elt[::-1]
[docs]
def directed_bipartition_of_one(seq):
"""Generate directed bipartitions where one part is of length 1.
Parameters
----------
seq : Iterable
The sequence to partition.
Returns
-------
Iterator[tuple[tuple, tuple]]
An iterator over each ``(single, remainder)`` bipartition and its
reverse. Bipartitions with an empty part are excluded, so a
single-element sequence yields nothing.
Examples
--------
>>> partitions = directed_bipartition_of_one((1, 2, 3))
>>> list(partitions) # doctest: +NORMALIZE_WHITESPACE
[((1,), (2, 3)),
((2,), (1, 3)),
((3,), (1, 2)),
((2, 3), (1,)),
((1, 3), (2,)),
((1, 2), (3,))]
>>> list(directed_bipartition_of_one((1,)))
[]
"""
bipartitions = [b for b in bipartition_of_one(seq) if b[0] and b[1]]
# For two elements the reversed splits coincide with the originals;
# yield each bipartition once.
seen = set()
return (
b
for b in chain(bipartitions, reverse_elements(bipartitions))
if not (b in seen or seen.add(b))
)
[docs]
@cache()
def directed_tripartition_indices(
N: int,
) -> list[tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]]:
"""Return indices for directed tripartitions of a sequence.
Parameters
----------
N : int
The length of the sequence.
Returns
-------
list[tuple]
A list of tuples containing the indices for each partition.
Examples
--------
>>> N = 1
>>> directed_tripartition_indices(N)
[((0,), (), ()), ((), (0,), ()), ((), (), (0,))]
"""
result = []
if N <= 0:
return result
base = [0, 1, 2]
for key in product(base, repeat=N):
part = [[], [], []]
for i, location in enumerate(key):
part[location].append(i)
result.append(tuple(tuple(p) for p in part))
return result
[docs]
def directed_tripartition(
seq: Sequence,
) -> Generator[tuple[tuple, tuple, tuple], None, None]:
"""Generator over all directed tripartitions of a sequence.
Parameters
----------
seq : Sequence
A sequence.
Yields
------
tuple[tuple, tuple, tuple]
A tripartition of ``seq``.
Examples
--------
>>> seq = (2, 5)
>>> list(directed_tripartition(seq)) # doctest: +NORMALIZE_WHITESPACE
[((2, 5), (), ()),
((2,), (5,), ()),
((2,), (), (5,)),
((5,), (2,), ()),
((), (2, 5), ()),
((), (2,), (5,)),
((5,), (), (2,)),
((), (5,), (2,)),
((), (), (2, 5))]
"""
for a, b, c in directed_tripartition_indices(len(seq)):
yield (
tuple(seq[i] for i in a),
tuple(seq[j] for j in b),
tuple(seq[k] for k in c),
)
# Knuth's algorithm for k-partitions of a set
# codereview.stackexchange.com/questions/1526/finding-all-k-subset-partitions
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def _visit(n, a, k, collection):
# pylint: disable=missing-docstring
ps = [[] for i in range(k)]
for j in range(n):
ps[a[j + 1]].append(collection[j])
return ps
def _f(mu, nu, sigma, n, a, k, collection):
# flake8: noqa
# pylint: disable=missing-docstring
if mu == 2:
yield _visit(n, a, k, collection)
else:
for v in _f(mu - 1, nu - 1, (mu + sigma) % 2, n, a, k, collection):
yield v
if nu == mu + 1:
a[mu] = mu - 1
yield _visit(n, a, k, collection)
while a[nu] > 0:
a[nu] = a[nu] - 1
yield _visit(n, a, k, collection)
elif nu > mu + 1:
if (mu + sigma) % 2 == 1:
a[nu - 1] = mu - 1
else:
a[mu] = mu - 1
if (a[nu] + sigma) % 2 == 1:
for v in _b(mu, nu - 1, 0, n, a, k, collection):
yield v
else:
for v in _f(mu, nu - 1, 0, n, a, k, collection):
yield v
while a[nu] > 0:
a[nu] = a[nu] - 1
if (a[nu] + sigma) % 2 == 1:
for v in _b(mu, nu - 1, 0, n, a, k, collection):
yield v
else:
for v in _f(mu, nu - 1, 0, n, a, k, collection):
yield v
def _b(mu, nu, sigma, n, a, k, collection):
# flake8: noqa
# pylint: disable=missing-docstring
if nu == mu + 1:
while a[nu] < mu - 1:
yield _visit(n, a, k, collection)
a[nu] = a[nu] + 1
yield _visit(n, a, k, collection)
a[mu] = 0
elif nu > mu + 1:
if (a[nu] + sigma) % 2 == 1:
for v in _f(mu, nu - 1, 0, n, a, k, collection):
yield v
else:
for v in _b(mu, nu - 1, 0, n, a, k, collection):
yield v
while a[nu] < mu - 1:
a[nu] = a[nu] + 1
if (a[nu] + sigma) % 2 == 1:
for v in _f(mu, nu - 1, 0, n, a, k, collection):
yield v
else:
for v in _b(mu, nu - 1, 0, n, a, k, collection):
yield v
if (mu + sigma) % 2 == 1:
a[nu - 1] = 0
else:
a[mu] = 0
if mu == 2:
yield _visit(n, a, k, collection)
else:
for v in _b(mu - 1, nu - 1, (mu + sigma) % 2, n, a, k, collection):
yield v
[docs]
def k_partitions(collection, k):
"""Generate all ``k``-partitions of a collection.
Uses Knuth's algorithm for set partitions into exactly ``k`` blocks.
Examples
--------
>>> list(k_partitions(range(3), 2))
[[[0, 1], [2]], [[0], [1, 2]], [[0, 2], [1]]]
"""
collection = list(collection)
n = len(collection)
# Special cases
if n == 0 or k < 1 or k > n:
return []
if k == 1:
return [[collection]]
if k == n:
return [[[item] for item in collection]]
a = [0] * (n + 1)
for j in range(1, k + 1):
a[n - k + j] = j - 1
return _f(k, n, 0, n, a, k, collection)
# Concrete partitions producing PyPhi models
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Distinction partitions
# ~~~~~~~~~~~~~~~~~~~~~~
def _check_scheme_signature(
func: Any, dummy_args: tuple, what: str, expected: str
) -> None:
"""Reject a scheme whose signature cannot accept the registry's call shape.
Uses :func:`inspect.signature` binding as a static check — the scheme is
not called. Callables whose signature cannot be introspected are accepted,
since nothing can be proven about them.
"""
try:
sig = inspect.signature(func)
except (TypeError, ValueError):
return
try:
sig.bind(*dummy_args)
except TypeError as err:
raise TypeError(
f"Cannot register {func!r} as {what}: its signature {sig} does "
f"not accept a {expected} call: {err}"
) from err
[docs]
class PartitionRegistry(Registry):
"""Storage for mechanism-level partition schemes registered with PyPhi.
Schemes registered here yield mechanism-level partitions
(:class:`pyphi.protocols.MechanismPartition` instances) for each
(mechanism, purview) pair. Used by ``System.find_mip`` to enumerate
partitions during MIP search.
Users can define custom partitions and use them by setting
``pyphi.config["iit.mechanism_partition_scheme"] = 'NONE'``.
Registered objects are validated at registration: they must be callable
and their signature must accept a ``(mechanism, purview)`` call, so
wrong-shape registrations fail at import rather than at the bottom of a
phi computation.
Examples
--------
>>> @partition_types.register('NONE') # doctest: +SKIP
... def no_partitions(mechanism, purview):
... return []
"""
desc = "distinction partitions"
[docs]
def register(self, name):
"""Decorator that validates and registers a partition scheme."""
from .protocols import MechanismPartitionScheme
def register_func(func):
if not isinstance(func, MechanismPartitionScheme):
raise TypeError(
f"Cannot register {func!r} as partition scheme {name!r}: "
f"object does not satisfy the MechanismPartitionScheme "
f"Protocol (must be callable)."
)
_check_scheme_signature(
func,
((0,), (0,)),
f"partition scheme {name!r}",
"(mechanism, purview)",
)
self.store[name] = func
return func
return register_func
partition_types = PartitionRegistry()
[docs]
def mechanism_partitions(mechanism, purview, node_labels=None):
"""Return a generator over all mechanism-purview partitions, based on the
current configuration.
"""
func = partition_types[config.formalism.iit.mechanism_partition_scheme]
return func(mechanism, purview, node_labels)
[docs]
@partition_types.register("JOINT_BIPARTITION")
def joint_bipartitions(
mechanism: tuple[int, ...],
purview: tuple[int, ...],
node_labels: Any = None,
) -> Iterable[JointBipartition]:
r"""Return a generator of all φ bipartitions of a mechanism over a purview.
Excludes all bipartitions where one half is entirely empty, *e.g*::
A ∅
─── ✕ ───
B ∅
is not valid, but ::
A ∅
─── ✕ ───
∅ B
is.
Parameters
----------
mechanism : tuple[int]
The mechanism to partition.
purview : tuple[int]
The purview to partition.
Yields
------
JointBipartition
Each bipartition, rendered as ``mech0/purview0 × mech1/purview1``
(∅ denotes an empty set).
Examples
--------
>>> from pyphi.models.partitions import concise_partition
>>> mechanism = (0,)
>>> purview = (2, 3)
>>> for partition in joint_bipartitions(mechanism, purview):
... print(concise_partition(partition))
∅/2 × 0/3
∅/3 × 0/2
∅/2,3 × 0/∅
"""
numerators = bipartition(mechanism)
denominators = directed_bipartition(purview)
for n, d in product(numerators, denominators):
if (n[0] or d[0]) and (n[1] or d[1]):
yield JointBipartition(
Part(n[0], d[0], node_labels=node_labels),
Part(n[1], d[1], node_labels=node_labels),
node_labels=node_labels,
)
[docs]
@partition_types.register("WEDGE_TRIPARTITION")
def wedge_tripartitions(
mechanism: tuple[int, ...],
purview: tuple[int, ...],
node_labels: Any = None,
) -> Iterable[JointTripartition]:
"""Return an iterator over all wedge partitions.
These are partitions which strictly split the mechanism and allow a subset
of the purview to be split into a third partition, e.g.::
A B ∅
─── ✕ ─── ✕ ───
B C D
See ``config.formalism.iit.mechanism_partition_scheme`` for more information.
Parameters
----------
mechanism : tuple[int]
A mechanism.
purview : tuple[int]
A purview.
Yields
------
JointTripartition
All unique tripartitions of this mechanism and purview.
"""
numerators = bipartition(mechanism)
denominators = directed_tripartition(purview)
yielded = set()
def valid(factoring):
"""Return whether the factoring should be considered."""
# pylint: disable=too-many-boolean-expressions
numerator, denominator = factoring
return (
(numerator[0] or denominator[0])
and (numerator[1] or denominator[1])
and (
(numerator[0] and numerator[1])
or not denominator[0]
or not denominator[1]
)
)
for n, d in filter(valid, product(numerators, denominators)):
# Normalize order of parts to remove duplicates.
tripart = JointTripartition(
Part(n[0], d[0], node_labels=node_labels),
Part(n[1], d[1], node_labels=node_labels),
Part((), d[2], node_labels=node_labels),
node_labels=node_labels,
).normalize()
def nonempty(part):
"""Check that the part is not empty."""
return part.mechanism or part.purview
def compressible(tripart):
"""Check if the tripartition can be transformed into a causally
equivalent partition by combing two of its parts; e.g., A/∅ × B/∅ ×
∅/CD is equivalent to AB/∅ × ∅/CD so we don't include it.
"""
pairs = [
(tripart[0], tripart[1]),
(tripart[0], tripart[2]),
(tripart[1], tripart[2]),
]
for x, y in pairs:
if (
nonempty(x)
and nonempty(y)
and (x.mechanism + y.mechanism == () or x.purview + y.purview == ())
):
return True
return False
if not compressible(tripart) and tripart not in yielded:
yielded.add(tripart)
yield tripart
[docs]
@partition_types.register("JOINT_PARTITION_ALL")
def all_joint_partitions(
mechanism: tuple[int, ...],
purview: tuple[int, ...],
node_labels: Any = None,
) -> Iterable[JointPartition]:
"""Return all possible partitions of a mechanism and purview.
Partitions can consist of any number of parts.
Parameters
----------
mechanism : tuple[int]
A mechanism.
purview : tuple[int]
A purview.
Yields
------
JointPartition
A partition of this mechanism and purview into ``k`` parts. Each
induced edge cut is yielded exactly once: structurally distinct
part assignments that sever the same mechanism-purview edges (for
example, the complete cut written as one mechanism part or as
several, each over an empty purview) describe the same physical
partition and produce the same partitioned repertoire, so only
the first-generated form is yielded.
"""
# TODO: yield complete partition directly, then use nontrivial set partitions
for mechanism_partition in combinatorics.set_partitions(mechanism):
mechanism_partition.append([])
n_mechanism_parts = len(mechanism_partition)
max_purview_partition = min(len(purview), n_mechanism_parts)
for n_purview_parts in range(1, max_purview_partition + 1):
n_empty = n_mechanism_parts - n_purview_parts
for purview_partition in k_partitions(purview, n_purview_parts):
purview_partition = [tuple(part) for part in purview_partition]
# Extend with empty tuples so purview partition has same size
# as mechanism purview
purview_partition.extend([()] * n_empty)
# Unique permutations to avoid duplicate empties
for purview_permutation in distinct_permutations(purview_partition):
parts = [
Part(tuple(m), tuple(p), node_labels=node_labels)
for m, p in zip(mechanism_partition, purview_permutation)
]
# Must partition the mechanism, unless the purview is fully
# cut away from the mechanism.
# TODO: find a way to avoid generating these in the first place
if parts[0].mechanism == mechanism and parts[0].purview:
continue
# Mechanism parts over an empty purview have every edge
# severed, so splitting or merging them yields the same
# induced cut; keep only the merged representative (at
# most one such part). Equivalent to deduplicating by
# ``lex_key()`` while preserving generation order, but
# with O(1) memory.
if (
sum(1 for part in parts if part.mechanism and not part.purview)
> 1
):
continue
yield JointPartition(*parts, node_labels=node_labels)
[docs]
class CompleteJointPartition(JointPartition):
"""Represents the partition that completely separates mechanism and purview."""
[docs]
def complete_joint_partition(mechanism, purview):
"""Return the partition that disconnects mechanism and purview entirely.
Parameters
----------
mechanism : tuple[int]
Mechanism indices.
purview : tuple[int]
Purview indices.
Returns
-------
CompleteJointPartition
Partition with empty cross-connections.
"""
n_parts = len(next(mechanism_partitions(mechanism, purview)))
parts = [Part((), ())] * (n_parts - 2) + [Part((), purview), Part(mechanism, ())]
return CompleteJointPartition(*parts)
[docs]
class AtomicJointPartition(JointPartition):
"""Represents the partition that separates all inter-element connections."""
[docs]
def atomic_joint_partition(elements):
"""Return the partition that isolates every element.
Parameters
----------
elements : Iterable[int]
Elements to separate.
Returns
-------
AtomicJointPartition
Partition where each element is its own part.
"""
return AtomicJointPartition(*[Part((elt,), (elt,)) for elt in elements])
# System partitions
# ~~~~~~~~~~~~~~~~~
[docs]
class SystemPartitionRegistry(Registry):
"""Storage for system-level partition schemes registered with PyPhi.
Schemes registered here yield system-level partitions
(:class:`pyphi.protocols.SystemPartitionLike` instances) for a set of
nodes. Used by SIA computations to enumerate cuts of the full system.
Users can define custom partitions and use them by setting
``pyphi.config.system_partition_scheme = 'NONE'``.
Registered objects are validated at registration: they must be callable
and their signature must accept a ``(nodes,)`` call, so wrong-shape
registrations (e.g. a mechanism-level scheme, which requires a second
positional argument) fail at import rather than at the bottom of a SIA
computation.
Examples
--------
>>> @system_partition_types.register('NONE') # doctest: +SKIP
... def no_partitions(nodes):
... return []
"""
desc = "system partitions"
[docs]
def register(self, name):
"""Decorator that validates and registers a system partition scheme."""
from .protocols import SystemPartitionScheme
def register_func(func):
if not isinstance(func, SystemPartitionScheme):
raise TypeError(
f"Cannot register {func!r} as system partition scheme "
f"{name!r}: object does not satisfy the "
f"SystemPartitionScheme Protocol (must be callable)."
)
_check_scheme_signature(
func,
((0, 1),),
f"system partition scheme {name!r}",
"(nodes,)",
)
self.store[name] = func
return func
return register_func
system_partition_types = SystemPartitionRegistry()
def _bipartitions_to_directed_bipartitions(func):
"""Wrap a bipartition generator to yield ``DirectedBipartition`` objects.
Each plain bipartition tuple is wrapped with ``Direction.EFFECT``. IIT 3.0
system partitions are symmetric with respect to causal direction, so the
default direction has no effect on phi values.
"""
@functools.wraps(func)
def wrapper(*args, node_labels=None, **kwargs):
bipartitions = func(*args, **kwargs)
return [
DirectedBipartition(
Direction.EFFECT,
bipartition[0],
bipartition[1],
node_labels=node_labels,
)
for bipartition in bipartitions
]
return wrapper
[docs]
@system_partition_types.register("DIRECTED_BIPARTITION")
@_bipartitions_to_directed_bipartitions
def directed_bipartitions(nodes):
"""Yield every nontrivial directed bipartition of the given nodes."""
# Don't consider trivial partitions where one part is empty
return directed_bipartition(nodes, nontrivial=True)
[docs]
@system_partition_types.register("DIRECTED_BIPARTITION_CUT_ONE")
@_bipartitions_to_directed_bipartitions
def directed_bipartitions_cut_one(nodes):
"""Yield directed bipartitions where one part has a single node."""
return directed_bipartition_of_one(nodes)
[docs]
@system_partition_types.register("DIRECTED_BIPARTITION_SEQUENTIAL")
def directed_bipartitions_sequential(
nodes: Sequence[int],
node_labels: Any = None,
) -> list[DirectedBipartition]:
"""Yield directed bipartitions by linearly splitting the node sequence.
Produces ``2 * (n - 1)`` partitions for ``n`` nodes: each split point
in ``range(1, n)`` yields one partition and its complement.
"""
# Use a list instead of generator for progress bar totals since it's linear
# in the size of the system
partitions: list[DirectedBipartition] = []
nodes_t = tuple(nodes)
for n in range(1, len(nodes_t)):
part1, part2 = nodes_t[:n], nodes_t[n:]
partitions.append(
DirectedBipartition(Direction.EFFECT, part1, part2, node_labels=node_labels)
)
partitions.append(
DirectedBipartition(Direction.EFFECT, part2, part1, node_labels=node_labels)
)
return partitions
def _cut_matrices(n, symmetric=False):
"""Generate binary cut matrices for ``n`` nodes.
Parameters
----------
n : int
Number of nodes.
symmetric : bool, optional
Whether to enforce symmetry (bidirectional cuts).
Yields
------
np.ndarray
``n x n`` binary matrices encoding disconnections. The all-zero matrix
(no disconnection) is skipped.
"""
repeat = n**2 - n
if symmetric:
repeat = repeat // 2
mid = repeat // 2
# Skip first all-zero combination since they are all zeros
for combination in itertools.islice(product([0, 1], repeat=repeat), 1, None):
cm = np.zeros([n, n], dtype=int)
if symmetric:
# Fill the upper triangle and mirror it: ``triu_indices`` and
# ``tril_indices`` enumerate mirror-image cells at the same flat
# position only for n <= 3, so assigning the same combination to
# both would produce asymmetric matrices from n = 4 on.
cm[np.triu_indices(n, k=1)] = combination
cm += cm.T
else:
cm[np.triu_indices(n, k=1)] = combination[:mid]
cm[np.tril_indices(n, k=-1)] = combination[mid:]
yield cm
[docs]
@system_partition_types.register("EDGE_CUT_ALL")
def all_edge_cuts(
node_indices: tuple[int, ...],
node_labels: Any = None,
) -> Iterable[EdgeCut]:
"""Yield every edge cut on the given nodes (with the complete cut)."""
yield TotalCut(node_indices, node_labels=node_labels)
for cut_matrix in _cut_matrices(len(node_indices)):
yield EdgeCut(node_indices, cut_matrix, node_labels=node_labels)
# Single-edge cuts need not disconnect the system, so SIA searches must
# filter these schemes' output to disconnecting cuts (IIT 4.0 Eqs. 14-16).
all_edge_cuts.may_yield_non_disconnecting_cuts = True # pyright: ignore[reportFunctionMemberAccess, reportAttributeAccessIssue]
[docs]
def num_edge_cuts(n: int) -> int:
"""Return the number of possible edge cuts on ``n`` nodes."""
return 2 ** (n**2 - n)
[docs]
@system_partition_types.register("EDGE_CUT_BIDIRECTIONAL")
def bidirectional_edge_cuts(
node_indices: tuple[int, ...],
node_labels: Any = None,
) -> Iterable[EdgeCut]:
"""Yield every bidirectional (symmetric) edge cut on the given nodes."""
yield TotalCut(node_indices, node_labels=node_labels)
for cut_matrix in _cut_matrices(len(node_indices), symmetric=True):
yield EdgeCut(node_indices, cut_matrix, node_labels=node_labels)
bidirectional_edge_cuts.may_yield_non_disconnecting_cuts = True # pyright: ignore[reportFunctionMemberAccess, reportAttributeAccessIssue]
def _directed_set_partitions(
node_indices: tuple[int, ...],
node_labels: Any = None,
) -> Iterable[EdgeCut]:
"""Yield every directed set partition of the given nodes.
Each set partition is yielded once per assignment of a direction
(``CAUSE``, ``EFFECT``, or ``BIDIRECTIONAL``) to each part.
"""
if len(node_indices) == 1 or config.formalism.iit.system_partition_include_total:
yield TotalCut(node_indices, node_labels=node_labels)
_node_indices = set(range(len(node_indices)))
# Convert set to list for set_partitions which expects Sequence
for partition in combinatorics.set_partitions(list(_node_indices), nontrivial=True):
for directions in product(Direction.all(), repeat=len(partition)):
cut_matrix = np.zeros([len(_node_indices), len(_node_indices)], dtype=int)
for part, direction in zip(partition, directions):
nonpart = list(_node_indices - set(part))
if direction == Direction.CAUSE:
source, target = nonpart, part
else:
source, target = part, nonpart
cut_matrix[np.ix_(source, target)] = 1
if direction == Direction.BIDIRECTIONAL:
cut_matrix[np.ix_(target, source)] = 1
yield DirectedSetPartition(
node_indices,
cut_matrix,
node_labels=node_labels,
set_partition=partition,
)
[docs]
@system_partition_types.register("DIRECTED_SET_PARTITION")
@functools.wraps(_directed_set_partitions)
def directed_set_partitions(node_indices, node_labels=None):
# TODO: generate properly without using set
yield from unique_everseen(
_directed_set_partitions(node_indices, node_labels=node_labels)
)
[docs]
def system_partitions(nodes, node_labels=None, partition_scheme=None, filter_func=None):
"""Return the currently configured system partitions for the given nodes."""
partition_scheme = fallback(
partition_scheme, config.formalism.iit.system_partition_scheme
)
partitions = system_partition_types[partition_scheme](nodes, node_labels=node_labels)
if filter_func is not None:
return filter(filter_func, partitions)
return partitions