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,703 @@
1
+ """Sample-based ε-machine reconstruction (CSSR, subtree merging, and spectral).
2
+
3
+ CSSR follows Shalizi, Shalizi & Crutchfield (arXiv:cs/0210025). Subtree merging
4
+ follows Crutchfield & Young (PRL 1989; PRE 1994). Spectral reconstruction learns
5
+ a weighted finite automaton by Hankel SVD :cite:`Balle2014,Hsu2012` and extracts
6
+ causal states as mixed states of the learned operators :cite:`Ellison2009`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import Counter, defaultdict
12
+ from collections.abc import Callable, Iterable, Sequence
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, ClassVar, Literal
15
+
16
+ import numpy as np
17
+ from scipy import stats
18
+
19
+ from sofic.exceptions import StochasticValidationError
20
+ from sofic.generators.epsilon_machine import EpsilonMachine
21
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB, TransitionGraph
22
+
23
+ History = tuple[Any, ...]
24
+
25
+
26
+ @dataclass
27
+ class SuffixCounts:
28
+ """Empirical counts of histories and following symbols in a sequence."""
29
+
30
+ alphabet: tuple[Any, ...]
31
+ history_counts: Counter[History] = field(default_factory=Counter)
32
+ next_counts: dict[History, Counter[Any]] = field(default_factory=lambda: defaultdict(Counter))
33
+
34
+ #: History key used as the fallback for an empty history set (overridden by stack counts).
35
+ empty_history: ClassVar[History] = ()
36
+
37
+ @classmethod
38
+ def from_sequence(
39
+ cls,
40
+ sequence: Sequence[Any],
41
+ *,
42
+ alphabet: Sequence[Any] | None = None,
43
+ max_length: int | None = None,
44
+ ) -> SuffixCounts:
45
+ seq = tuple(sequence)
46
+ if not seq:
47
+ raise ValueError("sequence must be non-empty")
48
+ alphabet = tuple(sorted(set(seq), key=repr)) if alphabet is None else tuple(alphabet)
49
+ unknown = set(seq) - set(alphabet)
50
+ if unknown:
51
+ raise ValueError(f"symbols {unknown!r} not in alphabet")
52
+ max_len = max_length if max_length is not None else len(seq)
53
+ counts = cls(alphabet=alphabet)
54
+ n = len(seq)
55
+ for t in range(n):
56
+ for length in range(0, min(t, max_len) + 1):
57
+ history = seq[t - length : t]
58
+ counts.history_counts[history] += 1
59
+ nxt = seq[t]
60
+ counts.next_counts[history][nxt] += 1
61
+ return counts
62
+
63
+ def morph(self, history: History, *, smoothing: float = 0.0) -> dict[Any, float]:
64
+ """MLE (optional additive smoothing) of P(next symbol | history)."""
65
+ counts = self.next_counts.get(history, Counter())
66
+ total = sum(counts.values())
67
+ if total == 0:
68
+ uniform = 1.0 / len(self.alphabet)
69
+ return dict.fromkeys(self.alphabet, uniform)
70
+ denom = total + smoothing * len(self.alphabet)
71
+ return {symbol: (counts.get(symbol, 0) + smoothing) / denom for symbol in self.alphabet}
72
+
73
+ def state_morph(self, histories: set[History], *, smoothing: float = 0.0) -> dict[Any, float]:
74
+ """Weighted average of history morphs with weights from occurrence counts."""
75
+ weights = {history: float(self.history_counts.get(history, 0)) for history in histories}
76
+ total_weight = sum(weights.values())
77
+ if total_weight <= 0.0:
78
+ return self.morph(self.empty_history, smoothing=smoothing)
79
+ result = dict.fromkeys(self.alphabet, 0.0)
80
+ for history, weight in weights.items():
81
+ morph = self.morph(history, smoothing=smoothing)
82
+ for symbol in self.alphabet:
83
+ result[symbol] += weight * morph[symbol]
84
+ return {symbol: prob / total_weight for symbol, prob in result.items()}
85
+
86
+ def marginal_morph(self) -> dict[Any, float]:
87
+ """Global next-symbol distribution (IID morph at L=0)."""
88
+ counts = Counter()
89
+ for _history, counter in self.next_counts.items():
90
+ counts.update(counter)
91
+ grand = sum(counts.values())
92
+ if grand == 0:
93
+ uniform = 1.0 / len(self.alphabet)
94
+ return dict.fromkeys(self.alphabet, uniform)
95
+ return {symbol: counts.get(symbol, 0) / grand for symbol in self.alphabet}
96
+
97
+ def restricted_to(self, histories: set[History]) -> SuffixCounts:
98
+ """Return a plain :class:`SuffixCounts` proxy limited to ``histories``.
99
+
100
+ The morph/comparison helpers only read the history sets handed to them, so
101
+ stack inference can reuse them by projecting its configuration counts onto a
102
+ flat proxy without changing any results.
103
+ """
104
+ proxy = SuffixCounts(alphabet=self.alphabet)
105
+ proxy.history_counts = Counter({h: self.history_counts.get(h, 0) for h in histories})
106
+ proxy.next_counts = defaultdict(Counter)
107
+ for history in histories:
108
+ proxy.next_counts[history] = self.next_counts.get(history, Counter())
109
+ return proxy
110
+
111
+
112
+ def _observed_counts_for_morph(
113
+ counts: SuffixCounts,
114
+ histories: set[History],
115
+ ) -> Counter[Any]:
116
+ observed = Counter()
117
+ for history in histories:
118
+ observed.update(counts.next_counts.get(history, Counter()))
119
+ return observed
120
+
121
+
122
+ def _contingency_rows(
123
+ counts: SuffixCounts,
124
+ left_histories: set[History],
125
+ right_histories: set[History],
126
+ ) -> np.ndarray | None:
127
+ left_obs = _observed_counts_for_morph(counts, left_histories)
128
+ right_obs = _observed_counts_for_morph(counts, right_histories)
129
+ active = [symbol for symbol in counts.alphabet if left_obs.get(symbol, 0) + right_obs.get(symbol, 0) > 0]
130
+ if not active:
131
+ return None
132
+ table = np.array(
133
+ [
134
+ [left_obs.get(symbol, 0) for symbol in active],
135
+ [right_obs.get(symbol, 0) for symbol in active],
136
+ ],
137
+ dtype=float,
138
+ )
139
+ if np.allclose(table[0], table[1]):
140
+ return None
141
+ if table.shape[1] < 2:
142
+ left_total = table[0].sum()
143
+ right_total = table[1].sum()
144
+ if left_total == 0.0 or right_total == 0.0:
145
+ return None
146
+ left_prob = table[0, 0] / left_total
147
+ right_prob = table[1, 0] / right_total
148
+ if np.isclose(left_prob, right_prob):
149
+ return None
150
+ return table
151
+
152
+
153
+ def morphs_differ(
154
+ counts: SuffixCounts,
155
+ left_histories: set[History],
156
+ right_histories: set[History],
157
+ *,
158
+ alpha: float = 0.05,
159
+ test: Literal["g", "chi2", "tv"] = "g",
160
+ delta: float = 0.0,
161
+ ) -> bool:
162
+ """Return whether two history sets have significantly different morphs."""
163
+ if test == "tv":
164
+ left = counts.state_morph(left_histories)
165
+ right = counts.state_morph(right_histories)
166
+ distance = 0.5 * sum(abs(left[s] - right[s]) for s in counts.alphabet)
167
+ return distance > delta
168
+
169
+ table = _contingency_rows(counts, left_histories, right_histories)
170
+ if table is None:
171
+ return False
172
+ if test == "g":
173
+ try:
174
+ with np.errstate(invalid="ignore", divide="ignore"):
175
+ statistic, _p_value, _dof, expected = stats.chi2_contingency(table, lambda_="log-likelihood")
176
+ except ValueError:
177
+ return False
178
+ if not np.isfinite(statistic) or np.any(expected == 0):
179
+ return False
180
+ dof = max(1, table.shape[1] - 1)
181
+ critical = float(stats.chi2.ppf(1.0 - alpha, dof))
182
+ return float(statistic) > critical
183
+ try:
184
+ statistic, p_value, _dof, expected = stats.chi2_contingency(table)
185
+ except ValueError:
186
+ return False
187
+ if np.any(expected == 0):
188
+ return False
189
+ return float(p_value) < alpha
190
+
191
+
192
+ def morph_test_score(
193
+ counts: SuffixCounts,
194
+ left_histories: set[History],
195
+ right_histories: set[History],
196
+ *,
197
+ test: Literal["g", "chi2", "tv"] = "g",
198
+ ) -> float:
199
+ """Score for matching morphs (lower is more similar)."""
200
+ if test == "tv":
201
+ left = counts.state_morph(left_histories)
202
+ right = counts.state_morph(right_histories)
203
+ return 0.5 * sum(abs(left[s] - right[s]) for s in counts.alphabet)
204
+ table = _contingency_rows(counts, left_histories, right_histories)
205
+ if table is None:
206
+ return 0.0
207
+ try:
208
+ if test == "g":
209
+ with np.errstate(invalid="ignore", divide="ignore"):
210
+ statistic, _p, _dof, _expected = stats.chi2_contingency(table, lambda_="log-likelihood")
211
+ return float(statistic) if np.isfinite(statistic) else 0.0
212
+ statistic, _p, _dof, _expected = stats.chi2_contingency(table)
213
+ return float(statistic)
214
+ except ValueError:
215
+ return 0.0
216
+
217
+
218
+ def _default_lmax(n: int, alphabet_size: int, min_count: int) -> int:
219
+ if alphabet_size <= 0:
220
+ return 1
221
+ return max(1, min(15, n // max(1, alphabet_size * min_count)))
222
+
223
+
224
+ def _grow_history(history: History, symbol: Any) -> History:
225
+ """Default successor: append the symbol without truncation (flat ε-machine CSSR)."""
226
+ return history + (symbol,)
227
+
228
+
229
+ def _cssr_homogenize(
230
+ counts: SuffixCounts,
231
+ *,
232
+ Lmax: int,
233
+ alpha: float,
234
+ test: Literal["g", "chi2", "tv"],
235
+ successor_fn: Callable[[History, Any], History] = _grow_history,
236
+ ) -> tuple[dict[int, set[History]], dict[History, int]]:
237
+ """Return state id -> histories and history -> state id."""
238
+ states: dict[int, set[History]] = {0: {counts.empty_history}}
239
+ history_to_state: dict[History, int] = {counts.empty_history: 0}
240
+ next_state_id = 1
241
+
242
+ for _length in range(Lmax + 1):
243
+ for state_id in sorted(states):
244
+ histories = set(states[state_id])
245
+ for history in list(histories):
246
+ for symbol in counts.alphabet:
247
+ child = successor_fn(history, symbol)
248
+ if child in history_to_state:
249
+ continue
250
+ if counts.history_counts.get(child, 0) == 0:
251
+ continue
252
+ child_histories = {child}
253
+ if morphs_differ(
254
+ counts,
255
+ histories,
256
+ child_histories,
257
+ alpha=alpha,
258
+ test=test,
259
+ ):
260
+ best_state: int | None = None
261
+ best_score = float("inf")
262
+ for candidate_id, candidate_histories in states.items():
263
+ if morphs_differ(
264
+ counts,
265
+ candidate_histories,
266
+ child_histories,
267
+ alpha=alpha,
268
+ test=test,
269
+ ):
270
+ continue
271
+ score = morph_test_score(
272
+ counts,
273
+ candidate_histories,
274
+ child_histories,
275
+ test=test,
276
+ )
277
+ if score < best_score:
278
+ best_score = score
279
+ best_state = candidate_id
280
+ if best_state is None:
281
+ best_state = next_state_id
282
+ states[next_state_id] = set()
283
+ next_state_id += 1
284
+ states[best_state].add(child)
285
+ history_to_state[child] = best_state
286
+ else:
287
+ states[state_id].add(child)
288
+ history_to_state[child] = state_id
289
+ return states, history_to_state
290
+
291
+
292
+ def _cssr_determinize(
293
+ states: dict[int, set[History]],
294
+ history_to_state: dict[History, int],
295
+ counts: SuffixCounts,
296
+ *,
297
+ length: int,
298
+ successor_fn: Callable[[History, Any], History] = _grow_history,
299
+ ) -> dict[int, set[History]]:
300
+ """Split homogeneous states until transitions are unifilar."""
301
+ current = {state_id: set(histories) for state_id, histories in states.items()}
302
+ changed = True
303
+ next_state_id = max(current) + 1 if current else 0
304
+
305
+ while changed:
306
+ changed = False
307
+ for state_id in sorted(current):
308
+ histories = current[state_id]
309
+ if len(histories) <= 1:
310
+ continue
311
+ for symbol in counts.alphabet:
312
+ buckets: dict[int, set[History]] = defaultdict(set)
313
+ for history in histories:
314
+ if counts.next_counts.get(history, Counter()).get(symbol, 0) == 0:
315
+ continue
316
+ child = successor_fn(history, symbol)
317
+ target = history_to_state.get(child)
318
+ if target is None:
319
+ continue
320
+ buckets[target].add(history)
321
+ if len(buckets) <= 1:
322
+ continue
323
+ # Keep the largest bucket in the original state; split others.
324
+ ordered = sorted(buckets.items(), key=lambda item: (-len(item[1]), min(item[1])))
325
+ keep_target, keep_histories = ordered[0]
326
+ current[state_id] = keep_histories
327
+ for _target, split_histories in ordered[1:]:
328
+ new_id = next_state_id
329
+ next_state_id += 1
330
+ current[new_id] = split_histories
331
+ for history in split_histories:
332
+ history_to_state[history] = new_id
333
+ changed = True
334
+ break
335
+ if changed:
336
+ break
337
+ return current
338
+
339
+
340
+ def _merge_similar_states(
341
+ states: dict[int, set[History]],
342
+ history_to_state: dict[History, int],
343
+ counts: SuffixCounts,
344
+ *,
345
+ alpha: float,
346
+ test: Literal["g", "chi2", "tv"],
347
+ ) -> dict[int, set[History]]:
348
+ """Merge inferred states whose pooled morphs are statistically indistinguishable.
349
+
350
+ Merging on the morph alone can fuse states with incompatible ``symbol ->
351
+ successor`` maps, yielding a non-unifilar partition. Callers must re-run
352
+ :func:`_cssr_determinize` afterwards to restore unifilarity.
353
+ """
354
+ current = {state_id: set(histories) for state_id, histories in states.items()}
355
+ changed = True
356
+ while changed:
357
+ changed = False
358
+ state_ids = sorted(current)
359
+ for index, left_id in enumerate(state_ids):
360
+ if left_id not in current:
361
+ continue
362
+ for right_id in state_ids[index + 1 :]:
363
+ if right_id not in current:
364
+ continue
365
+ if morphs_differ(
366
+ counts,
367
+ current[left_id],
368
+ current[right_id],
369
+ alpha=alpha,
370
+ test=test,
371
+ ):
372
+ continue
373
+ current[left_id].update(current.pop(right_id))
374
+ for history in current[left_id]:
375
+ history_to_state[history] = left_id
376
+ changed = True
377
+ break
378
+ if changed:
379
+ break
380
+ return current
381
+
382
+
383
+ def _drop_transient_states(
384
+ states: dict[int, set[History]],
385
+ history_to_state: dict[History, int],
386
+ counts: SuffixCounts,
387
+ *,
388
+ length: int,
389
+ ) -> dict[int, set[History]]:
390
+ """Keep only states in bottom strongly connected components."""
391
+ import networkx as nx
392
+
393
+ successors: dict[int, dict[Any, set[int]]] = defaultdict(lambda: defaultdict(set))
394
+ for state_id, histories in states.items():
395
+ for history in histories:
396
+ for symbol in counts.alphabet:
397
+ if counts.next_counts.get(history, Counter()).get(symbol, 0) == 0:
398
+ continue
399
+ child = history + (symbol,)
400
+ target = history_to_state.get(child)
401
+ if target is None:
402
+ continue
403
+ successors[state_id][symbol].add(target)
404
+
405
+ graph = nx.DiGraph()
406
+ for state_id in states:
407
+ graph.add_node(state_id)
408
+ for state_id, by_symbol in successors.items():
409
+ for targets in by_symbol.values():
410
+ for target in targets:
411
+ graph.add_edge(state_id, target)
412
+
413
+ if graph.number_of_edges() == 0:
414
+ return states
415
+
416
+ recurrent: set[int] = set()
417
+ for component in nx.strongly_connected_components(graph):
418
+ if not component:
419
+ continue
420
+ subgraph = graph.subgraph(component)
421
+ has_cycle = subgraph.number_of_edges() > 0 and (
422
+ len(component) > 1 or any(subgraph.has_edge(node, node) for node in component)
423
+ )
424
+ if not has_cycle:
425
+ continue
426
+ outgoing = any(graph.has_edge(v, w) for v in component for w in graph.nodes if w not in component)
427
+ if not outgoing:
428
+ recurrent.update(component)
429
+
430
+ if not recurrent:
431
+ return states
432
+ return {state_id: histories for state_id, histories in states.items() if state_id in recurrent}
433
+
434
+
435
+ def _empirical_state_visits(
436
+ sequence: Sequence[Any],
437
+ history_to_state: dict[History, int],
438
+ *,
439
+ length: int,
440
+ ) -> Counter[int]:
441
+ """Count how often each causal state is occupied along ``sequence``.
442
+
443
+ Every time step belongs to exactly one causal state — the one keyed by the
444
+ *longest* available suffix (up to ``length``). Counting each nested suffix
445
+ (as an earlier version did) over-weights short-history states and skews the
446
+ reconstructed ``initial_distribution`` away from the occupation/stationary law.
447
+ """
448
+ visits: Counter[int] = Counter()
449
+ seq = tuple(sequence)
450
+ for t in range(len(seq)):
451
+ for hist_len in range(min(t, length), -1, -1):
452
+ history = seq[t - hist_len : t]
453
+ state = history_to_state.get(history)
454
+ if state is not None:
455
+ visits[state] += 1
456
+ break
457
+ return visits
458
+
459
+
460
+ def _counts_to_mealy(
461
+ states: dict[int, set[History]],
462
+ counts: SuffixCounts,
463
+ history_to_state: dict[History, int],
464
+ sequence: Sequence[Any],
465
+ *,
466
+ length: int,
467
+ ) -> EpsilonMachine:
468
+ visits = _empirical_state_visits(sequence, history_to_state, length=length)
469
+ if not visits:
470
+ raise StochasticValidationError("no empirical state visits")
471
+
472
+ graph = TransitionGraph()
473
+ state_labels = {state_id: f"s{state_id}" for state_id in states}
474
+ for label in state_labels.values():
475
+ graph.add_state(label)
476
+
477
+ for state_id, histories in states.items():
478
+ label = state_labels[state_id]
479
+ morph = counts.state_morph(histories)
480
+ for symbol in counts.alphabet:
481
+ prob = morph[symbol]
482
+ if prob <= 0.0:
483
+ continue
484
+ emitting = [
485
+ history for history in histories if counts.next_counts.get(history, Counter()).get(symbol, 0) > 0
486
+ ]
487
+ if not emitting:
488
+ continue
489
+ child_histories = {history + (symbol,) for history in emitting}
490
+ targets = {history_to_state.get(child) for child in child_histories}
491
+ targets.discard(None)
492
+ if not targets:
493
+ continue
494
+ if len(targets) > 1:
495
+ raise StochasticValidationError(f"non-unifilar inferred transition from {label!r} on {symbol!r}")
496
+ target_label = state_labels[next(iter(targets))]
497
+ graph.add_transition(label, target_label, **{ATTR_PROB: prob, ATTR_EMISSION: symbol})
498
+
499
+ total_visits = float(sum(visits.values()))
500
+ initial = {state_labels[state_id]: visits[state_id] / total_visits for state_id in states if visits[state_id] > 0}
501
+ if not initial:
502
+ initial = {state_labels[next(iter(states))]: 1.0}
503
+
504
+ machine = EpsilonMachine(
505
+ graph=graph,
506
+ initial_distribution=initial,
507
+ observation_alphabet=frozenset(counts.alphabet),
508
+ )
509
+ machine.validate()
510
+ return machine
511
+
512
+
513
+ def cssr(
514
+ sequence: Sequence[Any],
515
+ *,
516
+ alphabet: Sequence[Any] | None = None,
517
+ Lmax: int | None = None,
518
+ alpha: float = 0.05,
519
+ test: Literal["g", "chi2", "tv"] = "g",
520
+ min_count: int = 5,
521
+ ) -> EpsilonMachine:
522
+ """Reconstruct an ε-machine by Causal-State Splitting Reconstruction (CSSR)."""
523
+ seq = tuple(sequence)
524
+ if len(seq) < 2:
525
+ raise ValueError("sequence must contain at least two symbols")
526
+ alphabet_size = len(set(seq)) if alphabet is None else len(tuple(alphabet))
527
+ max_length = Lmax if Lmax is not None else _default_lmax(len(seq), alphabet_size, min_count)
528
+ counts = SuffixCounts.from_sequence(seq, alphabet=alphabet, max_length=max_length + 1)
529
+
530
+ states, history_to_state = _cssr_homogenize(
531
+ counts,
532
+ Lmax=max_length,
533
+ alpha=alpha,
534
+ test=test,
535
+ )
536
+ states = _cssr_determinize(states, history_to_state, counts, length=max_length)
537
+ states = _merge_similar_states(states, history_to_state, counts, alpha=alpha, test=test)
538
+ # Re-determinize: morph-only merging can fuse states with incompatible
539
+ # successors, so restore unifilarity before building the machine.
540
+ states = _cssr_determinize(states, history_to_state, counts, length=max_length)
541
+ states = _drop_transient_states(states, history_to_state, counts, length=max_length)
542
+ history_to_state = {history: state_id for state_id, histories in states.items() for history in histories}
543
+ return _counts_to_mealy(states, counts, history_to_state, seq, length=max_length)
544
+
545
+
546
+ def _morph_distance(
547
+ counts: SuffixCounts,
548
+ left: History,
549
+ right: History,
550
+ *,
551
+ delta: float,
552
+ ) -> float:
553
+ left_morph = counts.morph(left)
554
+ right_morph = counts.morph(right)
555
+ return 0.5 * sum(abs(left_morph[s] - right_morph[s]) for s in counts.alphabet)
556
+
557
+
558
+ def _morphs_equivalent(
559
+ counts: SuffixCounts,
560
+ left: History,
561
+ right: History,
562
+ *,
563
+ delta: float,
564
+ ) -> bool:
565
+ left_morph = counts.morph(left)
566
+ right_morph = counts.morph(right)
567
+ if delta > 0.0:
568
+ return _morph_distance(counts, left, right, delta=delta) <= delta
569
+ return all(np.isclose(left_morph[symbol], right_morph[symbol], rtol=0.0, atol=1e-3) for symbol in counts.alphabet)
570
+
571
+
572
+ def _cluster_histories_by_morph(
573
+ counts: SuffixCounts,
574
+ histories: set[History],
575
+ *,
576
+ delta: float,
577
+ ) -> dict[int, set[History]]:
578
+ parent: dict[History, History] = {history: history for history in histories}
579
+
580
+ def find(history: History) -> History:
581
+ root = history
582
+ while parent[root] != root:
583
+ parent[root] = parent[parent[root]]
584
+ root = parent[root]
585
+ return root
586
+
587
+ def union(left: History, right: History) -> None:
588
+ left_root = find(left)
589
+ right_root = find(right)
590
+ if left_root != right_root:
591
+ parent[right_root] = left_root
592
+
593
+ history_list = sorted(histories)
594
+ for index, left in enumerate(history_list):
595
+ for right in history_list[index + 1 :]:
596
+ if _morphs_equivalent(counts, left, right, delta=delta):
597
+ union(left, right)
598
+
599
+ clusters: dict[History, set[History]] = defaultdict(set)
600
+ for history in histories:
601
+ clusters[find(history)].add(history)
602
+
603
+ states: dict[int, set[History]] = {}
604
+ for state_id, (_root, members) in enumerate(clusters.items()):
605
+ states[state_id] = set(members)
606
+ return states
607
+
608
+
609
+ def subtree_merge(
610
+ sequence: Sequence[Any],
611
+ *,
612
+ L: int,
613
+ delta: float = 0.0,
614
+ alphabet: Sequence[Any] | None = None,
615
+ ) -> EpsilonMachine:
616
+ """Reconstruct an ε-machine by merging depth-``L`` subtrees (Crutchfield--Young)."""
617
+ if L < 0:
618
+ raise ValueError("L must be non-negative")
619
+ seq = tuple(sequence)
620
+ if len(seq) < 2:
621
+ raise ValueError("sequence must contain at least two symbols")
622
+ counts = SuffixCounts.from_sequence(seq, alphabet=alphabet, max_length=L + 1)
623
+
624
+ histories = {history for history in counts.history_counts if len(history) <= L}
625
+ histories.add(())
626
+
627
+ states = _cluster_histories_by_morph(counts, histories, delta=delta)
628
+ history_to_state = {history: state_id for state_id, members in states.items() for history in members}
629
+
630
+ states = _cssr_determinize(states, history_to_state, counts, length=L)
631
+ history_to_state = {
632
+ history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
633
+ }
634
+ states = _merge_similar_states(states, history_to_state, counts, alpha=0.05, test="tv")
635
+ history_to_state = {
636
+ history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
637
+ }
638
+ # Re-determinize: morph-only merging can fuse states with incompatible
639
+ # successors, so restore unifilarity before building the machine.
640
+ states = _cssr_determinize(states, history_to_state, counts, length=L)
641
+ states = _drop_transient_states(states, history_to_state, counts, length=L)
642
+ history_to_state = {
643
+ history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
644
+ }
645
+ return _counts_to_mealy(states, counts, history_to_state, seq, length=L)
646
+
647
+
648
+ def spectral(
649
+ sequences: Iterable[Any] | None = None,
650
+ *,
651
+ word_probability: Callable[[Sequence[Any]], float] | None = None,
652
+ alphabet: Sequence[Any] | None = None,
653
+ rank: int | None = None,
654
+ prefix_length: int = 3,
655
+ suffix_length: int | None = None,
656
+ singular_value_threshold: float = 1e-3,
657
+ min_singular_value: float = 1e-12,
658
+ max_states: int = 10_000,
659
+ ) -> EpsilonMachine:
660
+ """Reconstruct an ε-machine by spectral learning then mixed-state extraction.
661
+
662
+ Learns a weighted finite automaton / observable-operator model from block
663
+ statistics :cite:`Balle2014,Hsu2012`, then extracts causal states as the
664
+ mixed states of those operators :cite:`Ellison2009`. When the learned
665
+ operators are non-negative this is a Mealy projection followed by
666
+ :meth:`~sofic.generators.epsilon_machine.EpsilonMachine.from_hmm`; signed
667
+ operators use mixed-state enumeration rather than a clustering heuristic.
668
+
669
+ Parameters
670
+ ----------
671
+ sequences
672
+ A single observed realization or an iterable of realizations. Ignored
673
+ when ``word_probability`` is given.
674
+ word_probability
675
+ Optional exact block-probability function ``f(word) -> float``.
676
+ ``alphabet`` is then required.
677
+ alphabet
678
+ Observation alphabet. Inferred from ``sequences`` when omitted.
679
+ rank
680
+ Number of latent states. When ``None`` the rank is chosen from the
681
+ Hankel singular-value spectrum.
682
+ prefix_length, suffix_length
683
+ Maximum lengths of the prefix and suffix bases. ``suffix_length``
684
+ defaults to ``prefix_length``.
685
+ singular_value_threshold, min_singular_value
686
+ Cutoffs for automatic rank selection; see
687
+ :func:`~sofic.inference.spectral.learn_spectral_wfa`.
688
+ max_states
689
+ Safety cap on enumerated mixed states.
690
+ """
691
+ from sofic.inference.spectral import learn_spectral_wfa, project_to_epsilon_machine
692
+
693
+ model = learn_spectral_wfa(
694
+ sequences,
695
+ word_probability=word_probability,
696
+ alphabet=alphabet,
697
+ rank=rank,
698
+ prefix_length=prefix_length,
699
+ suffix_length=suffix_length,
700
+ singular_value_threshold=singular_value_threshold,
701
+ min_singular_value=min_singular_value,
702
+ )
703
+ return project_to_epsilon_machine(model, max_states=max_states)