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,717 @@
1
+ """Bidirectional ε-machine construction (Ellison et al., arXiv:1107.2168, Sec. VII)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Hashable, Mapping
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from sofic.exceptions import StochasticValidationError
12
+ from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
13
+ from sofic.generators.epsilon_machine import EpsilonMachine
14
+ from sofic.generators.prob import (
15
+ as_prob,
16
+ has_symbolic,
17
+ is_positive_mass,
18
+ is_symbolic,
19
+ is_zero,
20
+ probs_equal,
21
+ row_sums_to_one,
22
+ simplify_prob,
23
+ sum_probs,
24
+ zeros,
25
+ )
26
+ from sofic.generators.reversal import time_reverse_stochastic
27
+ from sofic.generators.stationary import (
28
+ stationary_distribution_from_transition,
29
+ stationary_distribution_hmm,
30
+ )
31
+ from sofic.graph import ATTR_EMISSION, ATTR_FUTURE_SYMBOL, ATTR_PROB, TransitionGraph
32
+ from sofic.states import next_sequential_label_index, sequential_labels
33
+
34
+
35
+ def build_bidirectional_epsilon_machine(
36
+ forward: EpsilonMachine,
37
+ reverse: EpsilonMachine,
38
+ ) -> BidirectionalEpsilonMachine:
39
+ """Build M± from forward and reverse ε-machines via Eq. (15)."""
40
+ if forward.observation_alphabet != reverse.observation_alphabet:
41
+ raise StochasticValidationError("forward and reverse observation alphabets must match")
42
+
43
+ reverse = _relabel_collision_free(reverse, forward)
44
+ rev_time = time_reverse_stochastic(reverse)
45
+ compatible = _compatible_pairs(forward, reverse)
46
+ graph = _build_eq15_graph(forward, rev_time, sources=compatible)
47
+ graph, initial = _prune_to_stationary_support(graph, forward, reverse)
48
+ if not initial:
49
+ raise StochasticValidationError("bidirectional machine has empty joint support")
50
+
51
+ bidir = BidirectionalEpsilonMachine(
52
+ graph=graph,
53
+ initial_distribution=initial,
54
+ observation_alphabet=forward.observation_alphabet,
55
+ forward_machine=forward,
56
+ reverse_machine=reverse,
57
+ )
58
+ bidir._joint_pi = dict(initial)
59
+ bidir.validate()
60
+ _validate_bidirectional_anatomy(bidir)
61
+ return bidir
62
+
63
+
64
+ def _build_eq15_graph(
65
+ forward: EpsilonMachine,
66
+ rev_time: EpsilonMachine,
67
+ *,
68
+ sources: set[tuple[Hashable, Hashable]] | None = None,
69
+ ) -> TransitionGraph:
70
+ """Build the provisional Eq. (15) graph before pruning transient joint states."""
71
+ reverse_states = sorted(rev_time.states(), key=repr)
72
+ raw: dict[tuple[Hashable, Hashable], list[tuple[tuple[Hashable, Hashable], Any, Any]]] = defaultdict(list)
73
+
74
+ for alpha in sorted(forward.states(), key=repr):
75
+ for gamma in reverse_states:
76
+ source = (alpha, gamma)
77
+ if sources is not None and source not in sources:
78
+ continue
79
+ for transition in forward.graph.out_transitions(alpha):
80
+ symbol = transition.data.get(ATTR_EMISSION)
81
+ prob_forward = as_prob(transition.data.get(ATTR_PROB, 0.0))
82
+ if symbol is None or not is_positive_mass(prob_forward):
83
+ continue
84
+ beta = transition.target
85
+ for delta in reverse_states:
86
+ tex = _reverse_tex_probability(rev_time, gamma, delta, symbol)
87
+ if not is_positive_mass(tex):
88
+ continue
89
+ raw[source].append(((beta, delta), symbol, tex))
90
+
91
+ return _graph_from_raw(raw)
92
+
93
+
94
+ def _graph_from_raw(
95
+ raw: dict[tuple[Hashable, Hashable], list[tuple[tuple[Hashable, Hashable], Any, Any]]],
96
+ ) -> TransitionGraph:
97
+ graph = TransitionGraph()
98
+ registry: dict[tuple[Hashable, Hashable], tuple[Hashable, Hashable]] = {}
99
+
100
+ def _intern(pair: tuple[Hashable, Hashable]) -> tuple[Hashable, Hashable]:
101
+ existing = registry.get(pair)
102
+ if existing is not None:
103
+ return existing
104
+ registry[pair] = pair
105
+ return pair
106
+
107
+ for source, entries in raw.items():
108
+ if not entries:
109
+ continue
110
+ source = _intern(source)
111
+ graph.add_state(source)
112
+ aggregated: dict[tuple[tuple[Hashable, Hashable], Any], Any] = {}
113
+ for target, symbol, weight in entries:
114
+ key = (_intern(target), symbol)
115
+ if key in aggregated:
116
+ aggregated[key] = sum_probs([aggregated[key], weight])
117
+ else:
118
+ aggregated[key] = as_prob(weight)
119
+ total = sum_probs(aggregated.values())
120
+ if not is_positive_mass(total):
121
+ continue
122
+ symbolic = is_symbolic(total) or has_symbolic(aggregated.values())
123
+ for (target, symbol), weight in aggregated.items():
124
+ if symbolic:
125
+ prob = _clean_probability(simplify_prob(as_prob(weight) / as_prob(total)))
126
+ else:
127
+ prob = _clean_probability(float(weight) / float(total))
128
+ graph.add_transition(
129
+ source,
130
+ target,
131
+ **{ATTR_PROB: prob, ATTR_EMISSION: symbol},
132
+ )
133
+ return graph
134
+
135
+
136
+ def _clean_probability(probability: Any) -> Any:
137
+ if is_symbolic(probability):
138
+ return simplify_prob(probability)
139
+ rounded = round(float(probability), 15)
140
+ if np.isclose(probability, rounded, rtol=0.0, atol=1e-15):
141
+ return rounded
142
+ return float(probability)
143
+
144
+
145
+ def _reverse_tex_probability(
146
+ rev_time: EpsilonMachine,
147
+ gamma: Hashable,
148
+ delta: Hashable,
149
+ symbol: Any,
150
+ ) -> Any:
151
+ """T̃_x(γ, δ) from the time-reversed reverse ε-machine (Eq. 18)."""
152
+ masses: list[Any] = []
153
+ for transition in rev_time.graph.out_transitions(gamma):
154
+ if transition.data.get(ATTR_EMISSION) != symbol:
155
+ continue
156
+ if transition.target != delta:
157
+ continue
158
+ masses.append(as_prob(transition.data.get(ATTR_PROB, 0.0)))
159
+ if not masses:
160
+ return 0.0
161
+ return sum_probs(masses)
162
+
163
+
164
+ def _prune_to_stationary_support(
165
+ graph: TransitionGraph,
166
+ forward: EpsilonMachine,
167
+ reverse: EpsilonMachine,
168
+ *,
169
+ tol: float = 1e-12,
170
+ ) -> tuple[TransitionGraph, dict[tuple[Hashable, Hashable], Any]]:
171
+ """Drop transient joint states and return a margin-matching stationary joint π."""
172
+ states = list(graph.states())
173
+ if not states:
174
+ return graph, {}
175
+
176
+ keep = _recurrent_support(graph) or set(states)
177
+ trimmed = _restrict_graph(graph, keep)
178
+ initial = _joint_pi_minimum_support(trimmed, forward, reverse, tol=tol)
179
+ if not initial:
180
+ return trimmed, {}
181
+
182
+ trimmed = _restrict_graph(trimmed, set(initial.keys()))
183
+ keep = _recurrent_support(trimmed) or set(initial.keys())
184
+ trimmed = _restrict_graph(trimmed, keep)
185
+ initial = _joint_pi_minimum_support(trimmed, forward, reverse, tol=tol)
186
+ if initial:
187
+ trimmed = _restrict_graph(trimmed, set(initial.keys()))
188
+ return trimmed, initial
189
+
190
+
191
+ def _compatible_pairs(
192
+ forward: EpsilonMachine,
193
+ reverse: EpsilonMachine,
194
+ ) -> set[tuple[Hashable, Hashable]]:
195
+ """Joint states (α, γ) with positive measure in the bidirectional presentation.
196
+
197
+ Eq. (15) is evaluated on every forward/reverse pair that passes the reverse
198
+ future-symbol filter. When multiple undirected components admit a stationary
199
+ joint π, :func:`_joint_pi_minimum_support` selects the one whose marginals
200
+ match the forward/reverse causal-state stationary distributions.
201
+ """
202
+ future_symbol = _infer_future_symbols(reverse)
203
+ pairs: set[tuple[Hashable, Hashable]] = set()
204
+ for alpha in forward.states():
205
+ for gamma in reverse.states():
206
+ if gamma in future_symbol:
207
+ required = future_symbol[gamma]
208
+ if required not in forward.observation_alphabet:
209
+ continue
210
+ if not _forward_emits(forward, alpha, required):
211
+ continue
212
+ pairs.add((alpha, gamma))
213
+ return pairs
214
+
215
+
216
+ def _forward_emits(forward: EpsilonMachine, state: Hashable, symbol: Any) -> bool:
217
+ return any(
218
+ transition.data.get(ATTR_EMISSION) == symbol and is_positive_mass(as_prob(transition.data.get(ATTR_PROB, 0.0)))
219
+ for transition in forward.graph.out_transitions(state)
220
+ )
221
+
222
+
223
+ def _infer_future_symbols(reverse: EpsilonMachine) -> dict[Hashable, Any]:
224
+ """Map reverse causal states to the future symbol X₀ they carry when annotated."""
225
+ mapping: dict[Hashable, Any] = {}
226
+ alphabet = reverse.observation_alphabet
227
+ for state in reverse.states():
228
+ attrs = reverse.graph.state_attrs(state)
229
+ if ATTR_FUTURE_SYMBOL in attrs:
230
+ mapping[state] = attrs[ATTR_FUTURE_SYMBOL]
231
+ elif state in alphabet:
232
+ mapping[state] = state
233
+ return mapping
234
+
235
+
236
+ def _joint_pi_on_pair_subset(
237
+ graph: TransitionGraph,
238
+ pairs: list[tuple[Hashable, Hashable]],
239
+ *,
240
+ tol: float = 1e-12,
241
+ ) -> dict[tuple[Hashable, Hashable], Any] | None:
242
+ """Stationary joint π over a closed joint-state component, or ``None`` if unavailable.
243
+
244
+ The joint stationary distribution is the normalized left eigenvector (eigenvalue
245
+ one) of the component's row-stochastic transition matrix — the general HMM
246
+ stationary distribution. A component that is not row-stochastic (i.e. leaks
247
+ probability outside ``pairs``) is not a closed recurrent class and is rejected.
248
+ """
249
+ if not pairs:
250
+ return None
251
+
252
+ states = sorted(pairs, key=repr)
253
+ index = {state: i for i, state in enumerate(states)}
254
+ n = len(states)
255
+ edge_probs = [
256
+ as_prob(transition.data.get(ATTR_PROB, 0.0)) for state in states for transition in graph.out_transitions(state)
257
+ ]
258
+ symbolic = has_symbolic(edge_probs)
259
+ matrix = zeros((n, n), symbolic=symbolic)
260
+ for state in states:
261
+ row = index[state]
262
+ for transition in graph.out_transitions(state):
263
+ column = index.get(transition.target)
264
+ if column is None:
265
+ continue
266
+ matrix[row, column] = as_prob(matrix[row, column]) + as_prob(transition.data.get(ATTR_PROB, 0.0))
267
+
268
+ if symbolic:
269
+ for row in range(n):
270
+ if not row_sums_to_one([as_prob(matrix[row, j]) for j in range(n)]):
271
+ return None
272
+ elif not np.allclose(matrix.sum(axis=1), 1.0, atol=1e-9):
273
+ return None
274
+
275
+ try:
276
+ pi = stationary_distribution_from_transition(matrix)
277
+ except (StochasticValidationError, ValueError):
278
+ return None
279
+
280
+ if symbolic:
281
+ joint = {states[i]: simplify_prob(as_prob(pi[i])) for i in range(n) if is_positive_mass(pi[i])}
282
+ else:
283
+ joint = {states[i]: float(pi[i]) for i in range(n) if float(pi[i]) > tol}
284
+ return joint or None
285
+
286
+
287
+ def _undirected_components(
288
+ graph: TransitionGraph,
289
+ ) -> list[set[tuple[Hashable, Hashable]]]:
290
+ import networkx as nx
291
+
292
+ undirected = graph.nx.to_undirected()
293
+ return [set(component) for component in nx.connected_components(undirected)]
294
+
295
+
296
+ def _anatomy_gap_for_joint(
297
+ graph: TransitionGraph,
298
+ joint: dict[tuple[Hashable, Hashable], Any],
299
+ forward: EpsilonMachine,
300
+ reverse: EpsilonMachine,
301
+ ) -> float:
302
+ """Return |b_μ + r_μ − h_μ| for a candidate joint support, or inf if unavailable."""
303
+ try:
304
+ provisional = BidirectionalEpsilonMachine(
305
+ graph=_restrict_graph(graph, set(joint.keys())),
306
+ initial_distribution=joint,
307
+ observation_alphabet=forward.observation_alphabet,
308
+ forward_machine=forward,
309
+ reverse_machine=reverse,
310
+ )
311
+ provisional._joint_pi = dict(joint)
312
+ h_mu = provisional.entropy_rate()
313
+ b_mu = provisional.bound_information()
314
+ r_mu = provisional.ephemeral_information()
315
+ except (ImportError, StochasticValidationError, ValueError):
316
+ return float("inf")
317
+
318
+ if is_symbolic(h_mu) or is_symbolic(b_mu) or is_symbolic(r_mu):
319
+ import sympy as sp
320
+
321
+ gap = sp.simplify(sp.sympify(b_mu) + sp.sympify(r_mu) - sp.sympify(h_mu))
322
+ return 0.0 if gap == 0 else float("inf")
323
+ return abs(float(b_mu) + float(r_mu) - float(h_mu))
324
+
325
+
326
+ def _stationary_state_probabilities(machine: EpsilonMachine) -> dict[Hashable, Any]:
327
+ """Stationary causal-state distribution of ``machine`` keyed by state label."""
328
+ index = machine.reindex()
329
+ pi = machine.stationary_distribution()
330
+ symbolic = pi.dtype == object or has_symbolic(pi.ravel())
331
+ if symbolic:
332
+ return {state: simplify_prob(as_prob(pi[i])) for i, state in enumerate(index.states)}
333
+ return {state: float(pi[i]) for i, state in enumerate(index.states)}
334
+
335
+
336
+ def _abs_marginal_error(left: Any, right: Any) -> float:
337
+ """Absolute marginal deviation (exact sympy simplify, else numeric)."""
338
+ if probs_equal(left, right):
339
+ return 0.0
340
+ if is_symbolic(left) or is_symbolic(right):
341
+ import sympy as sp
342
+
343
+ simplified = sp.simplify(sp.Abs(sp.sympify(left) - sp.sympify(right)))
344
+ if getattr(simplified, "free_symbols", None):
345
+ return float("inf")
346
+ try:
347
+ return abs(float(simplified))
348
+ except (TypeError, ValueError):
349
+ return float("inf")
350
+ return abs(float(left) - float(right))
351
+
352
+
353
+ def _joint_marginal_mismatch(
354
+ joint: dict[tuple[Hashable, Hashable], Any],
355
+ pi_plus: dict[Hashable, Any],
356
+ pi_minus: dict[Hashable, Any],
357
+ ) -> float:
358
+ """Max abs deviation of ``joint``'s marginals from the target stationary marginals.
359
+
360
+ A valid bidirectional presentation is a closed recurrent joint class whose
361
+ forward/reverse marginals equal the forward/reverse causal-state stationary
362
+ distributions. Spurious closed sub-cycles (e.g. the all-``0`` period-3 cycle
363
+ of the Nemo process) violate this and are rejected by the selector below.
364
+ """
365
+ forward_marginal: dict[Hashable, Any] = {}
366
+ reverse_marginal: dict[Hashable, Any] = {}
367
+ for (alpha, gamma), mass in joint.items():
368
+ forward_marginal[alpha] = sum_probs([forward_marginal.get(alpha, 0), mass])
369
+ reverse_marginal[gamma] = sum_probs([reverse_marginal.get(gamma, 0), mass])
370
+
371
+ error = 0.0
372
+ for state in set(pi_plus) | set(forward_marginal):
373
+ error = max(
374
+ error,
375
+ _abs_marginal_error(forward_marginal.get(state, 0), pi_plus.get(state, 0)),
376
+ )
377
+ for state in set(pi_minus) | set(reverse_marginal):
378
+ error = max(
379
+ error,
380
+ _abs_marginal_error(reverse_marginal.get(state, 0), pi_minus.get(state, 0)),
381
+ )
382
+ return error
383
+
384
+
385
+ def _joint_pi_minimum_support(
386
+ graph: TransitionGraph,
387
+ forward: EpsilonMachine,
388
+ reverse: EpsilonMachine,
389
+ *,
390
+ tol: float = 1e-12,
391
+ marginal_tol: float = 1e-6,
392
+ ) -> dict[tuple[Hashable, Hashable], Any]:
393
+ """Pick the closed undirected component that is the true bidirectional class.
394
+
395
+ The joint π on each component is the general HMM stationary distribution (left
396
+ eigenvector for eigenvalue one). When the Eq. (15) graph has more than one
397
+ closed recurrent component (e.g. the Nemo process, which admits a spurious
398
+ all-``0`` period-3 cycle alongside the genuine 6-state machine), the correct
399
+ class is the one whose forward/reverse marginals equal the forward/reverse
400
+ causal-state stationary distributions. Among marginal-matching components the
401
+ information-anatomy identity ``h_μ = b_μ + r_μ`` and then the support size break
402
+ remaining ties; if none match we fall back to the smallest-mismatch component.
403
+ """
404
+ pairs = [state for state in graph.states() if isinstance(state, tuple) and len(state) == 2]
405
+ if not pairs:
406
+ return {}
407
+
408
+ pi_plus = _stationary_state_probabilities(forward)
409
+ pi_minus = _stationary_state_probabilities(reverse)
410
+
411
+ components = sorted(
412
+ _undirected_components(graph),
413
+ key=lambda component: (len(component), sorted(component, key=repr)),
414
+ )
415
+
416
+ candidates: list[tuple[float, float, int, dict[tuple[Hashable, Hashable], Any]]] = []
417
+ for component in components:
418
+ component_pairs = [pair for pair in pairs if pair in component]
419
+ joint = _joint_pi_on_pair_subset(graph, component_pairs, tol=tol)
420
+ if joint is None:
421
+ continue
422
+ marginal_error = _joint_marginal_mismatch(joint, pi_plus, pi_minus)
423
+ anatomy_gap = _anatomy_gap_for_joint(graph, joint, forward, reverse)
424
+ candidates.append((marginal_error, anatomy_gap, len(joint), joint))
425
+
426
+ if not candidates:
427
+ return {}
428
+
429
+ all_symbolic = all(has_symbolic(candidate[3].values()) for candidate in candidates)
430
+ if all_symbolic:
431
+ matching = [candidate for candidate in candidates if candidate[0] == 0.0]
432
+ else:
433
+ matching = [candidate for candidate in candidates if candidate[0] <= marginal_tol]
434
+ if matching:
435
+ best = min(matching, key=lambda candidate: (candidate[1], candidate[2]))
436
+ else:
437
+ best = min(candidates, key=lambda candidate: (candidate[0], candidate[1], candidate[2]))
438
+ return best[3]
439
+
440
+
441
+ def _canonical_joint_state(
442
+ state: tuple[Hashable, Hashable],
443
+ keep: set[tuple[Hashable, Hashable]],
444
+ ) -> tuple[Hashable, Hashable] | None:
445
+ if state in keep:
446
+ return state
447
+ for key in keep:
448
+ if key == state:
449
+ return key
450
+ return None
451
+
452
+
453
+ def _restrict_graph(graph: TransitionGraph, keep: set[tuple[Hashable, Hashable]]) -> TransitionGraph:
454
+ trimmed = TransitionGraph()
455
+ for state in keep:
456
+ trimmed.add_state(state)
457
+ for transition in graph.transitions():
458
+ source = _canonical_joint_state(transition.source, keep)
459
+ target = _canonical_joint_state(transition.target, keep)
460
+ if source is None or target is None:
461
+ continue
462
+ trimmed.add_transition(source, target, **transition.data)
463
+ return trimmed
464
+
465
+
466
+ def _relabel_epsilon_machine(
467
+ machine: EpsilonMachine,
468
+ mapping: Mapping[Hashable, Hashable],
469
+ ) -> EpsilonMachine:
470
+ from sofic.generators.stochastic import normalize_row_weights
471
+
472
+ graph = TransitionGraph()
473
+ for state in machine.states():
474
+ attrs = machine.graph.state_attrs(state)
475
+ graph.add_state(mapping[state], **attrs)
476
+ for state in machine.states():
477
+ outgoing = list(machine.graph.out_transitions(state))
478
+ merged: dict[tuple[Hashable, Any], Any] = {}
479
+ for transition in outgoing:
480
+ prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
481
+ emission = transition.data.get(ATTR_EMISSION)
482
+ key = (mapping[transition.target], emission)
483
+ if key in merged:
484
+ merged[key] = sum_probs([merged[key], prob])
485
+ else:
486
+ merged[key] = prob
487
+ merged = normalize_row_weights(merged)
488
+ source = mapping[state]
489
+ for (target, emission), prob in merged.items():
490
+ attrs = {ATTR_PROB: as_prob(prob)}
491
+ if emission is not None:
492
+ attrs[ATTR_EMISSION] = emission
493
+ graph.add_transition(source, target, **attrs)
494
+ initial = {mapping[state]: as_prob(prob) for state, prob in machine.initial_distribution.items()}
495
+ eps = EpsilonMachine(
496
+ graph=graph,
497
+ initial_distribution=initial,
498
+ observation_alphabet=machine.observation_alphabet,
499
+ )
500
+ eps.validate()
501
+ return eps
502
+
503
+
504
+ def _relabel_collision_free(
505
+ reverse: EpsilonMachine,
506
+ forward: EpsilonMachine,
507
+ ) -> EpsilonMachine:
508
+ """Relabel ``reverse`` to sequential capital letters when names collide with ``forward``."""
509
+ if not set(reverse.states()) & set(forward.states()):
510
+ return reverse
511
+ states = sorted(reverse.states(), key=repr)
512
+ start = next_sequential_label_index(forward.states())
513
+ labels = sequential_labels(len(states), start=start)
514
+ mapping = dict(zip(states, labels, strict=True))
515
+ return _relabel_epsilon_machine(reverse, mapping)
516
+
517
+
518
+ def _validate_bidirectional_anatomy(bidir: BidirectionalEpsilonMachine, *, tol: float = 1e-9) -> None:
519
+ """Ensure positive-support joint states form one weakly connected component."""
520
+ import networkx as nx
521
+
522
+ graph = bidir.to_networkx()
523
+ positive = set(bidir._joint_pi or bidir.joint_distribution())
524
+ if positive:
525
+ subgraph = graph.subgraph(positive).copy()
526
+ if subgraph.number_of_nodes() > 0:
527
+ components = list(nx.weakly_connected_components(subgraph))
528
+ if len(components) != 1:
529
+ raise StochasticValidationError(
530
+ f"bidirectional machine has {len(components)} weak components on positive support"
531
+ )
532
+
533
+
534
+ def infer_reverse_epsilon_machine(forward: EpsilonMachine) -> EpsilonMachine:
535
+ """Infer a reverse ε-machine presentation for bidirectional construction.
536
+
537
+ Time-reverses ``forward``, then builds the generator ε-machine via MSP and
538
+ probabilistic state merging (:meth:`EpsilonMachine.from_hmm`).
539
+ """
540
+ rev_hmm = time_reverse_stochastic(forward)
541
+ reverse = EpsilonMachine.from_hmm(rev_hmm)
542
+ return _relabel_collision_free(reverse, forward)
543
+
544
+
545
+ def _recurrent_support(graph: TransitionGraph, *, tol: float = 1e-12) -> set[tuple[Hashable, Hashable]]:
546
+ """Keep joint states in the recurrent class with stochastic outgoing edges."""
547
+ keep = set(graph.states())
548
+ changed = True
549
+ while changed:
550
+ changed = False
551
+ next_keep: set[tuple[Hashable, Hashable]] = set()
552
+ for state in keep:
553
+ outgoing = [transition for transition in graph.out_transitions(state) if transition.target in keep]
554
+ if not outgoing:
555
+ changed = True
556
+ continue
557
+ probs = [as_prob(t.data.get(ATTR_PROB, 0.0)) for t in outgoing]
558
+ if not row_sums_to_one(probs):
559
+ changed = True
560
+ continue
561
+ next_keep.add(state)
562
+ keep = next_keep
563
+ return keep
564
+
565
+
566
+ def joint_distribution(bidir: BidirectionalEpsilonMachine) -> dict[tuple[Hashable, Hashable], Any]:
567
+ """Return π(α, γ) = P(S⁺ = α, S⁻ = γ) under the bidirectional stationary distribution."""
568
+ if bidir._joint_pi is not None:
569
+ return dict(bidir._joint_pi)
570
+
571
+ idx = bidir.reindex()
572
+ pi = stationary_distribution_hmm(bidir)
573
+ symbolic = pi.dtype == object or has_symbolic(pi.ravel())
574
+ joint: dict[tuple[Hashable, Hashable], Any] = {}
575
+ for index, state in enumerate(idx.states):
576
+ alpha, gamma = state
577
+ mass = as_prob(pi[index])
578
+ if symbolic:
579
+ if is_positive_mass(mass):
580
+ joint[(alpha, gamma)] = simplify_prob(mass)
581
+ elif float(mass) > 0.0:
582
+ joint[(alpha, gamma)] = float(mass)
583
+ return joint
584
+
585
+
586
+ def bidirectional_step_distribution(bidir: BidirectionalEpsilonMachine) -> Any:
587
+ """Stationary joint ``Pr(S⁺₀, S⁻₀, X₀, S⁺₁, S⁻₁)`` as a 5-RV dit Distribution.
588
+
589
+ Random-variable indices (dit ``X0`` … ``X4``):
590
+
591
+ * ``0`` — forward causal state ``S⁺₀``
592
+ * ``1`` — reverse causal state ``S⁻₀``
593
+ * ``2`` — present emission ``X₀``
594
+ * ``3`` — forward causal state ``S⁺₁``
595
+ * ``4`` — reverse causal state ``S⁻₁``
596
+ """
597
+ dit = _require_dit_for_step()
598
+
599
+ joint = joint_distribution(bidir)
600
+ outcomes: list[tuple[Any, ...]] = []
601
+ probs: list[Any] = []
602
+ for (alpha, gamma), mass in joint.items():
603
+ if not is_positive_mass(mass):
604
+ continue
605
+ for transition in bidir.graph.out_transitions((alpha, gamma)):
606
+ symbol = transition.data.get(ATTR_EMISSION)
607
+ prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
608
+ if symbol is None or not is_positive_mass(prob):
609
+ continue
610
+ beta, delta = transition.target
611
+ if has_symbolic([mass, prob]):
612
+ weight = simplify_prob(as_prob(mass) * as_prob(prob))
613
+ else:
614
+ weight = float(mass) * float(prob)
615
+ if not is_positive_mass(weight):
616
+ continue
617
+ outcomes.append((alpha, gamma, symbol, beta, delta))
618
+ probs.append(weight)
619
+
620
+ if not probs:
621
+ raise StochasticValidationError("bidirectional step distribution is empty")
622
+
623
+ total = sum_probs(probs)
624
+ if has_symbolic(probs) or is_symbolic(total):
625
+ from dit.symbolic import symbolic_distribution
626
+
627
+ return symbolic_distribution(
628
+ outcomes,
629
+ [simplify_prob(as_prob(p) / as_prob(total)) for p in probs],
630
+ )
631
+ return dit.Distribution(outcomes, [float(p) / float(total) for p in probs])
632
+
633
+
634
+ def _require_dit_for_step():
635
+ from sofic.generators.measures import require_dit
636
+
637
+ return require_dit("bidirectional step distributions")
638
+
639
+
640
+ def forward_epsilon_machine(bidir: BidirectionalEpsilonMachine) -> EpsilonMachine:
641
+ """Marginalize M± to recover M⁺ (paper Eq. after 2454)."""
642
+ return _marginalize_to_epsilon(bidir, project_forward=True)
643
+
644
+
645
+ def reverse_epsilon_machine(bidir: BidirectionalEpsilonMachine) -> EpsilonMachine:
646
+ """Marginalize M± to recover M⁻ (paper Eq. after 2464)."""
647
+ return _marginalize_to_epsilon(bidir, project_forward=False)
648
+
649
+
650
+ def _marginalize_to_epsilon(
651
+ bidir: BidirectionalEpsilonMachine,
652
+ *,
653
+ project_forward: bool,
654
+ ) -> EpsilonMachine:
655
+ joint = joint_distribution(bidir)
656
+ side = bidir.forward_machine if project_forward else bidir.reverse_machine
657
+ coord = 0 if project_forward else 1
658
+ symbolic = has_symbolic(joint.values())
659
+
660
+ pi_marginal: dict[Hashable, Any] = {}
661
+ for pair, mass in joint.items():
662
+ state = pair[coord]
663
+ if state in pi_marginal:
664
+ pi_marginal[state] = sum_probs([pi_marginal[state], mass])
665
+ else:
666
+ pi_marginal[state] = as_prob(mass)
667
+
668
+ graph = TransitionGraph()
669
+ for state in side.states():
670
+ if project_forward:
671
+ graph.add_state(state)
672
+ else:
673
+ attrs = side.graph.state_attrs(state)
674
+ if ATTR_FUTURE_SYMBOL in attrs:
675
+ graph.add_state(state, **{ATTR_FUTURE_SYMBOL: attrs[ATTR_FUTURE_SYMBOL]})
676
+ else:
677
+ graph.add_state(state)
678
+
679
+ weights: dict[tuple[Hashable, Hashable, Any], Any] = {}
680
+ for pair, mass in joint.items():
681
+ source = pair[coord]
682
+ pi_source = pi_marginal.get(source, 0)
683
+ if not is_positive_mass(mass) or is_zero(pi_source):
684
+ continue
685
+ for transition in side.graph.out_transitions(source):
686
+ symbol = transition.data.get(ATTR_EMISSION)
687
+ prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
688
+ if symbol is None or not is_positive_mass(prob):
689
+ continue
690
+ key = (source, transition.target, symbol)
691
+ contribution = (
692
+ simplify_prob(as_prob(mass) * as_prob(prob) / as_prob(pi_source))
693
+ if symbolic or has_symbolic([mass, prob, pi_source])
694
+ else float(mass) * float(prob) / float(pi_source)
695
+ )
696
+ if key in weights:
697
+ weights[key] = sum_probs([weights[key], contribution])
698
+ else:
699
+ weights[key] = contribution
700
+
701
+ for (source, target, symbol), prob in weights.items():
702
+ if not is_positive_mass(prob):
703
+ continue
704
+ existing = [
705
+ t for t in graph.out_transitions(source) if t.data.get(ATTR_EMISSION) == symbol and t.target == target
706
+ ]
707
+ if existing:
708
+ continue
709
+ graph.add_transition(source, target, **{ATTR_PROB: as_prob(prob), ATTR_EMISSION: symbol})
710
+
711
+ eps = EpsilonMachine(
712
+ graph=graph,
713
+ initial_distribution=pi_marginal,
714
+ observation_alphabet=bidir.observation_alphabet,
715
+ )
716
+ eps.validate()
717
+ return eps