Skip to content

Commit b993e6d

Browse files
committed
Merge branch 'main' into wave7-landscape-optimizer
2 parents 447c44c + d1d85ec commit b993e6d

13 files changed

Lines changed: 1112 additions & 55 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
`plot_ces` now renders cause-effect structures backed by analytically-computed
2+
relations: pass `max_relations=N` to draw the N strongest relations by φ_r
3+
(node sizes and the spectrum view stay exact over the full structure).
4+
`CauseEffectStructure.diff` now reports relation statistic deltas (Σφ_r, count,
5+
and the per-degree spectrum) on both relation backends, alongside per-relation
6+
gained/lost rows where relations are enumerable.

docs/superpowers/plans/2026-07-11-relations-viz-diff-followups.md

Lines changed: 704 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Relations follow-ups: analytical-safe visualization and diff
2+
3+
**Date:** 2026-07-11
4+
**Status:** approved (design)
5+
**Branch:** `wave7-relations-followups`
6+
7+
## Problem
8+
9+
The relations query surface (merged 2026-07-11, `b3e8ec49`) added an
10+
`AnalyticalRelations` backend, selected by
11+
`config.formalism.iit.relation_computation = "ANALYTICAL"`. It answers moments,
12+
degree spectrum, counts, sums, and a lazy `strongest(k)` stream in closed form,
13+
without ever enumerating the relation set — which for large structures (Fig 6D:
14+
~1.5M relations) cannot be materialized.
15+
16+
Two consumers still assume relations are **enumerable** (a `ConcreteRelations`
17+
frozenset) and break, or silently degrade, on the analytical backend:
18+
19+
1. **Visualization**`pyphi/visualize/projection/__init__.py::project_ces`
20+
iterates `ces.relations` to build edges (line ~269) and reads
21+
`ces.relations.faces_by_degree` (line ~308, via `_faces`). Neither `__iter__`
22+
nor `faces_by_degree` exists on `AnalyticalRelations`, so projecting an
23+
analytical cause-effect structure raises.
24+
25+
2. **Diff**`pyphi/models/ces.py::CauseEffectStructure._changes` compares
26+
relations by `set(self.relations)` / `set(other.relations)`, guarded with
27+
`hasattr(..., "__iter__")`. For analytical relations the guard falls through
28+
to `set()`, so the diff silently reports **zero** relation changes even when
29+
Σφ_r, relation count, or the degree spectrum differ.
30+
31+
## Approach: Hybrid
32+
33+
Statistics (closed-form, available on both backends) are the always-present
34+
relation summary. Per-relation detail (edge list, gained/lost rows) is retained
35+
wherever the relations are enumerable, so the concrete path loses nothing.
36+
37+
## 1. Visualization
38+
39+
`project_ces(ces, node_labels=None, max_relations=None)` gains a
40+
`max_relations: int | None` parameter, threaded from both entry points that call
41+
it in `pyphi/visualize/__init__.py`: `plot_ces` and `highlight_phi_fold` (the
42+
latter over `AnalyticalFoldRelations`, which also implements `strongest`).
43+
44+
- **Edges** come from `ces.relations.strongest(k=max_relations)` instead of
45+
iterating `ces.relations`. `Relation` already exposes `.mechanisms`, `.phi`,
46+
`.purview`, and `len()`; edge construction is otherwise unchanged.
47+
- **Faces** are derived from the same top-k `Relation` objects via each
48+
relation's `.faces`, replacing the concrete-only `faces_by_degree` path. A
49+
single traversal of `strongest(k)` produces both edges and faces, keeping the
50+
two consistent (faces belong to rendered relations only).
51+
- **`k=None`** → the base `Relations.strongest` yields every relation (now in
52+
φ-descending order). For `ConcreteRelations` the rendered content is unchanged.
53+
- **Analytical + `k=None`** raises `ValueError`: the analytical relation set is
54+
unbounded in practice, and silently drawing the top-N of millions would
55+
misrepresent the structure as complete. The message directs the caller to pass
56+
`max_relations`.
57+
- **Node marker size** (`DistinctionNode.sum_phi_relations`) stays faithful to
58+
the full structure: it is each distinction's true incident Σφ_r over *all*
59+
relations touching it, independent of `max_relations`. Sizing from only the
60+
rendered top-k would make two distinctions look equal when one carries far
61+
more binding outside the cap — the same misrepresentation the uncapped-error
62+
guards against. The value is supplied by a new backend method (below), so it
63+
is exact on the concrete backend and closed-form (no enumeration) on the
64+
analytical one.
65+
66+
### New method: `Relations.sum_phi_by_distinction`
67+
68+
`Relations.sum_phi_by_distinction(distinctions) -> tuple[float, ...]` returns
69+
each distinction's incident Σφ_r (the Σφ_r over relations containing it),
70+
aligned to the given distinction order.
71+
72+
- **Base / `ConcreteRelations`**: one pass over the relation set, adding each
73+
relation's φ_r to every relatum it contains. Identical to the sum the current
74+
projector derives from the edge list when uncapped.
75+
- **`AnalyticalRelations`**: closed form. A distinction's incident sum is
76+
`total − Σφ_r(relations avoiding it)`; all n are computed in a single pass
77+
over the atom incidence index, without enumerating relations. (This is the
78+
quantity `AnalyticalFoldRelations` already exposes as a single-distinction
79+
fold's Σφ_r; the batch method shares one traversal instead of n folds.)
80+
81+
The projector calls this for node sizing, so it needs no `isinstance` branch.
82+
83+
### Spectrum view
84+
85+
`project_ces` is called for every view, including `"spectrum"`, whose renderer
86+
(`render/spectrum.py`) currently builds its per-degree count/Σφ bars by iterating
87+
`projection.edges`. Under a relation cap those bars would show only the top-k —
88+
a silently truncated census, the same failure mode faithful sizing avoids. The
89+
spectrum is a closed-form statistic, so:
90+
91+
- `CESProjection` gains a `degree_spectrum: dict[int, tuple[int, float]]` field,
92+
populated from `ces.relations.degree_spectrum()` (closed-form on both
93+
backends).
94+
- `render/spectrum.py` reads `projection.degree_spectrum` instead of iterating
95+
edges, making the spectrum exact and independent of `max_relations`.
96+
97+
The lattice, scatter, matrix, and hypergraph views legitimately render the
98+
strongest relations, so they are unchanged beyond consuming the capped edge set.
99+
100+
## 2. Diff
101+
102+
`CauseEffectStructure._changes` (`pyphi/models/ces.py`) replaces the
103+
enumerate-or-empty relation block:
104+
105+
- **Always** compute closed-form relation statistics on both sides and emit one
106+
`Change` per statistic that differs (floats compared with `numerics.eq`):
107+
- `"relation_sum_phi"``a`/`b` = Σφ_r (`relations.sum_phi()`)
108+
- `"relation_count"``a`/`b` = total count (`relations.num_relations()`)
109+
- `"relation_degree"`, keyed by degree — `a`/`b` = `(count, Σφ)` per degree
110+
present on either side (`relations.degree_spectrum()`)
111+
112+
Rows are emitted only where the value actually differs.
113+
- **Additionally**, when both relation objects are enumerable
114+
(`hasattr(..., "__iter__")`), keep the existing per-relation
115+
`relation_gained` / `relation_lost` rows.
116+
117+
The new `Change.kind` values are additive. `Change` and `ResultDiff._describe` /
118+
`to_pandas` already render arbitrary `(kind, key, a, b)` rows, so no structural
119+
change to `models/diff.py` is required.
120+
121+
## Interfaces touched
122+
123+
- `pyphi/visualize/projection/__init__.py``project_ces` signature + edge/face
124+
construction; `_faces` reworked to take relations from `strongest(k)`;
125+
`CESProjection` gains `degree_spectrum`; the now-dead `_sum_phi_relations`
126+
helper is removed.
127+
- `pyphi/visualize/__init__.py``plot_ces` and `highlight_phi_fold` gain and
128+
forward `max_relations`.
129+
- `pyphi/visualize/render/spectrum.py` — reads `projection.degree_spectrum`.
130+
- `pyphi/models/ces.py``_changes` relation block.
131+
- `pyphi/relations.py` — new `Relations.sum_phi_by_distinction` (base, iterating)
132+
and `AnalyticalRelations.sum_phi_by_distinction` (closed-form override).
133+
134+
`models/diff.py` and the other renderers (lattice, scatter, matrix, hypergraph)
135+
are unchanged; they already provide the generic `Change` rendering and consume
136+
the capped edge set as intended.
137+
138+
## Testing
139+
140+
- **Viz:** projecting a small concrete CES is unchanged (golden on edges/faces).
141+
Under `relation_computation="ANALYTICAL"`, `project_ces(ces,
142+
max_relations=k)` renders the top-k, and `project_ces(ces)` (no cap) raises
143+
`ValueError`. Parity: the mechanism/φ set of `strongest(k)` edges is a subset
144+
of the concrete edge set for the same CES; and analytical
145+
`sum_phi_by_distinction` equals the concrete per-distinction incident Σφ_r,
146+
distinction by distinction, on a small network.
147+
- **Spectrum:** `project_ces` carries a `degree_spectrum` equal to
148+
`ces.relations.degree_spectrum()`; the value is identical whether or not a cap
149+
is applied, and matches between the concrete and analytical backends.
150+
- **Diff:** two cause-effect structures computed under `ANALYTICAL` config now
151+
produce nonzero `relation_sum_phi` / `relation_count` / `relation_degree`
152+
deltas where they differ (zero today). A concrete diff still lists per-relation
153+
`relation_gained` / `relation_lost` **and** now carries the statistic deltas.
154+
- Changelog fragment under `changelog.d/`.
155+
156+
## Out of scope
157+
158+
- Any change to the lattice, scatter, matrix, or hypergraph renderers (only
159+
`render/spectrum.py` changes, to read the closed-form spectrum).
160+
- Any change to relation computation beyond the additive
161+
`sum_phi_by_distinction` query.
162+
- ROADMAP N6/N24 are already landed; this is a follow-up, not a new roadmap row.

pyphi/models/ces.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -414,20 +414,47 @@ def _changes(self, other) -> tuple[Change, ...]:
414414
changes.append(
415415
Change("distinction_changed", mech, a_value=da.phi, b_value=db.phi)
416416
)
417-
a_rels = (
418-
set(self.relations) # pyright: ignore[reportArgumentType]
419-
if hasattr(self.relations, "__iter__")
420-
else set()
421-
)
422-
b_rels = set(other.relations) if hasattr(other.relations, "__iter__") else set()
423-
changes.extend(
424-
Change("relation_lost", tuple(sorted(r.mechanisms)), a_value=r.phi)
425-
for r in a_rels - b_rels
426-
)
427-
changes.extend(
428-
Change("relation_gained", tuple(sorted(r.mechanisms)), b_value=r.phi)
429-
for r in b_rels - a_rels
430-
)
417+
a_rels, b_rels = self.relations, other.relations
418+
if not numerics.eq(float(a_rels.sum_phi()), float(b_rels.sum_phi())):
419+
changes.append(
420+
Change(
421+
"relation_sum_phi",
422+
None,
423+
a_value=a_rels.sum_phi(),
424+
b_value=b_rels.sum_phi(),
425+
)
426+
)
427+
if a_rels.num_relations() != b_rels.num_relations():
428+
changes.append(
429+
Change(
430+
"relation_count",
431+
None,
432+
a_value=a_rels.num_relations(),
433+
b_value=b_rels.num_relations(),
434+
)
435+
)
436+
a_spec, b_spec = a_rels.degree_spectrum(), b_rels.degree_spectrum()
437+
for degree in sorted(a_spec.keys() | b_spec.keys()):
438+
av, bv = a_spec.get(degree), b_spec.get(degree)
439+
same = (
440+
av is not None
441+
and bv is not None
442+
and av[0] == bv[0]
443+
and numerics.eq(av[1], bv[1])
444+
)
445+
if not same:
446+
changes.append(Change("relation_degree", degree, a_value=av, b_value=bv))
447+
if hasattr(a_rels, "__iter__") and hasattr(b_rels, "__iter__"):
448+
a_set = set(a_rels) # pyright: ignore[reportArgumentType] # iterability guarded above
449+
b_set = set(b_rels) # pyright: ignore[reportArgumentType] # iterability guarded above
450+
changes.extend(
451+
Change("relation_lost", tuple(sorted(r.mechanisms)), a_value=r.phi)
452+
for r in a_set - b_set
453+
)
454+
changes.extend(
455+
Change("relation_gained", tuple(sorted(r.mechanisms)), b_value=r.phi)
456+
for r in b_set - a_set
457+
)
431458
return tuple(changes)
432459

433460
def diff(self, other) -> ResultDiff:

pyphi/models/diff.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ class Change:
2828
2929
``kind`` is a stable machine key (``"distinction_gained"`` /
3030
``"distinction_lost"`` / ``"distinction_changed"`` / ``"relation_gained"`` /
31-
``"relation_lost"`` / ``"link_gained"`` / ``"link_lost"`` /
31+
``"relation_lost"`` / ``"relation_sum_phi"`` / ``"relation_count"`` /
32+
``"relation_degree"`` / ``"link_gained"`` / ``"link_lost"`` /
3233
``"link_changed"`` / ``"purview_changed"``); ``key`` identifies the element
3334
(mechanism, relata, or link); ``a_value`` / ``b_value`` are the per-side
3435
quantities (``None`` on the side where the element is absent); ``tone`` is

pyphi/relations.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,24 @@ def degree_spectrum(self) -> dict[int, tuple[int, float]]:
465465
for degree in sorted(counts)
466466
}
467467

468+
def sum_phi_by_distinction(self, distinctions) -> tuple[float, ...]:
469+
"""Return each distinction's incident Σφ_r, aligned to ``distinctions``.
470+
471+
A distinction's incident Σφ_r is the sum of φ_r over every relation
472+
that contains it, including its self-relation. The result is a tuple
473+
parallel to ``distinctions``; a distinction that no relation reaches
474+
contributes ``0.0``.
475+
"""
476+
position = {tuple(d.mechanism): i for i, d in enumerate(distinctions)}
477+
sums = [0.0] * len(position)
478+
for relation in self: # type: ignore[attr-defined] # iterable in subclasses
479+
phi = float(relation.phi)
480+
for mechanism in relation.mechanisms:
481+
index = position.get(tuple(mechanism))
482+
if index is not None:
483+
sums[index] += phi
484+
return tuple(sums)
485+
468486
def max_phi(self) -> float:
469487
"""Return the maximum φ_r over all relations, or ``0.0`` if empty."""
470488
# numerics: exact — the reported maximum, not a tolerant selection.
@@ -890,6 +908,26 @@ def degree_spectrum(self) -> dict[int, tuple[int, float]]:
890908
spectrum[degree] = (count, self.sum_phi_of_degree(degree))
891909
return spectrum
892910

911+
def sum_phi_by_distinction(self, distinctions) -> tuple[float, ...]:
912+
"""Return each distinction's incident Σφ_r in closed form.
913+
914+
A relation either contains a given distinction or does not, so its
915+
incident Σφ_r is ``total − Σφ_r(relations avoiding it)``: the full
916+
total differenced against the total over the remaining distinctions.
917+
No relations are enumerated. The result is parallel to ``distinctions``.
918+
"""
919+
from pyphi.models.distinctions import ResolvedDistinctions
920+
921+
total = self.sum_phi()
922+
result = []
923+
for distinction in distinctions:
924+
mechanism = tuple(distinction.mechanism)
925+
others = ResolvedDistinctions(
926+
d for d in self.distinctions if tuple(d.mechanism) != mechanism
927+
)
928+
result.append(total - AnalyticalRelations(others).sum_phi())
929+
return tuple(result)
930+
893931
def max_phi(self) -> float:
894932
"""Return the maximum φ_r, scanning only pairs and self-relations.
895933

pyphi/visualize/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def plot_ces(
5959
show=None,
6060
degrees=None,
6161
star_min_degree=None,
62+
max_relations=None,
6263
):
6364
"""Plot a :class:`~pyphi.models.ces.CauseEffectStructure`.
6465
@@ -135,6 +136,11 @@ def plot_ces(
135136
(degree-2 lines, degree-3 triangles). Must be 2, 3, or 4. ``2`` (the
136137
default) draws every face as a star; ``4`` keeps degree-2 lines and
137138
degree-3 triangles.
139+
max_relations : int, optional
140+
Render only the strongest ``max_relations`` relations by φ_r. Required
141+
when the structure's relations are computed analytically (the set is
142+
not enumerable); node sizes and the spectrum view remain exact
143+
regardless. If None, every relation is rendered.
138144
139145
Raises
140146
------
@@ -151,7 +157,7 @@ def plot_ces(
151157
"use highlight_phi_fold(fold) to render a fold against its parent "
152158
"structure"
153159
)
154-
projection = project_ces(ces_, node_labels=node_labels)
160+
projection = project_ces(ces_, node_labels=node_labels, max_relations=max_relations)
155161
if view == "lattice":
156162
from .render.lattice import render_lattice
157163

@@ -208,6 +214,7 @@ def highlight_phi_fold(
208214
fig=None,
209215
geometry=None,
210216
show=None,
217+
max_relations=None,
211218
):
212219
"""Plot a cause-effect structure dimmed, highlighting a phi-fold.
213220
@@ -235,6 +242,9 @@ def highlight_phi_fold(
235242
Layout knobs.
236243
show : tuple of str, optional
237244
Element classes to draw.
245+
max_relations : int, optional
246+
Render only the strongest ``max_relations`` relations by φ_r; required
247+
for analytically-computed relations.
238248
239249
Raises
240250
------
@@ -254,7 +264,7 @@ def highlight_phi_fold(
254264
phi_fold = ces_
255265
ces_ = phi_fold.parent
256266

257-
projection = project_ces(ces_, node_labels=node_labels)
267+
projection = project_ces(ces_, node_labels=node_labels, max_relations=max_relations)
258268
dimmed = dataclasses.replace(
259269
theme,
260270
colorscale="Greys",

0 commit comments

Comments
 (0)