|
| 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. |
0 commit comments