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,499 @@
1
+ """transCSSR: sample-based epsilon-transducer reconstruction.
2
+
3
+ Generalizes Causal-State Splitting Reconstruction (Shalizi, Shalizi &
4
+ Crutchfield, arXiv:cs/0210025) from a single process to an input-output channel,
5
+ following the ε-transducer of Barnett & Crutchfield (J. Stat. Phys. 161:2
6
+ (2015)) and the transCSSR algorithm (Darmon & Rapp, ``ddarmon/transCSSR``).
7
+
8
+ Causal states are equivalence classes of joint ``(input, output)`` pasts that
9
+ induce the same conditional next-output law ``P(y | history, x)`` for every input
10
+ symbol ``x``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import Counter, defaultdict
16
+ from collections.abc import Sequence
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Literal
19
+
20
+ import numpy as np
21
+ from scipy import stats
22
+
23
+ from sofic.exceptions import StochasticValidationError
24
+ from sofic.generators.epsilon_transducer import EpsilonTransducer
25
+ from sofic.graph import ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, TransitionGraph
26
+
27
+ JointHistory = tuple[tuple[Any, Any], ...]
28
+
29
+
30
+ @dataclass
31
+ class JointSuffixCounts:
32
+ """Empirical counts of joint pasts and following input-conditioned outputs."""
33
+
34
+ input_alphabet: tuple[Any, ...]
35
+ output_alphabet: tuple[Any, ...]
36
+ history_counts: Counter[JointHistory] = field(default_factory=Counter)
37
+ #: ``next_counts[history][input]`` is a Counter over following output symbols.
38
+ next_counts: dict[JointHistory, dict[Any, Counter[Any]]] = field(default_factory=dict)
39
+
40
+ @classmethod
41
+ def from_sequences(
42
+ cls,
43
+ inputs: Sequence[Any],
44
+ outputs: Sequence[Any],
45
+ *,
46
+ input_alphabet: Sequence[Any] | None = None,
47
+ output_alphabet: Sequence[Any] | None = None,
48
+ max_length: int,
49
+ ) -> JointSuffixCounts:
50
+ xs = tuple(inputs)
51
+ ys = tuple(outputs)
52
+ if len(xs) != len(ys):
53
+ raise ValueError("inputs and outputs must have equal length")
54
+ if not xs:
55
+ raise ValueError("sequences must be non-empty")
56
+ in_alpha = tuple(sorted(set(xs), key=repr)) if input_alphabet is None else tuple(input_alphabet)
57
+ out_alpha = tuple(sorted(set(ys), key=repr)) if output_alphabet is None else tuple(output_alphabet)
58
+ counts = cls(input_alphabet=in_alpha, output_alphabet=out_alpha)
59
+ pairs = tuple(zip(xs, ys, strict=True))
60
+ n = len(pairs)
61
+ for t in range(n):
62
+ for length in range(0, min(t, max_length) + 1):
63
+ history = pairs[t - length : t]
64
+ counts.history_counts[history] += 1
65
+ by_input = counts.next_counts.setdefault(history, {})
66
+ by_input.setdefault(xs[t], Counter())[ys[t]] += 1
67
+ return counts
68
+
69
+ def output_counts(self, histories: set[JointHistory], input_symbol: Any) -> Counter[Any]:
70
+ observed: Counter[Any] = Counter()
71
+ for history in histories:
72
+ by_input = self.next_counts.get(history)
73
+ if by_input is None:
74
+ continue
75
+ counter = by_input.get(input_symbol)
76
+ if counter is not None:
77
+ observed.update(counter)
78
+ return observed
79
+
80
+ def state_morph(self, histories: set[JointHistory], input_symbol: Any) -> dict[Any, float]:
81
+ """Return ``P(output | histories, input_symbol)``."""
82
+ observed = self.output_counts(histories, input_symbol)
83
+ total = sum(observed.values())
84
+ if total == 0:
85
+ return {}
86
+ return {symbol: observed.get(symbol, 0) / total for symbol in self.output_alphabet}
87
+
88
+
89
+ def _output_contingency(left: Counter[Any], right: Counter[Any], alphabet: tuple[Any, ...]) -> np.ndarray | None:
90
+ active = [symbol for symbol in alphabet if left.get(symbol, 0) + right.get(symbol, 0) > 0]
91
+ if not active:
92
+ return None
93
+ table = np.array(
94
+ [[left.get(symbol, 0) for symbol in active], [right.get(symbol, 0) for symbol in active]],
95
+ dtype=float,
96
+ )
97
+ if np.allclose(table[0], table[1]):
98
+ return None
99
+ if table.shape[1] < 2:
100
+ left_total = table[0].sum()
101
+ right_total = table[1].sum()
102
+ if left_total == 0.0 or right_total == 0.0:
103
+ return None
104
+ if np.isclose(table[0, 0] / left_total, table[1, 0] / right_total):
105
+ return None
106
+ return table
107
+
108
+
109
+ #: Aggregated output counts of a state: ``agg[input_symbol]`` is a Counter over outputs.
110
+ StateAggregate = dict[Any, Counter[Any]]
111
+
112
+
113
+ def _history_aggregate(counts: JointSuffixCounts, history: JointHistory) -> StateAggregate:
114
+ return {input_symbol: Counter(counter) for input_symbol, counter in counts.next_counts.get(history, {}).items()}
115
+
116
+
117
+ def _merge_aggregate(target: StateAggregate, source: StateAggregate) -> None:
118
+ for input_symbol, counter in source.items():
119
+ target.setdefault(input_symbol, Counter()).update(counter)
120
+
121
+
122
+ def aggregates_differ(
123
+ left: StateAggregate,
124
+ right: StateAggregate,
125
+ *,
126
+ input_alphabet: tuple[Any, ...],
127
+ output_alphabet: tuple[Any, ...],
128
+ alpha: float,
129
+ test: Literal["g", "chi2"] = "g",
130
+ ) -> bool:
131
+ """Return whether two aggregated morphs differ on ``P(output | ., input)`` for some input."""
132
+ for input_symbol in input_alphabet:
133
+ table = _output_contingency(
134
+ left.get(input_symbol, Counter()),
135
+ right.get(input_symbol, Counter()),
136
+ output_alphabet,
137
+ )
138
+ if table is None:
139
+ continue
140
+ if _table_significant(table, alpha=alpha, test=test):
141
+ return True
142
+ return False
143
+
144
+
145
+ def _aggregate_score(
146
+ left: StateAggregate,
147
+ right: StateAggregate,
148
+ *,
149
+ input_alphabet: tuple[Any, ...],
150
+ output_alphabet: tuple[Any, ...],
151
+ ) -> float:
152
+ total = 0.0
153
+ for input_symbol in input_alphabet:
154
+ table = _output_contingency(
155
+ left.get(input_symbol, Counter()),
156
+ right.get(input_symbol, Counter()),
157
+ output_alphabet,
158
+ )
159
+ if table is None:
160
+ continue
161
+ try:
162
+ with np.errstate(invalid="ignore", divide="ignore"):
163
+ statistic, _p, _dof, _expected = stats.chi2_contingency(table, lambda_="log-likelihood")
164
+ if np.isfinite(statistic):
165
+ total += float(statistic)
166
+ except ValueError:
167
+ continue
168
+ return total
169
+
170
+
171
+ def _table_significant(table: np.ndarray, *, alpha: float, test: Literal["g", "chi2"]) -> bool:
172
+ try:
173
+ if test == "g":
174
+ with np.errstate(invalid="ignore", divide="ignore"):
175
+ statistic, _p, _dof, expected = stats.chi2_contingency(table, lambda_="log-likelihood")
176
+ if not np.isfinite(statistic) or np.any(expected == 0):
177
+ return False
178
+ dof = max(1, table.shape[1] - 1)
179
+ return float(statistic) > float(stats.chi2.ppf(1.0 - alpha, dof))
180
+ statistic, p_value, _dof, expected = stats.chi2_contingency(table)
181
+ except ValueError:
182
+ return False
183
+ if np.any(expected == 0):
184
+ return False
185
+ return float(p_value) < alpha
186
+
187
+
188
+ def _homogenize(
189
+ counts: JointSuffixCounts,
190
+ *,
191
+ Lmax: int,
192
+ alpha: float,
193
+ test: Literal["g", "chi2"],
194
+ min_count: int,
195
+ ) -> tuple[dict[int, set[JointHistory]], dict[JointHistory, int]]:
196
+ in_alpha = counts.input_alphabet
197
+ out_alpha = counts.output_alphabet
198
+ states: dict[int, set[JointHistory]] = {0: {()}}
199
+ state_agg: dict[int, StateAggregate] = {0: _history_aggregate(counts, ())}
200
+ history_to_state: dict[JointHistory, int] = {(): 0}
201
+ next_state_id = 1
202
+
203
+ for _length in range(Lmax + 1):
204
+ for state_id in sorted(states):
205
+ for history in list(states[state_id]):
206
+ for pair in _observed_pairs(counts, history):
207
+ child = (*history, pair)
208
+ if child in history_to_state or counts.history_counts.get(child, 0) == 0:
209
+ continue
210
+ child_agg = _history_aggregate(counts, child)
211
+ if counts.history_counts.get(child, 0) < min_count:
212
+ # Too rare to split reliably; inherit the parent's causal state.
213
+ states[state_id].add(child)
214
+ history_to_state[child] = state_id
215
+ _merge_aggregate(state_agg[state_id], child_agg)
216
+ continue
217
+ if aggregates_differ(
218
+ state_agg[state_id],
219
+ child_agg,
220
+ input_alphabet=in_alpha,
221
+ output_alphabet=out_alpha,
222
+ alpha=alpha,
223
+ test=test,
224
+ ):
225
+ best_state: int | None = None
226
+ best_score = float("inf")
227
+ for candidate_id, candidate_agg in state_agg.items():
228
+ if aggregates_differ(
229
+ candidate_agg,
230
+ child_agg,
231
+ input_alphabet=in_alpha,
232
+ output_alphabet=out_alpha,
233
+ alpha=alpha,
234
+ test=test,
235
+ ):
236
+ continue
237
+ score = _aggregate_score(
238
+ candidate_agg,
239
+ child_agg,
240
+ input_alphabet=in_alpha,
241
+ output_alphabet=out_alpha,
242
+ )
243
+ if score < best_score:
244
+ best_score = score
245
+ best_state = candidate_id
246
+ if best_state is None:
247
+ best_state = next_state_id
248
+ states[next_state_id] = set()
249
+ state_agg[next_state_id] = {}
250
+ next_state_id += 1
251
+ states[best_state].add(child)
252
+ history_to_state[child] = best_state
253
+ _merge_aggregate(state_agg[best_state], child_agg)
254
+ else:
255
+ states[state_id].add(child)
256
+ history_to_state[child] = state_id
257
+ _merge_aggregate(state_agg[state_id], child_agg)
258
+ return states, history_to_state
259
+
260
+
261
+ def _observed_pairs(counts: JointSuffixCounts, history: JointHistory) -> list[tuple[Any, Any]]:
262
+ by_input = counts.next_counts.get(history)
263
+ if by_input is None:
264
+ return []
265
+ pairs: list[tuple[Any, Any]] = []
266
+ for input_symbol, counter in by_input.items():
267
+ for output_symbol in counter:
268
+ pairs.append((input_symbol, output_symbol))
269
+ return pairs
270
+
271
+
272
+ def _determinize(
273
+ states: dict[int, set[JointHistory]],
274
+ history_to_state: dict[JointHistory, int],
275
+ counts: JointSuffixCounts,
276
+ ) -> dict[int, set[JointHistory]]:
277
+ current = {state_id: set(histories) for state_id, histories in states.items()}
278
+ next_state_id = (max(current) + 1) if current else 0
279
+ changed = True
280
+ while changed:
281
+ changed = False
282
+ for state_id in sorted(current):
283
+ histories = current[state_id]
284
+ if len(histories) <= 1:
285
+ continue
286
+ for pair in _pairs_from(counts):
287
+ buckets: dict[int, set[JointHistory]] = defaultdict(set)
288
+ for history in histories:
289
+ if not _history_emits(counts, history, pair):
290
+ continue
291
+ child = (*history, pair)
292
+ target = history_to_state.get(child)
293
+ if target is None:
294
+ continue
295
+ buckets[target].add(history)
296
+ if len(buckets) <= 1:
297
+ continue
298
+ ordered = sorted(buckets.items(), key=lambda item: (-len(item[1]), repr(min(item[1], key=repr))))
299
+ _keep_target, keep_histories = ordered[0]
300
+ current[state_id] = keep_histories
301
+ for _target, split_histories in ordered[1:]:
302
+ current[next_state_id] = split_histories
303
+ for history in split_histories:
304
+ history_to_state[history] = next_state_id
305
+ next_state_id += 1
306
+ changed = True
307
+ break
308
+ if changed:
309
+ break
310
+ return current
311
+
312
+
313
+ def _pairs_from(counts: JointSuffixCounts) -> list[tuple[Any, Any]]:
314
+ return [(x, y) for x in counts.input_alphabet for y in counts.output_alphabet]
315
+
316
+
317
+ def _history_emits(counts: JointSuffixCounts, history: JointHistory, pair: tuple[Any, Any]) -> bool:
318
+ by_input = counts.next_counts.get(history)
319
+ if by_input is None:
320
+ return False
321
+ counter = by_input.get(pair[0])
322
+ return bool(counter) and counter.get(pair[1], 0) > 0
323
+
324
+
325
+ def _drop_transient(
326
+ states: dict[int, set[JointHistory]],
327
+ history_to_state: dict[JointHistory, int],
328
+ counts: JointSuffixCounts,
329
+ ) -> dict[int, set[JointHistory]]:
330
+ import networkx as nx
331
+
332
+ graph = nx.DiGraph()
333
+ graph.add_nodes_from(states)
334
+ for state_id, histories in states.items():
335
+ for history in histories:
336
+ for pair in _pairs_from(counts):
337
+ if not _history_emits(counts, history, pair):
338
+ continue
339
+ target = history_to_state.get((*history, pair))
340
+ if target is not None:
341
+ graph.add_edge(state_id, target)
342
+ if graph.number_of_edges() == 0:
343
+ return states
344
+
345
+ recurrent: set[int] = set()
346
+ for component in nx.strongly_connected_components(graph):
347
+ subgraph = graph.subgraph(component)
348
+ has_cycle = subgraph.number_of_edges() > 0 and (
349
+ len(component) > 1 or any(subgraph.has_edge(node, node) for node in component)
350
+ )
351
+ if not has_cycle:
352
+ continue
353
+ if not any(graph.has_edge(v, w) for v in component for w in graph.nodes if w not in component):
354
+ recurrent.update(component)
355
+ if not recurrent:
356
+ return states
357
+ return {state_id: histories for state_id, histories in states.items() if state_id in recurrent}
358
+
359
+
360
+ def _state_visits(
361
+ inputs: Sequence[Any],
362
+ outputs: Sequence[Any],
363
+ history_to_state: dict[JointHistory, int],
364
+ *,
365
+ length: int,
366
+ ) -> Counter[int]:
367
+ visits: Counter[int] = Counter()
368
+ pairs = tuple(zip(inputs, outputs, strict=True))
369
+ for t in range(len(pairs)):
370
+ for hist_len in range(min(t, length), -1, -1):
371
+ history = pairs[t - hist_len : t]
372
+ state = history_to_state.get(history)
373
+ if state is not None:
374
+ visits[state] += 1
375
+ break
376
+ return visits
377
+
378
+
379
+ def _build_transducer(
380
+ states: dict[int, set[JointHistory]],
381
+ counts: JointSuffixCounts,
382
+ history_to_state: dict[JointHistory, int],
383
+ inputs: Sequence[Any],
384
+ outputs: Sequence[Any],
385
+ *,
386
+ length: int,
387
+ ) -> EpsilonTransducer:
388
+ visits = _state_visits(inputs, outputs, history_to_state, length=length)
389
+ if not visits:
390
+ raise StochasticValidationError("no empirical causal-state visits")
391
+
392
+ graph = TransitionGraph()
393
+ labels = {state_id: f"s{state_id}" for state_id in states}
394
+ for label in labels.values():
395
+ graph.add_state(label)
396
+
397
+ used_inputs: set[Any] = set()
398
+ used_outputs: set[Any] = set()
399
+ for state_id, histories in states.items():
400
+ source = labels[state_id]
401
+ for input_symbol in counts.input_alphabet:
402
+ morph = counts.state_morph(histories, input_symbol)
403
+ if not morph:
404
+ continue
405
+ row: list[tuple[str, Any, float]] = []
406
+ for output_symbol, prob in morph.items():
407
+ if prob <= 0.0:
408
+ continue
409
+ emitting = [
410
+ history for history in histories if _history_emits(counts, history, (input_symbol, output_symbol))
411
+ ]
412
+ targets = {history_to_state.get((*history, (input_symbol, output_symbol))) for history in emitting}
413
+ targets.discard(None)
414
+ if len(targets) != 1:
415
+ continue
416
+ target_id = next(iter(targets))
417
+ if target_id not in labels:
418
+ continue
419
+ row.append((labels[target_id], output_symbol, prob))
420
+ total = sum(prob for _label, _out, prob in row)
421
+ if total <= 0.0:
422
+ continue
423
+ for target_label, output_symbol, prob in row:
424
+ graph.add_transition(
425
+ source,
426
+ target_label,
427
+ **{ATTR_SYMBOL: input_symbol, ATTR_OUTPUT: output_symbol, ATTR_PROB: prob / total},
428
+ )
429
+ used_inputs.add(input_symbol)
430
+ used_outputs.add(output_symbol)
431
+
432
+ total_visits = float(sum(visits.values()))
433
+ initial = {labels[state_id]: visits[state_id] / total_visits for state_id in states if visits.get(state_id, 0) > 0}
434
+ if not initial:
435
+ initial = {labels[next(iter(states))]: 1.0}
436
+
437
+ result = EpsilonTransducer(
438
+ input_alphabet=frozenset(used_inputs),
439
+ output_alphabet=frozenset(used_outputs),
440
+ initial_states=frozenset(initial),
441
+ initial_distribution=initial,
442
+ graph=graph,
443
+ )
444
+ result.validate()
445
+ return result
446
+
447
+
448
+ def _default_lmax(n: int, alphabet_size: int, min_count: int) -> int:
449
+ if alphabet_size <= 0:
450
+ return 1
451
+ # The joint (input, output) history space grows as ``alphabet_size ** L``, so
452
+ # keep the default depth modest relative to the single-process CSSR bound.
453
+ return max(1, min(5, n // max(1, alphabet_size * min_count)))
454
+
455
+
456
+ def transcssr(
457
+ inputs: Sequence[Any],
458
+ outputs: Sequence[Any],
459
+ *,
460
+ input_alphabet: Sequence[Any] | None = None,
461
+ output_alphabet: Sequence[Any] | None = None,
462
+ Lmax: int | None = None,
463
+ alpha: float = 0.001,
464
+ test: Literal["g", "chi2"] = "g",
465
+ min_count: int = 5,
466
+ ) -> EpsilonTransducer:
467
+ """Reconstruct an ε-transducer from paired input/output sequences (transCSSR).
468
+
469
+ ``alpha`` is the per-test significance level for the causal-state split
470
+ decision; the transCSSR/CSSR default of ``0.001`` favors fewer, more robust
471
+ states. ``Lmax`` bounds the joint-history depth and ``min_count`` the minimum
472
+ occurrences before a history is eligible to seed a new state.
473
+ """
474
+ xs = tuple(inputs)
475
+ ys = tuple(outputs)
476
+ if len(xs) != len(ys):
477
+ raise ValueError("inputs and outputs must have equal length")
478
+ if len(xs) < 2:
479
+ raise ValueError("sequences must contain at least two symbols")
480
+ joint_alphabet_size = (
481
+ len(set(xs)) * len(set(ys))
482
+ if input_alphabet is None or output_alphabet is None
483
+ else len(tuple(input_alphabet)) * len(tuple(output_alphabet))
484
+ )
485
+ max_length = Lmax if Lmax is not None else _default_lmax(len(xs), joint_alphabet_size, min_count)
486
+ counts = JointSuffixCounts.from_sequences(
487
+ xs,
488
+ ys,
489
+ input_alphabet=input_alphabet,
490
+ output_alphabet=output_alphabet,
491
+ max_length=max_length + 1,
492
+ )
493
+
494
+ states, history_to_state = _homogenize(counts, Lmax=max_length, alpha=alpha, test=test, min_count=min_count)
495
+ states = _determinize(states, history_to_state, counts)
496
+ history_to_state = {history: state_id for state_id, histories in states.items() for history in histories}
497
+ states = _drop_transient(states, history_to_state, counts)
498
+ history_to_state = {history: state_id for state_id, histories in states.items() for history in histories}
499
+ return _build_transducer(states, counts, history_to_state, xs, ys, length=max_length)