Skip to content

Commit c61d95d

Browse files
wmaynerclaude
andcommitted
Implement Eq. 23 ii(s) cap and fix INTRINSIC_INFORMATION dispatch
The INTRINSIC_INFORMATION composite metric (min(GID, i_diff)) was being dispatched as a regular repertoire distance, conflating integration (GID, Eqs. 19-20) with the existence requirement (ii cap, Eq. 23 of Mayner, Marshall, & Tononi 2025). The i_spec component of the ii(s) cap was also missing entirely. Treat INTRINSIC_INFORMATION as a mode flag rather than a metric: - new_big_phi.evaluate_partition: resolve to GID for partition integration and apply ii(s) = min_d(min(i_diff_d, i_spec_d)) cap on phi. - subsystem.evaluate_partition: also resolve to GID — mechanism-level partition evaluation is unchanged from IIT 4.0 and does not get the ii cap (which is system-level only). The INTRINSIC_INFORMATION metric function in the registry is unchanged; it's still valid for computing intrinsic information directly, just no longer dispatched for partition integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5d13267 commit c61d95d

4 files changed

Lines changed: 232 additions & 8 deletions

File tree

changelog.d/eq23-ii-cap.fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implement Eq. 23 ii(s) cap: system-level partition evaluation now uses GID only (Eqs. 19-20), and phi is capped by ii(s) = min(ii_c, ii_e) where ii_d = min(i_diff_d, i_spec_d). Mechanism-level partition evaluation also uses GID only when `REPERTOIRE_DISTANCE=INTRINSIC_INFORMATION` (the composite metric is a system-level concept).

pyphi/new_big_phi/__init__.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -414,19 +414,26 @@ def evaluate_partition(
414414
directions = Direction.both()
415415
directions = tuple(directions)
416416
validate.directions(directions)
417+
418+
# Eqs. 19-20: system-level partition integration uses GID only.
419+
# The ii(s) cap (Eq. 23) is applied separately below.
420+
effective_distance = fallback(repertoire_distance, config.REPERTOIRE_DISTANCE)
421+
partition_distance = (
422+
"GENERALIZED_INTRINSIC_DIFFERENCE"
423+
if effective_distance == "INTRINSIC_INFORMATION"
424+
else effective_distance
425+
)
426+
417427
integration = {
418428
direction: integration_value(
419429
direction,
420430
subsystem,
421431
partition,
422432
system_state,
423-
repertoire_distance=repertoire_distance,
433+
repertoire_distance=partition_distance,
424434
)
425435
for direction in directions
426436
}
427-
phi = min(integration[direction].phi for direction in directions)
428-
norm = normalization_factor(partition)
429-
normalized_phi = phi * norm
430437

431438
intrinsic_differentiation = {
432439
direction: intrinsic_differentiation_value(
@@ -437,6 +444,19 @@ def evaluate_partition(
437444
for direction in directions
438445
}
439446

447+
phi = min(integration[direction].phi for direction in directions)
448+
449+
# Eq. 23: φ_s(s) = min{φ_c(s), φ_e(s), ii(s)}
450+
# where ii(s) = min_d{min(i_diff_d, i_spec_d)}
451+
if effective_distance == "INTRINSIC_INFORMATION":
452+
for direction in directions:
453+
i_spec = float(system_state[direction].intrinsic_information)
454+
i_diff = float(intrinsic_differentiation[direction])
455+
phi = min(phi, i_spec, i_diff)
456+
457+
norm = normalization_factor(partition)
458+
normalized_phi = phi * norm
459+
440460
result = SystemIrreducibilityAnalysis(
441461
phi=phi,
442462
normalized_phi=normalized_phi,

pyphi/subsystem.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -811,15 +811,16 @@ def evaluate_partition(
811811
partitioned repertoires, and the partitioned repertoire.
812812
"""
813813
repertoire_distance = fallback(repertoire_distance, config.REPERTOIRE_DISTANCE)
814+
# Mechanism-level partition evaluation uses GID only.
815+
# INTRINSIC_INFORMATION is a system-level composite (Eq. 23).
816+
if repertoire_distance == "INTRINSIC_INFORMATION":
817+
repertoire_distance = "GENERALIZED_INTRINSIC_DIFFERENCE"
814818
# TODO(4.0) refactor
815819
# TODO(4.0) consolidate logic with system level partitions
816820
if repertoire is None:
817821
repertoire = self.repertoire(direction, mechanism, purview)
818822
# TODO(4.0) use same partitioned_repertoire func
819-
if repertoire_distance in [
820-
"GENERALIZED_INTRINSIC_DIFFERENCE",
821-
"INTRINSIC_INFORMATION",
822-
]:
823+
if repertoire_distance == "GENERALIZED_INTRINSIC_DIFFERENCE":
823824
func = metrics.distribution.measures[repertoire_distance]
824825
assert not isinstance(repertoire, (int, float)), (
825826
"GID requires full repertoire"

test/test_big_phi_robust.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,205 @@ def test_sia_sequential_equals_parallel_phi(self, subsystem_fixture, request):
388388
f" Parallel: {par_result.phi}\n"
389389
f" Diff: {abs(seq_result.phi - par_result.phi)}"
390390
)
391+
392+
393+
# ============================================================================
394+
# Eq. 23: ii(s) cap and GID-only partition evaluation
395+
# ============================================================================
396+
397+
398+
class TestEq23IntrinsicInformationCap:
399+
"""Test that sia() implements Eq. 23 from Mayner, Marshall, & Tononi 2025.
400+
401+
φ_s(s) = min{φ_c(s), φ_e(s), ii(s)}
402+
403+
where ii(s) = min{ii_c(s), ii_e(s)} and ii_d = min{i_diff_d, i_spec_d}.
404+
405+
Partition evaluation uses GID only (Eqs. 19-20); i_diff and i_spec are
406+
applied as the ii(s) cap separately.
407+
"""
408+
409+
II_CONFIG = dict(
410+
REPERTOIRE_DISTANCE="INTRINSIC_INFORMATION",
411+
REPERTOIRE_DISTANCE_SPECIFICATION="INTRINSIC_SPECIFICATION",
412+
REPERTOIRE_DISTANCE_DIFFERENTIATION="INTRINSIC_DIFFERENTIATION",
413+
)
414+
415+
@staticmethod
416+
def _noisy_copy_subsystem(p, state):
417+
"""Create a 2-node noisy COPY system.
418+
419+
Each node copies the other with probability p (LOLI state ordering).
420+
"""
421+
import numpy as np
422+
from pyphi import Network, Subsystem
423+
424+
tpm = np.array([
425+
[1 - p, 1 - p], # (0,0)
426+
[1 - p, p], # (1,0)
427+
[p, 1 - p], # (0,1)
428+
[p, p], # (1,1)
429+
])
430+
cm = np.array([[0, 1], [1, 0]])
431+
network = Network(tpm, cm=cm, node_labels=["A", "B"])
432+
return Subsystem(network, state)
433+
434+
def test_phi_capped_by_ii(self):
435+
"""phi is capped by ii(s) = min(i_diff, i_spec) per direction.
436+
437+
With p=0.8, state (1,1): GID(MIP) ≈ 0.868 but i_diff ≈ 0.644,
438+
so ii(s) ≈ 0.644 caps phi below GID(MIP).
439+
"""
440+
from pyphi.direction import Direction
441+
from pyphi.new_big_phi import sia, system_intrinsic_information
442+
443+
subsystem = self._noisy_copy_subsystem(0.8, (1, 1))
444+
with config.override(**self.II_CONFIG):
445+
sys_state = system_intrinsic_information(subsystem)
446+
result = sia(subsystem)
447+
448+
# Compute ii(s) from components
449+
ii_cause = min(
450+
float(sys_state.cause.intrinsic_information),
451+
float(result.intrinsic_differentiation[Direction.CAUSE]),
452+
)
453+
ii_effect = min(
454+
float(sys_state.effect.intrinsic_information),
455+
float(result.intrinsic_differentiation[Direction.EFFECT]),
456+
)
457+
ii_s = min(ii_cause, ii_effect)
458+
459+
# phi must equal ii(s), not GID(MIP)
460+
assert float(result.phi) == pytest.approx(ii_s, abs=1e-9)
461+
# GID(MIP) is larger than ii(s) — cap is binding
462+
gid_mip = min(float(result.cause.phi), float(result.effect.phi))
463+
assert gid_mip > ii_s + 1e-6
464+
465+
def test_partition_evaluation_uses_gid_only(self):
466+
"""Per-direction phi values at MIP are GID, not min(GID, i_diff).
467+
468+
With p=0.8: GID ≈ 0.868, i_diff ≈ 0.644. The cause/effect phi
469+
values on the SIA should be the GID values (un-folded), not the
470+
old min(GID, i_diff).
471+
"""
472+
from pyphi.direction import Direction
473+
from pyphi.new_big_phi import sia
474+
475+
subsystem = self._noisy_copy_subsystem(0.8, (1, 1))
476+
with config.override(**self.II_CONFIG):
477+
result = sia(subsystem)
478+
479+
i_diff = float(result.intrinsic_differentiation[Direction.CAUSE])
480+
cause_phi = float(result.cause.phi)
481+
effect_phi = float(result.effect.phi)
482+
483+
# cause/effect phi should be GID, which is LARGER than i_diff
484+
assert cause_phi > i_diff + 1e-6
485+
assert effect_phi > i_diff + 1e-6
486+
487+
def test_gid_distance_unaffected(self, s):
488+
"""GID-based computation is unchanged by the Eq. 23 logic.
489+
490+
The ii(s) cap and GID-only partition override only activate when
491+
REPERTOIRE_DISTANCE=INTRINSIC_INFORMATION.
492+
"""
493+
from pyphi.new_big_phi import sia
494+
495+
# Default config uses GENERALIZED_INTRINSIC_DIFFERENCE
496+
result = sia(s)
497+
assert float(result.phi) == pytest.approx(
498+
EXPECTED_PHI_VALUES["s"], abs=1e-9
499+
)
500+
501+
502+
# ============================================================================
503+
# Paper examples: Mayner, Marshall, & Tononi 2025 (arXiv:2510.03881)
504+
# ============================================================================
505+
506+
507+
class TestPaperExamples:
508+
"""Regression tests for the paper's worked examples.
509+
510+
These verify that PyPhi reproduces the analytical results from
511+
Mayner, Marshall, & Tononi 2025, "Intrinsic cause-effect power:
512+
the tradeoff between differentiation and specification."
513+
"""
514+
515+
II_CONFIG = dict(
516+
REPERTOIRE_DISTANCE="INTRINSIC_INFORMATION",
517+
REPERTOIRE_DISTANCE_SPECIFICATION="INTRINSIC_SPECIFICATION",
518+
REPERTOIRE_DISTANCE_DIFFERENTIATION="INTRINSIC_DIFFERENTIATION",
519+
)
520+
521+
@staticmethod
522+
def _monad_subsystem(p):
523+
"""Single-node system that stays in current state with probability p."""
524+
import numpy as np
525+
from pyphi import Network, Subsystem
526+
527+
tpm = np.array([[1 - p], [p]])
528+
cm = np.array([[1]])
529+
network = Network(tpm, cm=cm)
530+
return Subsystem(network, state=(1,))
531+
532+
def test_monad_intrinsic_information(self):
533+
"""Example 1, Eq. 27: ii(s) = min{p*log(2p), -log(p)}.
534+
535+
At p=0.744 (near the optimal): i_diff ≈ i_spec ≈ 0.427.
536+
The paper reports φ_s = 0.427 (Figure 2C).
537+
"""
538+
import numpy as np
539+
from pyphi.new_big_phi import system_intrinsic_information
540+
541+
p = 0.744
542+
subsystem = self._monad_subsystem(p)
543+
i_diff_expected = -np.log2(p)
544+
i_spec_expected = p * np.log2(2 * p)
545+
ii_expected = min(i_diff_expected, i_spec_expected)
546+
547+
with config.override(**self.II_CONFIG):
548+
sys_state = system_intrinsic_information(subsystem)
549+
# system_intrinsic_information uses INTRINSIC_SPECIFICATION,
550+
# so it returns i_spec
551+
i_spec_pyphi = float(sys_state.effect.intrinsic_information)
552+
assert i_spec_pyphi == pytest.approx(i_spec_expected, abs=1e-6)
553+
# Verify the analytical ii value matches the paper
554+
assert ii_expected == pytest.approx(0.427, abs=0.001)
555+
556+
@pytest.mark.parametrize(
557+
"p,i_diff_expected,i_spec_expected",
558+
[
559+
(0.744, 0.426625, 0.426591), # crossover point (Figure 2C)
560+
(0.9, 0.152003, 0.763197), # high determinism
561+
(0.6, 0.736966, 0.157821), # high noise
562+
],
563+
)
564+
def test_monad_i_diff_i_spec_tradeoff(self, p, i_diff_expected, i_spec_expected):
565+
"""Verify i_diff and i_spec values across the tradeoff curve (Figure 2C).
566+
567+
i_diff = -log2(p), i_spec = p*log2(2p) for a monad in its ON state.
568+
"""
569+
import numpy as np
570+
from pyphi import direction, metrics
571+
from pyphi.new_big_phi import system_intrinsic_information
572+
573+
subsystem = self._monad_subsystem(p)
574+
575+
with config.override(**self.II_CONFIG):
576+
sys_state = system_intrinsic_information(subsystem)
577+
i_spec = float(sys_state.effect.intrinsic_information)
578+
assert i_spec == pytest.approx(i_spec_expected, abs=1e-5)
579+
580+
# Compute i_diff from forward repertoire
581+
fr = subsystem.forward_repertoire(
582+
direction.Direction.EFFECT,
583+
subsystem.node_indices,
584+
subsystem.node_indices,
585+
None,
586+
)
587+
i_diff = float(
588+
metrics.distribution.intrinsic_differentiation(
589+
fr, fr, state=subsystem.proper_state
590+
)
591+
)
592+
assert i_diff == pytest.approx(i_diff_expected, abs=1e-5)

0 commit comments

Comments
 (0)