Skip to content

Commit 876629b

Browse files
wmaynerclaude
andcommitted
Fix tied specified-state bug breaking permutation symmetry in SIA
When computing system-level integration (IIT 4.0), the specified cause/effect state can tie at multiple states with equal intrinsic information. integration_value() previously used whichever tied state was arbitrarily selected first, but the cut subsystem breaks the degeneracy between tied states, causing permutation-equivalent systems to produce different phi_c values (e.g., AND-XOR at (0,1) gave phi_c=0.5 while XOR-AND at (1,0) gave phi_c=0.0). Fix: integration_value() now evaluates all tied specified states and takes the minimum integration — the "cruelest cut" principle. Among equally- specified states, the partition is evaluated against the one it hurts most. Tests: add permutation symmetry invariant tests (TestPermutationSymmetry) and unit tests for tied state tracking (TestIntrinsicInformationTies). 843 existing tests pass with no regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 46ca112 commit 876629b

4 files changed

Lines changed: 176 additions & 11 deletions

File tree

pyphi/new_big_phi/__init__.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from pyphi.models.cuts import NullCut
3232
from pyphi.models.cuts import SystemPartition
3333
from pyphi.models.mechanism import RepertoireIrreducibilityAnalysis
34+
from pyphi.models.mechanism import StateSpecification
3435
from pyphi.models.subsystem import CauseEffectStructure
3536
from pyphi.models.subsystem import SystemStateSpecification
3637
from pyphi.parallel import MapReduce
@@ -288,16 +289,15 @@ def normalization_factor(partition: Cut | GeneralKCut) -> float:
288289
return 1.0
289290

290291

291-
def integration_value(
292+
def _integration_value_for_state(
292293
direction: Direction,
293294
subsystem: Subsystem,
295+
cut_subsystem: Subsystem,
294296
partition: Cut,
295-
system_state: SystemStateSpecification,
296-
repertoire_distance: str | None = None,
297+
specified: StateSpecification,
298+
repertoire_distance: str,
297299
) -> RepertoireIrreducibilityAnalysis:
298-
repertoire_distance = fallback(repertoire_distance, config.REPERTOIRE_DISTANCE)
299-
cut_subsystem = subsystem.apply_cut(partition)
300-
# TODO(4.0) deal with proliferation of special cases for GID
300+
"""Compute the integration value for a single specified state."""
301301
mechanism = purview = subsystem.node_indices
302302
if repertoire_distance in [
303303
"GENERALIZED_INTRINSIC_DIFFERENCE",
@@ -307,22 +307,45 @@ def integration_value(
307307
direction,
308308
mechanism,
309309
purview,
310-
system_state[direction].state,
311-
).squeeze()[system_state[direction].state]
310+
specified.state,
311+
).squeeze()[specified.state]
312312
else:
313313
partitioned_repertoire = cut_subsystem.repertoire(
314314
direction, subsystem.node_indices, subsystem.node_indices
315315
)
316-
ria = subsystem.evaluate_partition(
316+
return subsystem.evaluate_partition(
317317
direction,
318318
subsystem.node_indices,
319319
subsystem.node_indices,
320320
partition, # pyright: ignore[reportArgumentType] - Cut passed to Bipartition param in IIT 4.0
321321
partitioned_repertoire=partitioned_repertoire,
322322
repertoire_distance=repertoire_distance,
323-
state=system_state[direction],
323+
state=specified,
324324
)
325-
return ria
325+
326+
327+
def integration_value(
328+
direction: Direction,
329+
subsystem: Subsystem,
330+
partition: Cut,
331+
system_state: SystemStateSpecification,
332+
repertoire_distance: str | None = None,
333+
) -> RepertoireIrreducibilityAnalysis:
334+
repertoire_distance = fallback(repertoire_distance, config.REPERTOIRE_DISTANCE)
335+
cut_subsystem = subsystem.apply_cut(partition)
336+
specified = system_state[direction]
337+
tied_specs = specified.ties if specified.ties else (specified,)
338+
# When there are tied specified states, evaluate all of them and take the
339+
# minimum integration (the "cruelest cut"): among equally-specified states,
340+
# the partition should be evaluated against the one it hurts most.
341+
best_ria = None
342+
for spec in tied_specs:
343+
ria = _integration_value_for_state(
344+
direction, subsystem, cut_subsystem, partition, spec, repertoire_distance
345+
)
346+
if best_ria is None or ria.phi < best_ria.phi:
347+
best_ria = ria
348+
return best_ria
326349

327350

328351
def intrinsic_differentiation_value(

test/example_networks.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pyphi
44
from pyphi import utils
5+
from pyphi.labels import NodeLabels
56
from pyphi.macro import Blackbox
67
from pyphi.macro import MacroSubsystem
78
from pyphi.network import Network
@@ -754,3 +755,45 @@ def propagation_delay():
754755
return MacroSubsystem(
755756
network, cs, network.node_indices, time_scale=time_scale, blackbox=blackbox
756757
)
758+
759+
760+
# Permutation-equivalent pair for symmetry tests
761+
# ================================================
762+
763+
764+
def and_xor_network():
765+
"""AND-XOR 2-node network. Node 0: AND(0,1), Node 1: XOR(0,1).
766+
767+
Both nodes receive input from both nodes (all-ones CM).
768+
Deterministic transitions:
769+
(0,0) -> (0,0), (1,0) -> (0,1), (0,1) -> (0,1), (1,1) -> (1,0)
770+
"""
771+
# fmt: off
772+
tpm = np.array([
773+
[0, 0], # (0,0) -> (0,0)
774+
[0, 1], # (1,0) -> (0,1)
775+
[0, 1], # (0,1) -> (0,1)
776+
[1, 0], # (1,1) -> (1,0)
777+
])
778+
# fmt: on
779+
cm = np.ones((2, 2))
780+
return Network(tpm, cm=cm, node_labels=NodeLabels(("AND", "XOR"), tuple(range(2))))
781+
782+
783+
def xor_and_network():
784+
"""XOR-AND 2-node network (AND-XOR with nodes 0 and 1 permuted).
785+
786+
Both nodes receive input from both nodes (all-ones CM).
787+
Deterministic transitions:
788+
(0,0) -> (0,0), (1,0) -> (1,0), (0,1) -> (1,0), (1,1) -> (0,1)
789+
"""
790+
# fmt: off
791+
tpm = np.array([
792+
[0, 0], # (0,0) -> (0,0)
793+
[1, 0], # (1,0) -> (1,0)
794+
[1, 0], # (0,1) -> (1,0)
795+
[0, 1], # (1,1) -> (0,1)
796+
])
797+
# fmt: on
798+
cm = np.ones((2, 2))
799+
return Network(tpm, cm=cm, node_labels=NodeLabels(("XOR", "AND"), tuple(range(2))))

test/test_invariants.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
from pyphi import config
1515
from pyphi import new_big_phi
1616
from pyphi.new_big_phi import NullSystemIrreducibilityAnalysis
17+
from pyphi.subsystem import Subsystem
1718

19+
from . import example_networks
1820
from .conftest import skip_if_no_pyemd
1921

2022

@@ -320,3 +322,67 @@ def test_phi_structure_has_relations(self, example_name):
320322
f"but no relations attribute"
321323
)
322324
# Relations might be None for some systems, so don't assert it's non-empty
325+
326+
327+
class TestPermutationSymmetry:
328+
"""Systems related by node permutation must have identical phi values.
329+
330+
AND-XOR and XOR-AND have the same all-ones connectivity matrix but swap
331+
which node gets the AND vs XOR gate. They are related by the node
332+
permutation π: 0↔1. Under this permutation, state (a,b) in AND-XOR
333+
maps to (b,a) in XOR-AND.
334+
335+
All measures must be invariant under this permutation.
336+
"""
337+
338+
def test_system_intrinsic_information_symmetric(self):
339+
"""Cause/effect intrinsic information must be equal for permuted systems."""
340+
sub_ax = Subsystem(example_networks.and_xor_network(), (0, 1))
341+
sub_xa = Subsystem(example_networks.xor_and_network(), (1, 0))
342+
ss_ax = new_big_phi.system_intrinsic_information(sub_ax)
343+
ss_xa = new_big_phi.system_intrinsic_information(sub_xa)
344+
assert float(ss_ax.cause.intrinsic_information) == pytest.approx(
345+
float(ss_xa.cause.intrinsic_information)
346+
)
347+
assert float(ss_ax.effect.intrinsic_information) == pytest.approx(
348+
float(ss_xa.effect.intrinsic_information)
349+
)
350+
351+
def test_sia_phi_symmetric(self):
352+
"""Overall phi must be equal for permuted systems."""
353+
sub_ax = Subsystem(example_networks.and_xor_network(), (0, 1))
354+
sub_xa = Subsystem(example_networks.xor_and_network(), (1, 0))
355+
sia_ax = new_big_phi.sia(sub_ax)
356+
sia_xa = new_big_phi.sia(sub_xa)
357+
assert float(sia_ax.phi) == pytest.approx(float(sia_xa.phi))
358+
359+
def test_sia_phi_c_symmetric(self):
360+
"""phi_c must be equal for permuted systems.
361+
362+
This is the specific invariant that was violated by the tied-state
363+
bug: AND-XOR(0,1) reported phi_c=0.5 while XOR-AND(1,0) reported
364+
phi_c=0.0, due to arbitrary tie-breaking in the specified cause state.
365+
"""
366+
sub_ax = Subsystem(example_networks.and_xor_network(), (0, 1))
367+
sub_xa = Subsystem(example_networks.xor_and_network(), (1, 0))
368+
sia_ax = new_big_phi.sia(sub_ax)
369+
sia_xa = new_big_phi.sia(sub_xa)
370+
phi_c_ax = float(sia_ax.cause.phi) if sia_ax.cause else 0.0
371+
phi_c_xa = float(sia_xa.cause.phi) if sia_xa.cause else 0.0
372+
assert phi_c_ax == pytest.approx(phi_c_xa), (
373+
f"phi_c differs for permuted systems: "
374+
f"AND-XOR(0,1)={phi_c_ax}, XOR-AND(1,0)={phi_c_xa}"
375+
)
376+
377+
def test_sia_phi_e_symmetric(self):
378+
"""phi_e must be equal for permuted systems."""
379+
sub_ax = Subsystem(example_networks.and_xor_network(), (0, 1))
380+
sub_xa = Subsystem(example_networks.xor_and_network(), (1, 0))
381+
sia_ax = new_big_phi.sia(sub_ax)
382+
sia_xa = new_big_phi.sia(sub_xa)
383+
phi_e_ax = float(sia_ax.effect.phi) if sia_ax.effect else 0.0
384+
phi_e_xa = float(sia_xa.effect.phi) if sia_xa.effect else 0.0
385+
assert phi_e_ax == pytest.approx(phi_e_xa), (
386+
f"phi_e differs for permuted systems: "
387+
f"AND-XOR(0,1)={phi_e_ax}, XOR-AND(1,0)={phi_e_xa}"
388+
)

test/test_subsystem.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,36 @@ def test_concept_no_mechanism(s):
189189

190190
def test_concept_nonexistent(s):
191191
assert not s.concept((0, 2))
192+
193+
194+
class TestIntrinsicInformationTies:
195+
"""Test that intrinsic_information() correctly tracks tied states."""
196+
197+
def test_tied_states_are_stored(self):
198+
"""intrinsic_information() should populate .ties when states tie.
199+
200+
AND-XOR at state (0,1): the cause GID is 0.5 for both purview states
201+
(1,0) and (0,1), creating a tie. Both should appear in .ties.
202+
"""
203+
net = example_networks.and_xor_network()
204+
sub = Subsystem(net, (0, 1))
205+
spec = sub.intrinsic_information(Direction.CAUSE, (0, 1), (0, 1))
206+
assert len(spec.ties) == 2
207+
tied_states = {t.state for t in spec.ties}
208+
assert tied_states == {(0, 1), (1, 0)}
209+
210+
def test_tied_states_have_equal_ii(self):
211+
"""All tied StateSpecifications must have the same intrinsic information."""
212+
net = example_networks.and_xor_network()
213+
sub = Subsystem(net, (0, 1))
214+
spec = sub.intrinsic_information(Direction.CAUSE, (0, 1), (0, 1))
215+
ii_values = {float(t.intrinsic_information) for t in spec.ties}
216+
assert len(ii_values) == 1, f"Tied states have different II: {ii_values}"
217+
218+
def test_no_ties_when_unique_max(self):
219+
"""When a single state uniquely maximizes II, ties should have length 1."""
220+
net = example_networks.and_xor_network()
221+
sub = Subsystem(net, (0, 1))
222+
spec = sub.intrinsic_information(Direction.EFFECT, (0, 1), (0, 1))
223+
# Effect direction should have a unique max (no tie)
224+
assert len(spec.ties) == 1

0 commit comments

Comments
 (0)