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,1089 @@
1
+ """Constructors for canonical ε-machines used in computational mechanics.
2
+
3
+ References
4
+ ----------
5
+ - Even, ABC, SNS, NP2: Jurgens & Marzen, arXiv:1008.4182 (sync paper).
6
+ - Golden mean, butterfly, restricted golden mean, Nemo: Mahoney, Ellison &
7
+ Crutchfield, arXiv:0905.4787 (IACP); butterfly/Nemo transition tables in
8
+ Mahoney et al., arXiv:0906.5099.
9
+ - Reversible / Fig.~9 example: Ellison, Mahoney, James & Crutchfield,
10
+ arXiv:1107.2168.
11
+ - Golden-mean Markov chain (forbid ``00``): Ellison et al., arXiv:0905.3587.
12
+ - Golden-mean shift (forbid ``11``, Parry max-entropy): standard symbolic
13
+ dynamics; see e.g. Ellison et al., arXiv:1107.2168 Fig.~2.
14
+ - Tent map (Misiurewicz point): James, Burke & Crutchfield (2013), supplement
15
+ to *Chaos Forgets and Remembers*; Figs.~6--8. The ``partition`` family reads
16
+ the same dynamics through all four generating partitions built from the
17
+ critical point and its two order-1 preimages; only the kneading partition
18
+ appears in that paper's figures, so the three refinements are derived from the
19
+ interval Markov chain instead.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ from collections.abc import Hashable, Mapping, Sequence
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+
30
+ from sofic.generators.epsilon_machine import EpsilonMachine
31
+ from sofic.generators.mealy import MealyHMM
32
+ from sofic.graph import ATTR_EMISSION, ATTR_FUTURE_SYMBOL, ATTR_PROB, TransitionGraph
33
+ from sofic.shifts.tmc import TopologicalMarkovChain
34
+ from sofic.states import sequential_labels
35
+
36
+
37
+ def _stationary_distribution(
38
+ states: Sequence[Hashable],
39
+ symbol_matrices: Mapping[Any, np.ndarray],
40
+ ) -> dict[Hashable, Any]:
41
+ from sofic.generators.prob import as_prob, has_symbolic
42
+ from sofic.generators.stationary import stationary_distribution_from_transition
43
+
44
+ transition = sum(symbol_matrices.values())
45
+ pi = stationary_distribution_from_transition(transition)
46
+ if pi.dtype == object or has_symbolic(pi.ravel()):
47
+ return {states[i]: as_prob(pi[i]) for i in range(len(states))}
48
+ return {states[i]: float(pi[i]) for i in range(len(states))}
49
+
50
+
51
+ def from_symbol_matrices(
52
+ states: Sequence[Hashable],
53
+ symbols: Sequence[Any],
54
+ matrices: Mapping[Any, np.ndarray],
55
+ *,
56
+ initial_distribution: Mapping[Hashable, Any] | None = None,
57
+ ) -> EpsilonMachine:
58
+ """Build an ε-machine from edge-labeled transition matrices ``T^(x)``.
59
+
60
+ ``matrices[x][i, j]`` is ``Pr(S' = states[j], X = x | S = states[i])``.
61
+ Entries may be floats or exact sympy expressions.
62
+ """
63
+ from sofic.generators.prob import as_prob, has_symbolic, is_positive_mass
64
+
65
+ state_list = tuple(states)
66
+ symbol_list = tuple(symbols)
67
+ index = {state: i for i, state in enumerate(state_list)}
68
+ flat_entries = [entry for matrix in matrices.values() for entry in np.asarray(matrix, dtype=object).ravel()]
69
+ symbolic = has_symbolic(flat_entries)
70
+ arrays = {}
71
+ for symbol, matrix in matrices.items():
72
+ arr = np.asarray(matrix, dtype=object if symbolic else float)
73
+ if arr.shape != (len(state_list), len(state_list)):
74
+ raise ValueError(f"matrix for symbol {symbol!r} has shape {arr.shape}")
75
+ arrays[symbol] = arr
76
+
77
+ pi = (
78
+ dict(initial_distribution) if initial_distribution is not None else _stationary_distribution(state_list, arrays)
79
+ )
80
+ eps = EpsilonMachine(
81
+ initial_distribution=pi,
82
+ observation_alphabet=frozenset(symbol_list),
83
+ )
84
+ for state in state_list:
85
+ eps.graph.add_state(state)
86
+
87
+ for symbol, matrix in arrays.items():
88
+ for source in state_list:
89
+ i = index[source]
90
+ for target in state_list:
91
+ j = index[target]
92
+ prob = as_prob(matrix[i, j])
93
+ if not is_positive_mass(prob):
94
+ continue
95
+ eps.graph.add_transition(
96
+ source,
97
+ target,
98
+ **{ATTR_PROB: prob, ATTR_EMISSION: symbol},
99
+ )
100
+ eps.validate()
101
+ return eps
102
+
103
+
104
+ def bernoulli(p: float = 0.5, *, symbols: tuple[Any, Any] = ("0", "1")) -> EpsilonMachine:
105
+ """Memoryless (Bernoulli) source with ``P(symbols[0]) = 1 - p``."""
106
+ if not 0.0 < p < 1.0:
107
+ raise ValueError("p must be in (0, 1)")
108
+ from sofic.examples.processes import _edge_machine
109
+
110
+ zero, one = symbols
111
+ state = sequential_labels(1)[0]
112
+ return _edge_machine(
113
+ [
114
+ (state, state, zero, 1.0 - p),
115
+ (state, state, one, p),
116
+ ],
117
+ initial_distribution={state: 1.0},
118
+ normalize=False,
119
+ )
120
+
121
+
122
+ def fair_coin() -> EpsilonMachine:
123
+ """Fair binary memoryless source (``p = 1/2``)."""
124
+ return bernoulli(0.5)
125
+
126
+
127
+ def even_process(p: float = 0.5) -> EpsilonMachine:
128
+ """Even Process: even-length blocks of 1s bounded by 0s.
129
+
130
+ Jurgens & Marzen, arXiv:1008.4182, Fig.~1; Mahoney et al.,
131
+ arXiv:0905.4787, Sec.~III.1.
132
+ """
133
+ if not 0.0 < p < 1.0:
134
+ raise ValueError("p must be in (0, 1)")
135
+ return from_symbol_matrices(
136
+ sequential_labels(2),
137
+ (0, 1),
138
+ {
139
+ 0: np.array([[p, 0.0], [0.0, 0.0]]),
140
+ 1: np.array([[0.0, 1.0 - p], [1.0, 0.0]]),
141
+ },
142
+ )
143
+
144
+
145
+ def noisy_random_phase_slip() -> EpsilonMachine:
146
+ """Noisy Random Phase-Slip Process (James et al., 2011, Fig.~11c).
147
+
148
+ Five-state ε-machine with stochastic phase slip at state ``A`` and
149
+ emission noise at state ``D``. Prototype for block-convergence figures in
150
+ *Anatomy of a Bit* :cite:`James2011`.
151
+ """
152
+ from sofic.examples.processes import _edge_machine
153
+
154
+ states = sequential_labels(5)
155
+ a, b, c, d, e = states
156
+ return _edge_machine(
157
+ [
158
+ (a, a, 0, 0.5),
159
+ (a, b, 1, 0.5),
160
+ (b, c, 0, 1.0),
161
+ (c, d, 1, 1.0),
162
+ (d, e, 0, 0.5),
163
+ (d, e, 1, 0.5),
164
+ (e, a, 0, 1.0),
165
+ ],
166
+ machine_type=EpsilonMachine,
167
+ normalize=False,
168
+ )
169
+
170
+
171
+ def golden_mean(p: float = 0.5) -> EpsilonMachine:
172
+ """Golden Mean Process (two-state presentation, forbid consecutive ``11``).
173
+
174
+ State ``A`` self-loops on ``0`` (probability ``p``) and moves to ``B``
175
+ on ``1``; ``B`` always returns to ``A`` on ``0``. This is the
176
+ standard golden-mean *shift* topology (Mahoney et al., arXiv:0905.4787,
177
+ Fig.~2 style). For the order-1 Markov presentation that forbids ``00``,
178
+ see :func:`golden_mean_markov`; for the bidirectional machine in Ellison et
179
+ al., arXiv:0905.3587, Fig.~4, see :func:`golden_mean_forward` and
180
+ :func:`golden_mean_reverse`; for the Parry max-entropy measure on the
181
+ same shift, see :func:`golden_mean_shift_parry`.
182
+ """
183
+ if not 0.0 < p < 1.0:
184
+ raise ValueError("p must be in (0, 1)")
185
+ return from_symbol_matrices(
186
+ sequential_labels(2),
187
+ (0, 1),
188
+ {
189
+ 0: np.array([[p, 0.0], [1.0, 0.0]]),
190
+ 1: np.array([[0.0, 1.0 - p], [0.0, 0.0]]),
191
+ },
192
+ )
193
+
194
+
195
+ def golden_mean_forward(p: float = 0.5) -> EpsilonMachine:
196
+ """Forward ε-machine M⁺ for the golden mean (forbid ``00``).
197
+
198
+ States ``A`` and ``B``; self-loop probability ``p = Pr(1|A)``.
199
+ Ellison, Mahoney & Crutchfield, arXiv:0905.3587, Fig.~4(a).
200
+ """
201
+ if not 0.0 < p < 1.0:
202
+ raise ValueError("p must be in (0, 1)")
203
+ return from_symbol_matrices(
204
+ ("A", "B"),
205
+ (0, 1),
206
+ {
207
+ 0: np.array([[0.0, 1.0 - p], [0.0, 0.0]]),
208
+ 1: np.array([[p, 0.0], [1.0, 0.0]]),
209
+ },
210
+ )
211
+
212
+
213
+ def golden_mean_reverse(p: float = 0.5) -> EpsilonMachine:
214
+ """Reverse ε-machine M⁻ for the golden mean (forbid ``00``).
215
+
216
+ Isomorphic to :func:`golden_mean_forward` with states ``A`` and ``B``.
217
+ When paired for a bidirectional presentation, reverse states are relabeled
218
+ to continue the alphabet (``C``, ``D``, ...). Ellison, Mahoney & Crutchfield,
219
+ arXiv:0905.3587, Fig.~4(b).
220
+ """
221
+ return golden_mean_forward(p)
222
+
223
+
224
+ def golden_mean_bidirectional(p: float = 0.5):
225
+ """Bidirectional ε-machine M± for the golden mean (forbid ``00``).
226
+
227
+ Joint states ``(A, C)``, ``(A, D)``, ``(B, C)`` as in Ellison et al.,
228
+ arXiv:0905.3587, Fig.~4(c).
229
+ """
230
+ from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
231
+
232
+ return BidirectionalEpsilonMachine.from_pair(
233
+ golden_mean_forward(p),
234
+ golden_mean_reverse(p),
235
+ )
236
+
237
+
238
+ def golden_mean_markov(p: float = 0.5) -> EpsilonMachine:
239
+ """Order-1 Markov presentation of the golden mean (forbid ``00``).
240
+
241
+ States are the most recent symbol ``0`` or ``1``. Ellison et al.,
242
+ arXiv:0905.3587, Fig.~1; stationary ``pi(1) = 2/3`` at ``p = 1/2``.
243
+ For the paper's ``A``/``B`` labeling and bidirectional machine, prefer
244
+ :func:`golden_mean_forward`.
245
+ """
246
+ if not 0.0 < p < 1.0:
247
+ raise ValueError("p must be in (0, 1)")
248
+ return from_symbol_matrices(
249
+ sequential_labels(2),
250
+ (0, 1),
251
+ {
252
+ 0: np.array([[0.0, 0.0], [p, 0.0]]),
253
+ 1: np.array([[0.0, 1.0], [0.0, 1.0 - p]]),
254
+ },
255
+ )
256
+
257
+
258
+ def golden_mean_shift_parry() -> EpsilonMachine:
259
+ """Max-entropy (Parry) measure on the golden-mean shift (forbid 11).
260
+
261
+ This is the symbolic-dynamics convention (adjacency ``[[1,1],[1,0]]``),
262
+ distinct from :func:`golden_mean` / :func:`golden_mean_markov`, which
263
+ forbid consecutive 0s in the CM literature cited above.
264
+ """
265
+ tmc = TopologicalMarkovChain.from_adjacency(
266
+ np.array([[1, 1], [1, 0]], dtype=float),
267
+ symbol_alphabet=frozenset({0, 1}),
268
+ )
269
+ parry = tmc.parry_measure()
270
+ return EpsilonMachine.from_hmm(parry)
271
+
272
+
273
+ def alternating_biased_coins(p: float = 0.5, q: float = 0.4) -> EpsilonMachine:
274
+ """Alternating Biased Coins (ABC) Process.
275
+
276
+ Jurgens & Marzen, arXiv:1008.4182, Fig.~2.
277
+ """
278
+ if not 0.0 < p < 1.0 or not 0.0 < q < 1.0:
279
+ raise ValueError("p and q must be in (0, 1)")
280
+ return from_symbol_matrices(
281
+ sequential_labels(2),
282
+ (0, 1),
283
+ {
284
+ 0: np.array([[0.0, 1.0 - p], [1.0 - q, 0.0]]),
285
+ 1: np.array([[0.0, p], [q, 0.0]]),
286
+ },
287
+ )
288
+
289
+
290
+ def restricted_golden_mean(k: int = 1) -> EpsilonMachine:
291
+ """Restricted Golden Mean family (``k``-cryptic); distinct from :func:`golden_mean`.
292
+
293
+ Mahoney et al., arXiv:0906.5099, Sec.~III.
294
+ """
295
+ if k < 1:
296
+ raise ValueError("k must be >= 1")
297
+ states = sequential_labels(k + 1)
298
+ n = k + 1
299
+ t0 = np.zeros((n, n), dtype=float)
300
+ t1 = np.zeros((n, n), dtype=float)
301
+ t0[0, 1] = 0.5
302
+ t1[0, 0] = 0.5
303
+ for i in range(1, k):
304
+ t1[i, i + 1] = 1.0
305
+ t1[k, 0] = 1.0
306
+ return from_symbol_matrices(states, (0, 1), {0: t0, 1: t1})
307
+
308
+
309
+ def nemo_process(p: float = 0.5, q: float = 0.5) -> EpsilonMachine:
310
+ """Nemo Process (three-state, ``infty``-cryptic).
311
+
312
+ Mahoney et al., arXiv:0906.5099, Fig.~7 / Sec.~IV.
313
+ """
314
+ if not 0.0 < p < 1.0 or not 0.0 < q < 1.0:
315
+ raise ValueError("p and q must be in (0, 1)")
316
+ states = ("A", "B", "C")
317
+ return from_symbol_matrices(
318
+ states,
319
+ (0, 1),
320
+ {
321
+ 0: np.array(
322
+ [
323
+ [0.0, 1.0 - p, 0.0],
324
+ [0.0, 0.0, 1.0],
325
+ [1.0 - q, 0.0, 0.0],
326
+ ]
327
+ ),
328
+ 1: np.array(
329
+ [
330
+ [p, 0.0, 0.0],
331
+ [0.0, 0.0, 0.0],
332
+ [q, 0.0, 0.0],
333
+ ]
334
+ ),
335
+ },
336
+ )
337
+
338
+
339
+ def phase_slip_backtrack(p: float = 0.5, q: float = 0.5) -> EpsilonMachine:
340
+ """Phase-Slip Backtrack (PSB) Process (``R=3``, ``k_chi=2``).
341
+
342
+ James, Mahoney, Ellison & Crutchfield, arXiv:1010.5545, Fig.~2.
343
+ """
344
+ if not 0.0 < p < 1.0 or not 0.0 < q < 1.0:
345
+ raise ValueError("p and q must be in (0, 1)")
346
+ states = ("A", "B", "C", "D")
347
+ return from_symbol_matrices(
348
+ states,
349
+ (0, 1),
350
+ {
351
+ 0: np.array(
352
+ [
353
+ [0.0, 0.0, 1.0 - p, 0.0],
354
+ [0.0, 0.0, 0.0, 1.0 - q],
355
+ [1.0, 0.0, 0.0, 0.0],
356
+ [0.0, 0.0, 0.0, 0.0],
357
+ ]
358
+ ),
359
+ 1: np.array(
360
+ [
361
+ [0.0, p, 0.0, 0.0],
362
+ [0.0, 0.0, q, 0.0],
363
+ [0.0, 0.0, 0.0, 0.0],
364
+ [0.0, 1.0, 0.0, 0.0],
365
+ ]
366
+ ),
367
+ },
368
+ )
369
+
370
+
371
+ def butterfly_process() -> EpsilonMachine:
372
+ """Butterfly Process (five-state, ``2``-cryptic) over symbols ``0``--``7``.
373
+
374
+ Mahoney et al., arXiv:0906.5099, Fig.~1. Each causal state emits every
375
+ symbol with probability ``1/8``; synchronizing symbols ``2``--``7`` always
376
+ reach the same causal state regardless of the source.
377
+ """
378
+ states = ("A", "B", "C", "D", "E")
379
+ prob = 1.0 / 8.0
380
+ targets = {
381
+ 0: {"A": "B", "B": "B", "C": "D", "D": "B", "E": "D"},
382
+ 1: {"A": "C", "B": "C", "C": "C", "D": "E", "E": "E"},
383
+ 2: "A",
384
+ 3: "A",
385
+ 4: "B",
386
+ 5: "D",
387
+ 6: "C",
388
+ 7: "E",
389
+ }
390
+ from sofic.examples.processes import _edge_machine
391
+
392
+ edges = []
393
+ for source in states:
394
+ for symbol in range(8):
395
+ target_spec = targets[symbol]
396
+ target = target_spec if isinstance(target_spec, str) else target_spec[source]
397
+ edges.append((source, target, symbol, prob))
398
+ return _edge_machine(
399
+ edges,
400
+ initial_distribution=dict.fromkeys(states, 0.2),
401
+ normalize=False,
402
+ )
403
+
404
+
405
+ def ellison_fig9_forward() -> EpsilonMachine:
406
+ """Forward ε-machine from Ellison et al., arXiv:1107.2168, Fig.~9."""
407
+ from sofic.examples.processes import _edge_machine
408
+
409
+ return _edge_machine(
410
+ [
411
+ ("A", "A", 0, 0.5),
412
+ ("A", "B", 1, 0.5),
413
+ ("B", "B", 1, 0.5),
414
+ ("B", "A", 2, 0.5),
415
+ ],
416
+ initial_distribution={"A": 0.5, "B": 0.5},
417
+ normalize=False,
418
+ )
419
+
420
+
421
+ def tent_map_misiurewicz_a(symbolic: bool = False):
422
+ """Misiurewicz parameter ``a`` for the tent map (James et al., 2013, Eq. 12).
423
+
424
+ With ``symbolic=True``, return the exact sympy expression
425
+ ``α + 2/(3α)`` where ``α = (1 + sqrt(19/27))**(1/3)``.
426
+ """
427
+ if symbolic:
428
+ import sympy as sp
429
+
430
+ alpha = (1 + sp.sqrt(sp.Rational(19, 27))) ** sp.Rational(1, 3)
431
+ return sp.simplify(alpha + 2 / (3 * alpha))
432
+ alpha = (math.sqrt(19 / 27) + 1) ** (1 / 3)
433
+ return alpha + 2 / (3 * alpha)
434
+
435
+
436
+ def tent_map_misiurewicz_fig7_symbol_matrices(
437
+ a: Any | None = None,
438
+ ) -> tuple[tuple[str, ...], tuple[int, ...], dict[int, np.ndarray]]:
439
+ """Return Fig.~7 ε-machine symbol matrices for the tent map at parameter ``a``.
440
+
441
+ James, Burke & Crutchfield, *Chaos Forgets and Remembers* (2013), supplement
442
+ Fig.~7. State ``A`` emits only ``1``; ``D`` has a nontrivial ``0`` branch to
443
+ ``C`` and a ``1`` self-loop. When ``a`` is a sympy expression the matrices
444
+ use object dtype with exact entries.
445
+
446
+ This is the ``"c"`` member of the four-partition family of
447
+ :func:`tent_map_misiurewicz_partition_symbol_matrices`, keeping the figure's
448
+ state names. That function instead names states by decreasing stationary
449
+ probability, so its ``A, B, C, D`` are this function's ``D, C, B, A``.
450
+ """
451
+ from sofic.generators.prob import is_symbolic, zeros
452
+
453
+ if a is None:
454
+ a = tent_map_misiurewicz_a()
455
+ states = ("A", "B", "C", "D")
456
+ symbolic = is_symbolic(a)
457
+ denom = 2 * a**2 + 4 * a + 2
458
+ t0 = zeros((4, 4), symbolic=symbolic)
459
+ t1 = zeros((4, 4), symbolic=symbolic)
460
+ # Topology from supplement Fig. 7.
461
+ t1[0, 1] = 1 if symbolic else 1.0 # A → B on 1
462
+ t0[1, 2] = (a + 2) / (2 * a + 2) # B → C on 0
463
+ t1[1, 0] = a / (2 * a + 2) # B → A on 1
464
+ t0[2, 0] = 1 / (a + 2) if symbolic else 1.0 / (a + 2) # C → A on 0
465
+ t1[2, 3] = (a + 1) / (a + 2) # C → D on 1
466
+ t0[3, 2] = (a**2 + 2 * a) / denom # D → C on 0
467
+ t1[3, 3] = (a**2 + 2 * a + 2) / denom # D → D on 1
468
+ return states, (0, 1), {0: t0, 1: t1}
469
+
470
+
471
+ def tent_map_misiurewicz_forward(a: Any | None = None) -> EpsilonMachine:
472
+ """Forward ε-machine for tent-map symbolic dynamics at the Misiurewicz point."""
473
+ states, symbols, matrices = tent_map_misiurewicz_fig7_symbol_matrices(a)
474
+ return from_symbol_matrices(states, symbols, matrices)
475
+
476
+
477
+ #: Keys of the four generating partitions of the tent map at the Misiurewicz
478
+ #: point, in refinement order. Each names the cuts added to the critical point
479
+ #: ``c = 1/2``: nothing, the left preimage ``L = 1/(2a)``, the right preimage
480
+ #: ``R = 1 - 1/(2a)``, or both. See
481
+ #: :func:`tent_map_misiurewicz_partition_symbol_matrices`.
482
+ TENT_MAP_MISIUREWICZ_PARTITIONS: tuple[str, ...] = ("c", "Lc", "cR", "LcR")
483
+
484
+ #: ``partition -> (states, alphabet, edges)`` where each edge is
485
+ #: ``(source, symbol, target, (k0, k1, k2), d)`` standing for the transition
486
+ #: probability ``(k0 + k1 * a + k2 * a**2) / d``. Every entry is reduced modulo
487
+ #: the parameter's minimal polynomial ``a**3 = 2a + 2``, which is why no
488
+ #: probability carries an ``a``-dependent denominator. States are named by
489
+ #: decreasing stationary probability, uniformly across the four partitions.
490
+ _TENT_MAP_PARTITION_EDGES: dict[
491
+ str,
492
+ tuple[tuple[str, ...], tuple[int, ...], tuple[tuple[str, int, str, tuple[int, int, int], int], ...]],
493
+ ] = {
494
+ "c": (
495
+ ("A", "B", "C", "D"),
496
+ (0, 1),
497
+ (
498
+ ("A", 0, "B", (4, 0, -1), 2),
499
+ ("A", 1, "A", (-2, 0, 1), 2),
500
+ ("B", 0, "D", (2, -2, 1), 6),
501
+ ("B", 1, "A", (4, 2, -1), 6),
502
+ ("C", 0, "B", (0, -1, 1), 2),
503
+ ("C", 1, "D", (2, 1, -1), 2),
504
+ ("D", 1, "C", (1, 0, 0), 1),
505
+ ),
506
+ ),
507
+ "Lc": (
508
+ ("A", "B", "C", "D", "E"),
509
+ (0, 1, 2),
510
+ (
511
+ ("A", 0, "E", (2, -1, 0), 2),
512
+ ("A", 1, "B", (2, 1, -1), 2),
513
+ ("A", 2, "A", (-2, 0, 1), 2),
514
+ ("B", 2, "A", (1, 0, 0), 1),
515
+ ("C", 0, "E", (-1, -1, 1), 2),
516
+ ("C", 1, "B", (1, 0, 0), 2),
517
+ ("C", 2, "D", (2, 1, -1), 2),
518
+ ("D", 2, "C", (1, 0, 0), 1),
519
+ ("E", 1, "D", (1, 0, 0), 1),
520
+ ),
521
+ ),
522
+ "cR": (
523
+ ("A", "B", "C", "D", "E"),
524
+ (0, 1, 2),
525
+ (
526
+ ("A", 0, "B", (1, 0, 0), 1),
527
+ ("B", 0, "D", (2, -2, 1), 6),
528
+ ("B", 1, "C", (-2, -1, 2), 6),
529
+ ("B", 2, "A", (2, 1, -1), 2),
530
+ ("C", 1, "C", (-2, 0, 1), 2),
531
+ ("C", 2, "A", (4, 0, -1), 2),
532
+ ("D", 1, "E", (2, 1, -1), 2),
533
+ ("D", 2, "A", (0, -1, 1), 2),
534
+ ("E", 1, "D", (1, 0, 0), 1),
535
+ ),
536
+ ),
537
+ "LcR": (
538
+ ("A", "B", "C", "D", "E"),
539
+ (0, 1, 2, 3),
540
+ (
541
+ ("A", 2, "A", (-2, 0, 1), 2),
542
+ ("A", 3, "B", (4, 0, -1), 2),
543
+ ("B", 0, "D", (2, -2, 1), 6),
544
+ ("B", 1, "A", (4, 2, -1), 6),
545
+ ("C", 2, "E", (2, 1, -1), 2),
546
+ ("C", 3, "B", (0, -1, 1), 2),
547
+ ("D", 1, "C", (1, 0, 0), 1),
548
+ ("E", 2, "C", (1, 0, 0), 1),
549
+ ),
550
+ ),
551
+ }
552
+
553
+ #: ``partition -> ((k0, k1, k2), d)`` for the ephemeral information rate
554
+ #: ``r_mu = (k0 + k1 * a + k2 * a**2) / d``, again reduced modulo
555
+ #: ``a**3 = 2a + 2``. For ``"c"`` this is the published rate of James et al.
556
+ #: (2013) in reduced form; see
557
+ #: :func:`tent_map_misiurewicz_partition_information_expected`.
558
+ _TENT_MAP_PARTITION_EPHEMERAL: dict[str, tuple[tuple[int, int, int], int]] = {
559
+ "c": ((59, 7, -11), 57),
560
+ "Lc": ((56, 25, -23), 57),
561
+ "cR": ((1, -6, 4), 19),
562
+ "LcR": ((0, 0, 0), 1),
563
+ }
564
+
565
+
566
+ def _tent_map_partition_check(partition: str) -> None:
567
+ if partition not in _TENT_MAP_PARTITION_EDGES:
568
+ raise ValueError(f"unknown tent-map partition {partition!r}; expected one of {TENT_MAP_MISIUREWICZ_PARTITIONS}")
569
+
570
+
571
+ def _tent_map_quadratic(coeffs: tuple[int, int, int], denom: int, a: Any, symbolic: bool) -> Any:
572
+ """Evaluate ``(k0 + k1 * a + k2 * a**2) / denom`` exactly or in floats."""
573
+ k0, k1, k2 = coeffs
574
+ if symbolic:
575
+ import sympy as sp
576
+
577
+ return sp.Rational(k0, denom) + sp.Rational(k1, denom) * a + sp.Rational(k2, denom) * a**2
578
+ return (k0 + k1 * a + k2 * a**2) / denom
579
+
580
+
581
+ def tent_map_misiurewicz_partition_cuts(partition: str, a: Any | None = None) -> tuple[Any, ...]:
582
+ """Return the ascending cut points of one of the four tent-map partitions.
583
+
584
+ ``"c"`` cuts only at the critical point; ``"Lc"`` and ``"cR"`` add one
585
+ order-1 preimage of it; ``"LcR"`` adds both. With a symbolic ``a`` the cuts
586
+ are exact sympy expressions.
587
+ """
588
+ _tent_map_partition_check(partition)
589
+ from sofic.generators.prob import is_symbolic
590
+
591
+ if a is None:
592
+ a = tent_map_misiurewicz_a()
593
+ if is_symbolic(a):
594
+ import sympy as sp
595
+
596
+ c = sp.Rational(1, 2)
597
+ left = 1 / (2 * a)
598
+ else:
599
+ c = 0.5
600
+ left = 1.0 / (2.0 * a)
601
+ right = 1 - left
602
+ return {"c": (c,), "Lc": (left, c), "cR": (c, right), "LcR": (left, c, right)}[partition]
603
+
604
+
605
+ def tent_map_misiurewicz_partition_symbol_matrices(
606
+ partition: str,
607
+ a: Any | None = None,
608
+ ) -> tuple[tuple[str, ...], tuple[int, ...], dict[int, np.ndarray]]:
609
+ """Symbol matrices ``T^(x)`` for one of the tent map's four generating partitions.
610
+
611
+ At the Misiurewicz parameter the interval ``[0, 1]`` can be cut at the
612
+ critical point ``c = 1/2`` and, optionally, at either or both of its
613
+ order-1 preimages ``L = 1/(2a)`` and ``R = 1 - 1/(2a)``. All four choices
614
+ are generating, so all four read out the *same* dynamics at the same entropy
615
+ rate ``h_mu = log2(a)``; they differ in how many letters they spend and in
616
+ how much of that rate survives as bound information:
617
+
618
+ ========== ========================= ====== ======== ================
619
+ Partition Cells States Alphabet ``r_mu``
620
+ ========== ========================= ====== ======== ================
621
+ ``"c"`` ``c`` 4 2 ``(59 + 7a - 11a**2)/57``
622
+ ``"Lc"`` ``L, c`` 5 3 ``(56 + 25a - 23a**2)/57``
623
+ ``"cR"`` ``c, R`` 5 3 ``(1 - 6a + 4a**2)/19``
624
+ ``"LcR"`` ``L, c, R`` 5 4 ``0``
625
+ ========== ========================= ====== ======== ================
626
+
627
+ Symbols number the cells left to right, so ``"LcR"`` emits ``0`` on
628
+ ``[0, L)``, ``1`` on ``[L, c)``, ``2`` on ``[c, R)`` and ``3`` on
629
+ ``[R, 1]``. Reducing by the parameter's minimal polynomial
630
+ ``a**3 = 2a + 2`` makes every transition probability a quadratic in ``a``
631
+ with rational coefficients, so none of them carries an ``a``-dependent
632
+ denominator. States are named by decreasing stationary probability in every
633
+ partition, which makes them comparable across the family; for ``"c"`` that
634
+ relabels the published figure, whose ``A, B, C, D`` are this function's
635
+ ``D, C, B, A`` (see :func:`tent_map_misiurewicz_fig7_symbol_matrices`).
636
+
637
+ Derived from the exact interval Markov chain on the forward-orbit closure of
638
+ ``{c, L, R}``. The tent map, the Misiurewicz parameter and the ``"c"``
639
+ presentation are from James, Burke & Crutchfield, *Chaos Forgets and
640
+ Remembers* (2013) :cite:`James2013`; that paper's figures cover only the
641
+ kneading partition, so the three refinements have no published figure to
642
+ cite.
643
+ """
644
+ _tent_map_partition_check(partition)
645
+ from sofic.generators.prob import is_symbolic, zeros
646
+
647
+ if a is None:
648
+ a = tent_map_misiurewicz_a()
649
+ states, alphabet, edges = _TENT_MAP_PARTITION_EDGES[partition]
650
+ symbolic = is_symbolic(a)
651
+ index = {state: i for i, state in enumerate(states)}
652
+ size = len(states)
653
+ matrices = {symbol: zeros((size, size), symbolic=symbolic) for symbol in alphabet}
654
+ for source, symbol, target, coeffs, denom in edges:
655
+ matrices[symbol][index[source], index[target]] = _tent_map_quadratic(coeffs, denom, a, symbolic)
656
+ return states, alphabet, matrices
657
+
658
+
659
+ def tent_map_misiurewicz_partition_forward(partition: str, a: Any | None = None) -> EpsilonMachine:
660
+ """Forward ε-machine of the tent map under one of its four generating partitions.
661
+
662
+ See :func:`tent_map_misiurewicz_partition_symbol_matrices` for the partitions
663
+ and their presentations. Every one of the four is unifilar and strictly
664
+ sofic -- Markov and cryptic orders are infinite throughout -- so refining the
665
+ partition never buys finite memory. What it buys is bound information:
666
+ ``r_mu`` falls from ``0.6483`` bits/symbol at ``"c"`` to exactly zero at
667
+ ``"LcR"``.
668
+
669
+ ``"c"`` is :func:`tent_map_misiurewicz_forward` up to the state relabeling
670
+ noted in :func:`tent_map_misiurewicz_partition_symbol_matrices`.
671
+ """
672
+ states, symbols, matrices = tent_map_misiurewicz_partition_symbol_matrices(partition, a)
673
+ return from_symbol_matrices(states, symbols, matrices)
674
+
675
+
676
+ def tent_map_misiurewicz_partition_information_expected(
677
+ partition: str,
678
+ a: Any | None = None,
679
+ ) -> dict[str, Any]:
680
+ """Closed-form anatomy of one of the tent map's four generating partitions.
681
+
682
+ All four are generating, so ``entropy_rate = log2(a)`` throughout and only
683
+ the split into ``bound_mu`` and ``ephemeral_mu`` changes. Each ephemeral
684
+ rate is a quadratic in ``a`` with rational coefficients, tabulated in
685
+ :func:`tent_map_misiurewicz_partition_symbol_matrices`.
686
+
687
+ Two exact facts about the family are worth noting. First, ``"LcR"`` has
688
+ ``r_mu = 0``: its machine is unifilar, no two edges share both a source and
689
+ a target, and every branch leads to a state with a distinguishable future,
690
+ so the past fixes the causal state, the future fixes the successor, and the
691
+ two together name the emitted symbol -- leaving nothing for
692
+ ``r_mu = H[X_0 | past, future]`` to measure. Second, the ephemeral rate is
693
+ *modular* over the two available cuts,
694
+
695
+ ``r_mu("c") - r_mu("Lc") - r_mu("cR") + r_mu("LcR") = 0``
696
+
697
+ identically in ``a``, so each cut is worth a fixed number of bits regardless
698
+ of whether the other has been made; the ``L`` cut is worth
699
+ ``r_mu("cR") = (1 - 6a + 4a**2)/19``, which is also exactly the invariant
700
+ measure of the two fine cells it separates.
701
+
702
+ For ``"c"`` this reproduces :func:`tent_map_misiurewicz_information_expected`,
703
+ which states the same rate in the unreduced form published by James, Burke &
704
+ Crutchfield (2013) :cite:`James2013`.
705
+ """
706
+ _tent_map_partition_check(partition)
707
+ from sofic.generators.prob import is_symbolic
708
+
709
+ if a is None:
710
+ a = tent_map_misiurewicz_a()
711
+ coeffs, denom = _TENT_MAP_PARTITION_EPHEMERAL[partition]
712
+ if is_symbolic(a):
713
+ import sympy as sp
714
+
715
+ h_mu = sp.log(a, 2)
716
+ r_mu = sp.simplify(_tent_map_quadratic(coeffs, denom, a, True))
717
+ return {"bound_mu": sp.simplify(h_mu - r_mu), "ephemeral_mu": r_mu, "entropy_rate": h_mu}
718
+ h_mu = math.log2(a)
719
+ r_mu = _tent_map_quadratic(coeffs, denom, a, False)
720
+ return {"bound_mu": h_mu - r_mu, "ephemeral_mu": r_mu, "entropy_rate": h_mu}
721
+
722
+
723
+ def tent_map_misiurewicz_hmm(a: Any | None = None) -> MealyHMM:
724
+ """Non-unifilar HMM from supplement Fig.~6 (right).
725
+
726
+ James, Burke & Crutchfield, *Chaos Forgets and Remembers* (2013), supplement
727
+ Fig.~6 (right): generating partition overlaid on the Markov-partition chain.
728
+ Non-unifilar at ``A`` (two ``0`` outs) and ``D`` (three ``1`` outs).
729
+ :meth:`~sofic.generators.epsilon_machine.EpsilonMachine.from_hmm` recovers
730
+ the Fig.~7 ε-machine.
731
+ """
732
+ from sofic.generators.prob import as_prob, is_symbolic
733
+
734
+ if a is None:
735
+ a = tent_map_misiurewicz_a()
736
+ symbolic = is_symbolic(a)
737
+ if symbolic:
738
+ import sympy as sp
739
+
740
+ half = sp.Rational(1, 2)
741
+ one = sp.Integer(1)
742
+ inv_a1 = 1 / (a + 1)
743
+ half_a_a1 = a / (2 * (a + 1))
744
+ else:
745
+ half = 0.5
746
+ one = 1.0
747
+ inv_a1 = 1.0 / (a + 1.0)
748
+ half_a_a1 = a / (2.0 * (a + 1.0))
749
+
750
+ hmm = MealyHMM(observation_alphabet=frozenset({0, 1}))
751
+ for state in ("A", "B", "C", "D"):
752
+ hmm.graph.add_state(state)
753
+ edges = [
754
+ ("A", "B", 0, half),
755
+ ("A", "C", 0, half),
756
+ ("B", "D", 0, one),
757
+ ("C", "D", 1, one),
758
+ ("D", "A", 1, inv_a1),
759
+ ("D", "B", 1, half_a_a1),
760
+ ("D", "C", 1, half_a_a1),
761
+ ]
762
+ for source, target, symbol, prob in edges:
763
+ hmm.add_transition(source, target, symbol, as_prob(prob))
764
+
765
+ if symbolic:
766
+ # The Misiurewicz parameter is the real root of ``a**3 - 2*a - 2`` (James,
767
+ # Burke & Crutchfield, 2013, Eq. 12; the Cardano form ``alpha + 2/(3 alpha)``
768
+ # returned by ``tent_map_misiurewicz_a(symbolic=True)``). Carrying this
769
+ # minimal polynomial lets ``EpsilonMachine.from_hmm`` recognize the two
770
+ # mixed states that coincide only under the constraint and recover the
771
+ # 4-state Fig.~7 machine.
772
+ from sofic.generators.prob import SymbolConstraints
773
+
774
+ hmm.symbol_constraints = SymbolConstraints([a**3 - 2 * a - 2])
775
+
776
+ pi = hmm.stationary_distribution()
777
+ idx = hmm.reindex()
778
+ if pi.dtype == object or symbolic:
779
+ hmm.initial_distribution = {idx.state(i): as_prob(pi[i]) for i in range(len(idx.states))}
780
+ else:
781
+ hmm.initial_distribution = {idx.state(i): float(pi[i]) for i in range(len(idx.states))}
782
+ return hmm
783
+
784
+
785
+ def tent_map_misiurewicz_reverse(a: float | None = None) -> EpsilonMachine:
786
+ """Reverse ε-machine for tent-map symbolic dynamics at the Misiurewicz point.
787
+
788
+ Projected from the supplement Fig.~8 bidirectional presentation with
789
+ ``future_symbol`` annotations on ``E``, ``F``, and ``G``.
790
+ """
791
+ return tent_map_misiurewicz_bidirectional_fig8(a).reverse_machine
792
+
793
+
794
+ def _annotate_tent_map_misiurewicz_reverse_future_symbols(reverse: EpsilonMachine) -> None:
795
+ """Attach synchronization symbols to reverse causal states (supplement Fig.~8)."""
796
+ # Present-symbol constraints on reverse components E,F,G; H is outside support.
797
+ futures = {"E": 0, "F": 1, "G": 1, "H": -1}
798
+ for state, symbol in futures.items():
799
+ if not reverse.graph.has_state(state):
800
+ continue
801
+ attrs = dict(reverse.graph.state_attrs(state))
802
+ attrs[ATTR_FUTURE_SYMBOL] = symbol
803
+ reverse.graph.add_state(state, **attrs)
804
+
805
+
806
+ def _tent_map_misiurewicz_fig8_reverse_relabel() -> dict[str, str]:
807
+ """Map supplement reverse causal labels to collision-free joint labels."""
808
+ return {"A": "E", "B": "F", "C": "G", "D": "H"}
809
+
810
+
811
+ def _tent_map_misiurewicz_fig8_joint_state(
812
+ forward: str,
813
+ reverse: str,
814
+ *,
815
+ relabel: Mapping[str, str],
816
+ ) -> tuple[str, str]:
817
+ return forward, relabel[reverse]
818
+
819
+
820
+ def _tent_map_misiurewicz_fig8_edges(
821
+ a: Any,
822
+ ) -> list[tuple[tuple[str, str], tuple[str, str], int, Any]]:
823
+ """Directed edges for supplement Fig.~8 with reverse states relabeled E--H.
824
+
825
+ Joint labels use ``S⁺:S⁻`` from James et al. (2013), supplement Fig.~8.
826
+ Edge probabilities are the figure's ``1/2`` and ``a/(a+1)`` templates.
827
+ """
828
+ from sofic.generators.prob import is_symbolic
829
+
830
+ relabel = _tent_map_misiurewicz_fig8_reverse_relabel()
831
+ name = {
832
+ "BA": ("B", "A"),
833
+ "CC": ("C", "C"),
834
+ "AB": ("A", "B"),
835
+ "BC": ("B", "C"),
836
+ "CB": ("C", "B"),
837
+ "DA": ("D", "A"),
838
+ "DB": ("D", "B"),
839
+ "DC": ("D", "C"),
840
+ }
841
+ if is_symbolic(a):
842
+ import sympy as sp
843
+
844
+ half = sp.Rational(1, 2)
845
+ inv_a1 = 1 / (a + 1)
846
+ frac_a1 = a / (a + 1)
847
+ one = sp.Integer(1)
848
+ else:
849
+ half = 0.5
850
+ inv_a1 = 1.0 / (a + 1.0)
851
+ frac_a1 = a / (a + 1.0)
852
+ one = 1.0
853
+ edge_specs = [
854
+ ("BA", "CC", 0, one),
855
+ ("CC", "AB", 0, half),
856
+ ("CC", "DB", 1, half),
857
+ ("AB", "BA", 1, inv_a1),
858
+ ("AB", "BC", 1, frac_a1),
859
+ ("BC", "AB", 1, half),
860
+ ("BC", "CB", 0, half),
861
+ ("DA", "CC", 0, one),
862
+ ("DB", "DA", 1, inv_a1),
863
+ ("DB", "DC", 1, frac_a1),
864
+ ("DC", "DB", 1, half),
865
+ ("DC", "CB", 0, half),
866
+ ("CB", "DC", 1, frac_a1),
867
+ ("CB", "DA", 1, inv_a1),
868
+ ]
869
+ edges: list[tuple[tuple[str, str], tuple[str, str], int, Any]] = []
870
+ for row, target, symbol, prob in edge_specs:
871
+ source = _tent_map_misiurewicz_fig8_joint_state(name[row][0], name[row][1], relabel=relabel)
872
+ dest = _tent_map_misiurewicz_fig8_joint_state(name[target][0], name[target][1], relabel=relabel)
873
+ edges.append((source, dest, symbol, prob))
874
+ return edges
875
+
876
+
877
+ def _stationary_distribution_from_joint_graph(
878
+ graph: TransitionGraph,
879
+ ) -> dict[tuple[str, str], Any]:
880
+ from sofic.generators.prob import as_prob, has_symbolic, zeros
881
+ from sofic.generators.stationary import stationary_distribution_from_transition
882
+
883
+ states = list(graph.states())
884
+ if not states:
885
+ return {}
886
+ index = {state: position for position, state in enumerate(states)}
887
+ edge_probs = [t.data.get(ATTR_PROB, 0.0) for t in graph.transitions()]
888
+ symbolic = has_symbolic(edge_probs)
889
+ transition = zeros((len(states), len(states)), symbolic=symbolic)
890
+ for transition_edge in graph.transitions():
891
+ source = index[transition_edge.source]
892
+ target = index[transition_edge.target]
893
+ transition[source, target] = as_prob(transition[source, target]) + as_prob(
894
+ transition_edge.data.get(ATTR_PROB, 0.0)
895
+ )
896
+ if symbolic:
897
+ pi = stationary_distribution_from_transition(transition)
898
+ return {states[position]: as_prob(pi[position]) for position in range(len(states))}
899
+ stationary = np.ones(len(states), dtype=float) / len(states)
900
+ for _ in range(20_000):
901
+ stationary = stationary @ np.asarray(transition, dtype=float)
902
+ return {states[position]: float(stationary[position]) for position in range(len(states))}
903
+
904
+
905
+ def _project_bidirectional_side(
906
+ graph: TransitionGraph,
907
+ joint_pi: Mapping[tuple[str, str], Any],
908
+ *,
909
+ project_forward: bool,
910
+ future_symbols: Mapping[str, Any] | None = None,
911
+ ) -> EpsilonMachine:
912
+ """Marginalize a hand-built bidirectional graph to an ε-machine presentation."""
913
+ from sofic.generators.prob import (
914
+ as_prob,
915
+ is_positive_mass,
916
+ simplify_prob,
917
+ )
918
+
919
+ coordinate = 0 if project_forward else 1
920
+ marginal: dict[str, Any] = {}
921
+ for pair, mass in joint_pi.items():
922
+ side_state = pair[coordinate]
923
+ if side_state in marginal:
924
+ marginal[side_state] = simplify_prob(as_prob(marginal[side_state]) + as_prob(mass))
925
+ else:
926
+ marginal[side_state] = as_prob(mass)
927
+
928
+ side_graph = TransitionGraph()
929
+ for state, mass in marginal.items():
930
+ if not is_positive_mass(mass):
931
+ continue
932
+ attrs: dict[str, Any] = {}
933
+ if future_symbols is not None and state in future_symbols:
934
+ attrs[ATTR_FUTURE_SYMBOL] = future_symbols[state]
935
+ side_graph.add_state(state, **attrs)
936
+
937
+ aggregated: dict[tuple[str, str, Any], Any] = {}
938
+ for pair, mass in joint_pi.items():
939
+ source = pair[coordinate]
940
+ source_mass = marginal.get(source, 0)
941
+ if not is_positive_mass(mass) or not is_positive_mass(source_mass):
942
+ continue
943
+ for transition in graph.out_transitions(pair):
944
+ symbol = transition.data.get(ATTR_EMISSION)
945
+ prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
946
+ if symbol is None or not is_positive_mass(prob):
947
+ continue
948
+ target_state = transition.target[coordinate]
949
+ key = (source, target_state, symbol)
950
+ contrib = simplify_prob(as_prob(mass) * as_prob(prob) / as_prob(source_mass))
951
+ if key in aggregated:
952
+ aggregated[key] = simplify_prob(as_prob(aggregated[key]) + contrib)
953
+ else:
954
+ aggregated[key] = contrib
955
+
956
+ for (source, target, symbol), prob in aggregated.items():
957
+ if not is_positive_mass(prob):
958
+ continue
959
+ side_graph.add_transition(
960
+ source,
961
+ target,
962
+ **{ATTR_PROB: as_prob(prob), ATTR_EMISSION: symbol},
963
+ )
964
+
965
+ eps = EpsilonMachine(
966
+ graph=side_graph,
967
+ initial_distribution=marginal,
968
+ observation_alphabet=frozenset({0, 1}),
969
+ )
970
+ eps.validate_stochastic()
971
+ return eps
972
+
973
+
974
+ def tent_map_misiurewicz_bidirectional_fig8(a: Any | None = None):
975
+ """Hand-built supplement Fig.~8 bidirectional ε-machine."""
976
+ from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
977
+ from sofic.generators.prob import as_prob, is_symbolic
978
+
979
+ if a is None:
980
+ a = tent_map_misiurewicz_a()
981
+ forward = tent_map_misiurewicz_forward(a)
982
+ graph = TransitionGraph()
983
+ for source, target, symbol, prob in _tent_map_misiurewicz_fig8_edges(a):
984
+ if not graph.has_state(source):
985
+ graph.add_state(source)
986
+ if not graph.has_state(target):
987
+ graph.add_state(target)
988
+ graph.add_transition(
989
+ source,
990
+ target,
991
+ **{ATTR_PROB: as_prob(prob), ATTR_EMISSION: symbol},
992
+ )
993
+ joint_pi = _stationary_distribution_from_joint_graph(graph)
994
+ reverse_raw = _project_bidirectional_side(
995
+ graph,
996
+ joint_pi,
997
+ project_forward=False,
998
+ future_symbols={"E": 0, "F": 1, "G": 1, "H": -1},
999
+ )
1000
+ if is_symbolic(a):
1001
+ from sofic.generators.epsilon_machine import _row_normalized_presentation
1002
+
1003
+ reverse = _row_normalized_presentation(reverse_raw)
1004
+ else:
1005
+ try:
1006
+ reverse = EpsilonMachine.from_hmm(reverse_raw)
1007
+ except Exception:
1008
+ from sofic.generators.epsilon_machine import _row_normalized_presentation
1009
+
1010
+ reverse = _row_normalized_presentation(reverse_raw)
1011
+ _annotate_tent_map_misiurewicz_reverse_future_symbols(reverse)
1012
+ bidir = BidirectionalEpsilonMachine(
1013
+ graph=graph,
1014
+ initial_distribution=joint_pi,
1015
+ observation_alphabet=frozenset({0, 1}),
1016
+ forward_machine=forward,
1017
+ reverse_machine=reverse,
1018
+ )
1019
+ bidir._joint_pi = dict(joint_pi)
1020
+ bidir.validate()
1021
+ return bidir
1022
+
1023
+
1024
+ def tent_map_misiurewicz_bidirectional(a: float | None = None):
1025
+ """Bidirectional ε-machine for the tent map at the Misiurewicz parameter."""
1026
+ return tent_map_misiurewicz_bidirectional_fig8(a)
1027
+
1028
+
1029
+ def tent_map_misiurewicz_information_expected(a: Any | None = None) -> dict[str, Any]:
1030
+ """Closed-form anatomy rates from James et al. (2013), supplement.
1031
+
1032
+ Returns floats when ``a`` is numeric, or sympy expressions when ``a`` is
1033
+ symbolic. The ephemeral rate is
1034
+ ``r_μ = (1/4)*(3 - 2/(a+1) - 4/(a+2) + 9/(2a+3))``.
1035
+
1036
+ This is the kneading partition, i.e. the ``"c"`` member of the family of
1037
+ :func:`tent_map_misiurewicz_partition_information_expected`, which states the
1038
+ same rate reduced modulo ``a**3 = 2a + 2`` to ``(59 + 7a - 11a**2)/57``.
1039
+ """
1040
+ from sofic.generators.prob import is_symbolic
1041
+
1042
+ if a is None:
1043
+ a = tent_map_misiurewicz_a()
1044
+ if is_symbolic(a):
1045
+ import sympy as sp
1046
+
1047
+ h_mu = sp.log(a, 2)
1048
+ r_mu = sp.Rational(1, 4) * (3 - 2 / (a + 1) - 4 / (a + 2) + 9 / (2 * a + 3))
1049
+ b_mu = sp.simplify(h_mu - r_mu)
1050
+ return {
1051
+ "bound_mu": b_mu,
1052
+ "ephemeral_mu": sp.simplify(r_mu),
1053
+ "entropy_rate": h_mu,
1054
+ }
1055
+ h_mu = math.log2(a)
1056
+ r_mu = 0.25 * (3.0 - 2.0 / (a + 1) - 4.0 / (a + 2) + 9.0 / (2 * a + 3))
1057
+ b_mu = h_mu - r_mu
1058
+ return {
1059
+ "bound_mu": b_mu,
1060
+ "ephemeral_mu": r_mu,
1061
+ "entropy_rate": h_mu,
1062
+ }
1063
+
1064
+
1065
+ def ellison_fig9_reverse() -> EpsilonMachine:
1066
+ """Reverse ε-machine from Ellison et al., arXiv:1107.2168, Fig.~9.
1067
+
1068
+ MSP-derived presentation used for Fig.~15 bidirectional pairing via Eq.~(15).
1069
+ """
1070
+ from sofic.generators.reversal import time_reverse_stochastic
1071
+
1072
+ forward = ellison_fig9_forward()
1073
+ reverse = EpsilonMachine.from_hmm(time_reverse_stochastic(forward))
1074
+ reverse.validate()
1075
+ return reverse
1076
+
1077
+
1078
+ def ellison_fig15_bidirectional():
1079
+ """Bidirectional ε-machine M± from Ellison et al., arXiv:1107.2168, Fig.~15.
1080
+
1081
+ Built from the separate forward and reverse presentations in Fig.~9 via
1082
+ Eq.~(15) in the same paper.
1083
+ """
1084
+ from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
1085
+
1086
+ return BidirectionalEpsilonMachine.from_pair(
1087
+ ellison_fig9_forward(),
1088
+ ellison_fig9_reverse(),
1089
+ )