sofic 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. sofic/__init__.py +185 -0
  2. sofic/automata/__init__.py +207 -0
  3. sofic/automata/_config_simulation.py +40 -0
  4. sofic/automata/active.py +611 -0
  5. sofic/automata/alergia.py +222 -0
  6. sofic/automata/algorithms.py +376 -0
  7. sofic/automata/atomaton.py +58 -0
  8. sofic/automata/base.py +161 -0
  9. sofic/automata/buchi.py +23 -0
  10. sofic/automata/buchi_simulation.py +67 -0
  11. sofic/automata/canonical_dual.py +18 -0
  12. sofic/automata/canonical_extraction.py +122 -0
  13. sofic/automata/dfa.py +85 -0
  14. sofic/automata/dfasat.py +195 -0
  15. sofic/automata/edsm.py +219 -0
  16. sofic/automata/enumeration.py +44 -0
  17. sofic/automata/icdfa.py +421 -0
  18. sofic/automata/idfa.py +363 -0
  19. sofic/automata/languages/__init__.py +39 -0
  20. sofic/automata/languages/_quotient_utils.py +64 -0
  21. sofic/automata/languages/atoms.py +31 -0
  22. sofic/automata/languages/automaton_ops.py +243 -0
  23. sofic/automata/languages/base.py +67 -0
  24. sofic/automata/languages/operations.py +78 -0
  25. sofic/automata/languages/quotients.py +66 -0
  26. sofic/automata/languages/residuals.py +25 -0
  27. sofic/automata/learning.py +79 -0
  28. sofic/automata/nfa.py +39 -0
  29. sofic/automata/nwa.py +343 -0
  30. sofic/automata/nwa_simulation.py +56 -0
  31. sofic/automata/observation.py +40 -0
  32. sofic/automata/papni.py +301 -0
  33. sofic/automata/regex.py +128 -0
  34. sofic/automata/rfsa.py +35 -0
  35. sofic/automata/rpni.py +193 -0
  36. sofic/automata/subsequential.py +201 -0
  37. sofic/automata/transducer_operations.py +350 -0
  38. sofic/automata/transducer_simulation.py +150 -0
  39. sofic/automata/transducers.py +365 -0
  40. sofic/automata/unifilar.py +107 -0
  41. sofic/automata/vpa.py +1373 -0
  42. sofic/automata/vpa_simulation.py +53 -0
  43. sofic/base.py +153 -0
  44. sofic/core.py +47 -0
  45. sofic/examples/__init__.py +86 -0
  46. sofic/examples/epsilon_machines.py +1089 -0
  47. sofic/examples/processes.py +1491 -0
  48. sofic/examples/shifts.py +144 -0
  49. sofic/exceptions.py +33 -0
  50. sofic/generators/__init__.py +115 -0
  51. sofic/generators/_word_measures.py +94 -0
  52. sofic/generators/alternative_complexity.py +104 -0
  53. sofic/generators/base.py +327 -0
  54. sofic/generators/bidirectional_construction.py +717 -0
  55. sofic/generators/bidirectional_epsilon_machine.py +689 -0
  56. sofic/generators/block_convergence.py +668 -0
  57. sofic/generators/block_entropy.py +578 -0
  58. sofic/generators/channel_measures.py +75 -0
  59. sofic/generators/conversions.py +182 -0
  60. sofic/generators/directional_flow.py +245 -0
  61. sofic/generators/edge_emissions.py +36 -0
  62. sofic/generators/edge_machine.py +178 -0
  63. sofic/generators/epsilon_construction.py +193 -0
  64. sofic/generators/epsilon_inference.py +703 -0
  65. sofic/generators/epsilon_machine.py +557 -0
  66. sofic/generators/epsilon_transducer.py +168 -0
  67. sofic/generators/epsilon_transducer_construction.py +185 -0
  68. sofic/generators/epsilon_transducer_inference.py +499 -0
  69. sofic/generators/hmm_inference.py +719 -0
  70. sofic/generators/information_diagram.py +428 -0
  71. sofic/generators/lumping.py +447 -0
  72. sofic/generators/markov.py +100 -0
  73. sofic/generators/mealy.py +156 -0
  74. sofic/generators/measures.py +257 -0
  75. sofic/generators/minimal_generative_model.py +821 -0
  76. sofic/generators/mixed_state.py +250 -0
  77. sofic/generators/mixed_state_construction.py +163 -0
  78. sofic/generators/moore.py +75 -0
  79. sofic/generators/nmachine.py +78 -0
  80. sofic/generators/nmachine_construction.py +70 -0
  81. sofic/generators/pfa.py +100 -0
  82. sofic/generators/prob.py +291 -0
  83. sofic/generators/process_equivalence.py +207 -0
  84. sofic/generators/quasi_inference.py +74 -0
  85. sofic/generators/quasi_realization.py +97 -0
  86. sofic/generators/reversal.py +66 -0
  87. sofic/generators/stack_hmm.py +426 -0
  88. sofic/generators/stack_inference.py +509 -0
  89. sofic/generators/stationary.py +134 -0
  90. sofic/generators/stochastic.py +65 -0
  91. sofic/generators/synchronization.py +407 -0
  92. sofic/generators/topological_epsilon_enumeration.py +349 -0
  93. sofic/generators/words.py +226 -0
  94. sofic/graph.py +135 -0
  95. sofic/indexing.py +31 -0
  96. sofic/inference/__init__.py +45 -0
  97. sofic/inference/bayesian/__init__.py +68 -0
  98. sofic/inference/bayesian/comparison.py +199 -0
  99. sofic/inference/bayesian/counts.py +219 -0
  100. sofic/inference/bayesian/diversity.py +254 -0
  101. sofic/inference/bayesian/epsilon.py +270 -0
  102. sofic/inference/bayesian/hdp_hmm.py +340 -0
  103. sofic/inference/bayesian/markov.py +294 -0
  104. sofic/inference/bayesian/pymc_backend.py +71 -0
  105. sofic/inference/bayesian/stack_hmm.py +215 -0
  106. sofic/inference/model_selection.py +365 -0
  107. sofic/inference/spectral.py +564 -0
  108. sofic/operations.py +16 -0
  109. sofic/properties.py +339 -0
  110. sofic/serialization.py +450 -0
  111. sofic/shifts/__init__.py +48 -0
  112. sofic/shifts/algorithms.py +84 -0
  113. sofic/shifts/base.py +49 -0
  114. sofic/shifts/cover_construction.py +76 -0
  115. sofic/shifts/covers.py +47 -0
  116. sofic/shifts/dyck_algorithms.py +100 -0
  117. sofic/shifts/dyck_enumeration.py +275 -0
  118. sofic/shifts/markov_dyck.py +172 -0
  119. sofic/shifts/parry_construction.py +82 -0
  120. sofic/shifts/sft.py +104 -0
  121. sofic/shifts/sft_construction.py +52 -0
  122. sofic/shifts/sliding_block_code.py +156 -0
  123. sofic/shifts/sofic.py +111 -0
  124. sofic/shifts/sofic_dyck.py +110 -0
  125. sofic/shifts/sofic_relation.py +64 -0
  126. sofic/shifts/textile.py +104 -0
  127. sofic/shifts/tmc.py +46 -0
  128. sofic/shifts/tmc_construction.py +58 -0
  129. sofic/shifts/topological_anatomy.py +150 -0
  130. sofic/states.py +27 -0
  131. sofic/testing/__init__.py +8 -0
  132. sofic/testing/strategies.py +154 -0
  133. sofic/viz/__init__.py +16 -0
  134. sofic/viz/_context.py +345 -0
  135. sofic/viz/_edge.py +216 -0
  136. sofic/viz/_format.py +89 -0
  137. sofic/viz/_labels.py +34 -0
  138. sofic/viz/_names.py +17 -0
  139. sofic/viz/_rational.py +20 -0
  140. sofic/viz/_tikz_compile.py +177 -0
  141. sofic/viz/_tikz_format.py +122 -0
  142. sofic/viz/_tikz_layout.py +218 -0
  143. sofic/viz/assets/vaucanson.tikz +71 -0
  144. sofic/viz/graphviz.py +158 -0
  145. sofic/viz/idiagram.py +350 -0
  146. sofic/viz/tikz.py +381 -0
  147. sofic-0.1.0.dist-info/METADATA +444 -0
  148. sofic-0.1.0.dist-info/RECORD +150 -0
  149. sofic-0.1.0.dist-info/WHEEL +4 -0
  150. sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
@@ -0,0 +1,428 @@
1
+ """Five-variable information-anatomy I-diagram over ``Pr(S⁺₀, S⁻₀, X₀, S⁺₁, S⁻₁)``.
2
+
3
+ The signed I-measure (:cite:`yeung1991new`) of the five bidirectional random
4
+ variables has ``2⁵ − 1 = 31`` atoms. Each atom is the *conditional
5
+ co-information* of the variables that are "inside" the region given the ones
6
+ that are "outside". For a nonempty subset ``S`` of the five random variables,
7
+
8
+ .. math::
9
+
10
+ a_S = I\\!\\left(X_i : i \\in S \\;\\middle|\\; X_j : j \\notin S\\right),
11
+
12
+ which reduces to a conditional entropy when ``|S| = 1`` and to a (possibly
13
+ negative) conditional co-information otherwise.
14
+
15
+ Unifilarity forces ten of the 31 atoms to vanish identically. Of the remaining
16
+ twenty-one *generically nonzero* atoms, fourteen are the named taxonomy atoms of
17
+ :cite:`jurgens2026taxonomy` Table II; the other seven are cancelling partners of
18
+ shielded four-variable zeros (Theorem A / A′ and the classical ``q_μ`` refinement)
19
+ that Table II omits.
20
+
21
+ :func:`information_diagram` computes every atom, classifies it into an anatomy
22
+ role, attaches the Jurgens taxonomy label when one exists, and reports named
23
+ totals.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from itertools import combinations
30
+ from typing import Any, Literal
31
+
32
+ # Anatomy variable ordering; matches ``bidirectional_step_distribution``.
33
+ _S_PLUS_0, _S_MINUS_0, _X_0, _S_PLUS_1, _S_MINUS_1 = 0, 1, 2, 3, 4
34
+ _ALL_INDICES: tuple[int, ...] = (_S_PLUS_0, _S_MINUS_0, _X_0, _S_PLUS_1, _S_MINUS_1)
35
+
36
+ #: Display names for the five random variables, indexed as in the step joint.
37
+ VARIABLE_NAMES: tuple[str, ...] = ("S⁺₀", "S⁻₀", "X₀", "S⁺₁", "S⁻₁")
38
+
39
+ #: Fixed anatomy roles, in the order atoms are laid out (never sorted by value).
40
+ ROLE_ORDER: tuple[str, ...] = (
41
+ "r_gauge", # rμ: X₀ only (transient / pure output relabeling)
42
+ "r_fwd", # rμ: X₀ + next forward state
43
+ "r_rev", # rμ: X₀ + previous reverse state
44
+ "r_joint", # rμ: X₀ + both next states
45
+ "b_plus", # b⁺μ: present ∩ future ∖ past (forward binding zone)
46
+ "b_minus", # b⁻μ: present ∩ past ∖ future (reverse binding zone)
47
+ "q_mu", # classical qμ = I[S⁺₀:X₀:S⁻₁] (present ∩ past ∩ future)
48
+ "sigma_mu", # σμ: past↔future information bypassing the present
49
+ "chi_plus", # χ⁺ crypticity atoms (t.χ⁺ / p.χ⁺)
50
+ "chi_minus", # χ⁻ crypticity atoms (t.χ⁻ / p.χ⁻)
51
+ "structure", # leftover state-structure (always zero under unifilarity)
52
+ )
53
+
54
+ #: Colour / legend group for each fine role (several roles share a colour).
55
+ COLOR_GROUP: dict[str, str] = {
56
+ "r_gauge": "r_mu",
57
+ "r_fwd": "r_mu",
58
+ "r_rev": "r_mu",
59
+ "r_joint": "r_mu",
60
+ "b_plus": "b_plus",
61
+ "b_minus": "b_minus",
62
+ "q_mu": "q_mu",
63
+ "sigma_mu": "sigma_mu",
64
+ "chi_plus": "chi_plus",
65
+ "chi_minus": "chi_minus",
66
+ "structure": "structure",
67
+ }
68
+
69
+ #: Jurgens & Crutchfield (2026) Table II labels by Yeung-atom membership.
70
+ JURGENS_LABELS: dict[tuple[int, ...], str] = {
71
+ (2,): "t.rμ",
72
+ (1, 2): "p.r⁻μ",
73
+ (2, 3): "p.r⁺μ",
74
+ (1, 2, 3): "p.r±μ",
75
+ (2, 3, 4): "t.b⁺μ",
76
+ (1, 2, 3, 4): "p.b⁺μ",
77
+ (0, 1, 2): "t.b⁻μ",
78
+ (0, 1, 2, 3): "p.b⁻μ",
79
+ (0, 1, 2, 3, 4): "qμ",
80
+ (0, 1, 3, 4): "σμ",
81
+ (0,): "t.χ⁺",
82
+ (0, 3): "p.χ⁺",
83
+ (4,): "t.χ⁻",
84
+ (1, 4): "p.χ⁻",
85
+ }
86
+
87
+ #: Labels for the seven generically-nonzero atoms omitted from Table II.
88
+ #: These are the cancelling partners of shielded four-variable zeros.
89
+ EXTRA_LABELS: dict[tuple[int, ...], str] = {
90
+ (2, 4): "†b⁺μ gauge",
91
+ (1, 2, 4): "†b⁺μ rev",
92
+ (0, 2): "†b⁻μ gauge",
93
+ (0, 2, 3): "†b⁻μ fwd",
94
+ (0, 2, 4): "†qμ gauge",
95
+ (0, 1, 2, 4): "†qμ rev",
96
+ (0, 2, 3, 4): "†qμ fwd",
97
+ }
98
+
99
+ #: The 21 Yeung atoms that are not forced to zero by unifilarity / shielding
100
+ #: (Table II's fourteen plus the seven cancelling extras). Verified across the
101
+ #: structural-ephemeral zoo.
102
+ GENERICALLY_NONZERO: frozenset[tuple[int, ...]] = frozenset(
103
+ {
104
+ *JURGENS_LABELS,
105
+ *EXTRA_LABELS,
106
+ }
107
+ )
108
+
109
+ #: Zone symbol for plot / legend naming.
110
+ _ZONE_SYMBOL: dict[str, str | None] = {
111
+ "r_gauge": "rμ",
112
+ "r_fwd": "rμ",
113
+ "r_rev": "rμ",
114
+ "r_joint": "rμ",
115
+ "b_plus": "b⁺μ",
116
+ "b_minus": "b⁻μ",
117
+ "q_mu": "qμ",
118
+ "sigma_mu": "σμ",
119
+ "chi_plus": "χ⁺",
120
+ "chi_minus": "χ⁻",
121
+ "structure": None,
122
+ }
123
+
124
+ #: Maps each role to the ``totals`` key holding its aggregate value.
125
+ ROLE_TOTAL_KEY: dict[str, str] = {
126
+ "r_gauge": "r_gauge",
127
+ "r_fwd": "r_fwd",
128
+ "r_rev": "r_rev",
129
+ "r_joint": "r_joint",
130
+ "b_plus": "b_plus",
131
+ "b_minus": "b_minus",
132
+ "q_mu": "q_mu",
133
+ "sigma_mu": "sigma_mu",
134
+ "chi_plus": "chi_plus",
135
+ "chi_minus": "chi_minus",
136
+ "structure": "structure",
137
+ }
138
+
139
+
140
+ def _require_dit() -> Any:
141
+ from sofic.generators.measures import require_dit
142
+
143
+ return require_dit("information diagrams")
144
+
145
+
146
+ def _branch_name(indices: tuple[int, ...]) -> str:
147
+ next_fwd = _S_PLUS_1 in indices
148
+ prev_rev = _S_MINUS_0 in indices
149
+ if next_fwd and prev_rev:
150
+ return "joint"
151
+ if next_fwd:
152
+ return "fwd"
153
+ if prev_rev:
154
+ return "rev"
155
+ return "gauge"
156
+
157
+
158
+ def _classify(indices: tuple[int, ...]) -> str:
159
+ """Assign an I-diagram atom to exactly one anatomy role by its membership."""
160
+ inside = set(indices)
161
+ present = _X_0 in inside
162
+ past = _S_PLUS_0 in inside
163
+ future = _S_MINUS_1 in inside
164
+ if present:
165
+ if past and future:
166
+ return "q_mu"
167
+ if future:
168
+ return "b_plus"
169
+ if past:
170
+ return "b_minus"
171
+ branch = _branch_name(indices)
172
+ return {
173
+ "gauge": "r_gauge",
174
+ "fwd": "r_fwd",
175
+ "rev": "r_rev",
176
+ "joint": "r_joint",
177
+ }[branch]
178
+ if past and future:
179
+ return "sigma_mu"
180
+ if past:
181
+ return "chi_plus"
182
+ if future:
183
+ return "chi_minus"
184
+ return "structure"
185
+
186
+
187
+ def _atom_symbol(indices: tuple[int, ...]) -> str | None:
188
+ """A ``{zone} {branch}`` anatomy name, e.g. ``rμ gauge`` or ``χ⁺ transient``."""
189
+ role = _classify(indices)
190
+ zone = _ZONE_SYMBOL[role]
191
+ if zone is None:
192
+ return None
193
+ if role in ("chi_plus", "chi_minus"):
194
+ # Crypticity: transient = singleton state, persistent = correlated with next.
195
+ persistent = (_S_PLUS_1 in indices) if role == "chi_plus" else (_S_MINUS_0 in indices)
196
+ return f"{zone} {'persistent' if persistent else 'transient'}"
197
+ if role == "sigma_mu":
198
+ return f"{zone} {_branch_name(indices)}"
199
+ return f"{zone} {_branch_name(indices)}"
200
+
201
+
202
+ def _taxonomy_label(indices: tuple[int, ...]) -> str | None:
203
+ """Jurgens Table II label, or a †-marked extra label; ``None`` if always zero."""
204
+ if indices in JURGENS_LABELS:
205
+ return JURGENS_LABELS[indices]
206
+ if indices in EXTRA_LABELS:
207
+ return EXTRA_LABELS[indices]
208
+ return None
209
+
210
+
211
+ def _ordering_key(indices: tuple[int, ...]) -> tuple[int, int, tuple[int, ...]]:
212
+ return (ROLE_ORDER.index(_classify(indices)), len(indices), indices)
213
+
214
+
215
+ def _coerce(dist: Any, value: Any) -> Any:
216
+ """Return a float for numeric distributions, the raw expression if symbolic."""
217
+ if hasattr(dist, "is_symbolic") and dist.is_symbolic():
218
+ return value
219
+ return float(value)
220
+
221
+
222
+ def _is_zero(value: Any, tol: float) -> bool:
223
+ try:
224
+ return abs(float(value)) <= tol
225
+ except (TypeError, ValueError):
226
+ return False # symbolic expression: keep it
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class IDiagramAtom:
231
+ """One atom of the five-variable information diagram.
232
+
233
+ Attributes:
234
+ indices: Random-variable indices that are *inside* the region.
235
+ variables: The matching :data:`VARIABLE_NAMES`.
236
+ value: The atom's I-measure (a conditional co-information); ``float`` for
237
+ numeric machines, a sympy expression for symbolic ones.
238
+ role: One of :data:`ROLE_ORDER`.
239
+ """
240
+
241
+ indices: tuple[int, ...]
242
+ variables: tuple[str, ...]
243
+ value: Any
244
+ role: str
245
+
246
+ @property
247
+ def label(self) -> str:
248
+ """Set-builder label, e.g. ``{X₀,S⁺₁}``."""
249
+ return "{" + ",".join(self.variables) + "}"
250
+
251
+ @property
252
+ def conditional_expression(self) -> str:
253
+ """The atom's I-measure as a conditional co-information, e.g.
254
+ ``I[X₀:S⁺₁|S⁺₀,S⁻₀,S⁻₁]`` (a conditional entropy ``H[…|…]`` when a
255
+ single variable is inside). This is the term's exact name."""
256
+ inside = ":".join(self.variables)
257
+ outside = ",".join(VARIABLE_NAMES[i] for i in _ALL_INDICES if i not in self.indices)
258
+ head = "H" if len(self.indices) == 1 else "I"
259
+ body = inside if not outside else f"{inside}|{outside}"
260
+ return f"{head}[{body}]"
261
+
262
+ @property
263
+ def symbol(self) -> str | None:
264
+ """A ``{zone} {branch}`` anatomy name (e.g. ``rμ gauge``, ``χ⁺ transient``)."""
265
+ return _atom_symbol(self.indices)
266
+
267
+ @property
268
+ def jurgens_label(self) -> str | None:
269
+ """Taxonomy label from :cite:`jurgens2026taxonomy` Table II, or a
270
+ ``†``-marked extra for the seven cancelling partners omitted there."""
271
+ return _taxonomy_label(self.indices)
272
+
273
+ @property
274
+ def color_group(self) -> str:
275
+ """Coarse colour group (``r_mu``, ``b_plus``, ``b_minus``, …)."""
276
+ return COLOR_GROUP[self.role]
277
+
278
+ def value_float(self) -> float:
279
+ """The atom value as a float (raises for symbolic values)."""
280
+ return float(self.value)
281
+
282
+
283
+ @dataclass
284
+ class InformationDiagram:
285
+ """The full five-variable information diagram with anatomy roles and totals.
286
+
287
+ Attributes:
288
+ atoms: Retained atoms in fixed role order (see ``atoms`` argument of
289
+ :func:`information_diagram`).
290
+ variable_names: The five variable display names.
291
+ totals: Named anatomy quantities and per-role sums.
292
+ """
293
+
294
+ atoms: list[IDiagramAtom]
295
+ variable_names: tuple[str, ...]
296
+ totals: dict[str, Any]
297
+
298
+ def by_role(self) -> dict[str, list[IDiagramAtom]]:
299
+ """Group the retained atoms by role, in :data:`ROLE_ORDER`."""
300
+ groups: dict[str, list[IDiagramAtom]] = {role: [] for role in ROLE_ORDER}
301
+ for atom in self.atoms:
302
+ groups[atom.role].append(atom)
303
+ return groups
304
+
305
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
306
+ t = self.totals
307
+ return (
308
+ "InformationDiagram("
309
+ f"atoms={len(self.atoms)}, "
310
+ f"h_mu={t['h_mu']:.4f}, r_mu={t['r_mu']:.4f}, "
311
+ f"b_mu={t['b_mu']:.4f}, sigma_mu={t['sigma_mu']:.4f})"
312
+ )
313
+
314
+
315
+ def _resolve_step_distribution(source: Any) -> Any:
316
+ """Extract the five-variable step joint from a machine or pass a distribution."""
317
+ if hasattr(source, "step_distribution"):
318
+ return source.step_distribution()
319
+ if hasattr(source, "to_bidirectional"):
320
+ return source.to_bidirectional().step_distribution()
321
+ return source
322
+
323
+
324
+ def _compute_totals(records: list[tuple[tuple[int, ...], Any]]) -> dict[str, Any]:
325
+ role_sum: dict[str, Any] = dict.fromkeys(ROLE_ORDER, 0)
326
+ for indices, value in records:
327
+ role_sum[_classify(indices)] += value
328
+ r_mu = role_sum["r_gauge"] + role_sum["r_fwd"] + role_sum["r_rev"] + role_sum["r_joint"]
329
+ b_plus = role_sum["b_plus"]
330
+ b_minus = role_sum["b_minus"]
331
+ q_mu = role_sum["q_mu"]
332
+ totals: dict[str, Any] = dict(role_sum)
333
+ totals.update(
334
+ {
335
+ "r_mu": r_mu,
336
+ "b_mu": b_plus, # stationary: b⁺μ = b⁻μ = b_μ
337
+ "b_plus": b_plus,
338
+ "b_minus": b_minus,
339
+ "q_mu": q_mu,
340
+ "sigma_mu": role_sum["sigma_mu"],
341
+ "chi_plus": role_sum["chi_plus"],
342
+ "chi_minus": role_sum["chi_minus"],
343
+ # Classical predicted information I[X₀:S⁺₀] = b⁻ zone + qμ zone.
344
+ "rho_mu": b_minus + q_mu,
345
+ "h_mu": r_mu + b_plus,
346
+ "H[X0]": r_mu + b_plus + b_minus + q_mu,
347
+ "h_imc": b_plus + role_sum["r_fwd"] + role_sum["r_joint"],
348
+ "h_imc_reverse": b_plus + role_sum["r_rev"] + role_sum["r_joint"],
349
+ }
350
+ )
351
+ return totals
352
+
353
+
354
+ def information_diagram(
355
+ source: Any,
356
+ *,
357
+ show_zero: bool = False,
358
+ atoms: Literal["process", "generic", "all"] | None = None,
359
+ tol: float = 1e-9,
360
+ ) -> InformationDiagram:
361
+ """Compute the five-variable information-anatomy I-diagram.
362
+
363
+ Args:
364
+ source: A :class:`~sofic.generators.bidirectional_epsilon_machine.BidirectionalEpsilonMachine`,
365
+ an :class:`~sofic.generators.epsilon_machine.EpsilonMachine` (converted
366
+ via ``to_bidirectional``), or a five-variable ``dit`` distribution
367
+ whose random variables are ordered ``(S⁺₀, S⁻₀, X₀, S⁺₁, S⁻₁)``.
368
+ show_zero: Deprecated alias — if ``True`` and ``atoms`` is omitted,
369
+ keep every atom (``atoms="all"``). Prefer the ``atoms`` argument.
370
+ atoms: Which atoms to retain:
371
+
372
+ * ``"process"`` (default) — only numerically nonzero atoms for this
373
+ process;
374
+ * ``"generic"`` — the 21 generically nonzero membership sets
375
+ (Table II's 14 plus the 7 cancelling extras), including zeros for
376
+ this process;
377
+ * ``"all"`` — all 31 nonempty Yeung atoms.
378
+ tol: Magnitude below which a numeric atom is treated as zero.
379
+
380
+ Returns:
381
+ An :class:`InformationDiagram` with the retained atoms in fixed
382
+ :data:`ROLE_ORDER` layout and the named anatomy ``totals``.
383
+ """
384
+ _require_dit()
385
+ from dit.multivariate import coinformation
386
+
387
+ if atoms is None:
388
+ atoms = "all" if show_zero else "process"
389
+ if atoms not in ("process", "generic", "all"):
390
+ raise ValueError(f"atoms must be 'process', 'generic', or 'all'; got {atoms!r}")
391
+
392
+ dist = _resolve_step_distribution(source)
393
+ length = dist.outcome_length() if hasattr(dist, "outcome_length") else None
394
+ if length is not None and length != len(_ALL_INDICES):
395
+ raise ValueError(
396
+ f"information_diagram expects a 5-variable step distribution "
397
+ f"(S⁺₀, S⁻₀, X₀, S⁺₁, S⁻₁); got outcome length {length}."
398
+ )
399
+
400
+ records: list[tuple[tuple[int, ...], Any]] = []
401
+ for size in range(1, len(_ALL_INDICES) + 1):
402
+ for subset in combinations(_ALL_INDICES, size):
403
+ crvs = [i for i in _ALL_INDICES if i not in subset]
404
+ value = coinformation(dist, rvs=[[i] for i in subset], crvs=crvs)
405
+ records.append((subset, _coerce(dist, value)))
406
+
407
+ records.sort(key=lambda record: _ordering_key(record[0]))
408
+
409
+ retained: list[IDiagramAtom] = []
410
+ for subset, value in records:
411
+ if atoms == "process" and _is_zero(value, tol):
412
+ continue
413
+ if atoms == "generic" and subset not in GENERICALLY_NONZERO:
414
+ continue
415
+ retained.append(
416
+ IDiagramAtom(
417
+ indices=subset,
418
+ variables=tuple(VARIABLE_NAMES[i] for i in subset),
419
+ value=value,
420
+ role=_classify(subset),
421
+ )
422
+ )
423
+
424
+ return InformationDiagram(
425
+ atoms=retained,
426
+ variable_names=VARIABLE_NAMES,
427
+ totals=_compute_totals(records),
428
+ )