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,365 @@
1
+ """Finite-state transducers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import abstractmethod
6
+ from collections import defaultdict
7
+ from collections.abc import Hashable, Mapping, Sequence
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from sofic.base import StateMachine
13
+ from sofic.exceptions import StochasticValidationError
14
+ from sofic.graph import ATTR_EMISSION, ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, EPSILON
15
+
16
+ ERROR_SYMBOL = "?"
17
+ ERROR_STATE = "?"
18
+
19
+
20
+ class Transducer(StateMachine):
21
+ """Non-probabilistic input-to-output machine."""
22
+
23
+ input_alphabet: frozenset[Any]
24
+ output_alphabet: frozenset[Any]
25
+ initial_states: frozenset[Hashable]
26
+
27
+ def __init__(
28
+ self,
29
+ input_alphabet: frozenset[Any] | None = None,
30
+ output_alphabet: frozenset[Any] | None = None,
31
+ initial_states: frozenset[Hashable] | None = None,
32
+ **kwargs: Any,
33
+ ) -> None:
34
+ super().__init__(**kwargs)
35
+ self.input_alphabet = input_alphabet if input_alphabet is not None else frozenset()
36
+ self.output_alphabet = output_alphabet if output_alphabet is not None else frozenset()
37
+ self.initial_states = initial_states if initial_states is not None else frozenset()
38
+
39
+ def validate(self) -> None:
40
+ for state in self.initial_states:
41
+ self._require(self.graph.has_state(state), f"missing initial state {state!r}")
42
+
43
+ def is_deterministic(self) -> bool:
44
+ """Return whether each state has at most one transition per input symbol."""
45
+ from sofic.properties import is_deterministic_transducer
46
+
47
+ return is_deterministic_transducer(self)
48
+
49
+ def alphabets(
50
+ self,
51
+ *,
52
+ node: bool = False,
53
+ ) -> tuple[frozenset[Any], frozenset[Any]] | tuple[dict[Hashable, frozenset[Any]], dict[Hashable, frozenset[Any]]]:
54
+ """Return effective input and output alphabets.
55
+
56
+ ``EPSILON`` transitions are operationally meaningful but are not part
57
+ of the external alphabets returned here.
58
+ """
59
+ input_by_state: dict[Hashable, set[Any]] = {state: set() for state in self.states()}
60
+ output_by_state: dict[Hashable, set[Any]] = {state: set() for state in self.states()}
61
+ inputs: set[Any] = set(self.input_alphabet)
62
+ outputs: set[Any] = set(self.output_alphabet)
63
+ for transition in self.transitions():
64
+ symbol = transition.data.get(ATTR_SYMBOL)
65
+ if symbol is not None and symbol is not EPSILON:
66
+ inputs.add(symbol)
67
+ input_by_state.setdefault(transition.source, set()).add(symbol)
68
+ output = transition.data.get(ATTR_OUTPUT)
69
+ if output is not None and output is not EPSILON:
70
+ outputs.add(output)
71
+ output_by_state.setdefault(transition.source, set()).add(output)
72
+ if node:
73
+ return (
74
+ {state: frozenset(symbols) for state, symbols in input_by_state.items()},
75
+ {state: frozenset(symbols) for state, symbols in output_by_state.items()},
76
+ )
77
+ return frozenset(inputs), frozenset(outputs)
78
+
79
+ def is_complete(self, alphabet: frozenset[Any] | None = None) -> bool:
80
+ """Return whether every state has an outgoing edge for each input symbol."""
81
+ inputs = alphabet if alphabet is not None else self.alphabets()[0]
82
+ input_by_state, _ = self.alphabets(node=True)
83
+ return all(inputs <= symbols for symbols in input_by_state.values())
84
+
85
+ @abstractmethod
86
+ def transduce(self, word: Sequence[Any]) -> set[tuple[Any, ...]]:
87
+ """Return possible output sequences for ``word``."""
88
+
89
+
90
+ class MealyMachine(Transducer):
91
+ """Output symbols on transitions."""
92
+
93
+ def add_transition(
94
+ self,
95
+ source: Hashable,
96
+ target: Hashable,
97
+ symbol: Any,
98
+ output: Any | None = None,
99
+ *,
100
+ prob: float | None = None,
101
+ **attrs: Any,
102
+ ) -> int:
103
+ """Add a transition with input ``symbol`` and optional output.
104
+
105
+ Use :data:`sofic.graph.EPSILON` for empty input or empty output.
106
+ Omitting ``prob`` keeps the transition topological; probability-aware
107
+ helpers treat missing probabilities as weight ``1``.
108
+ """
109
+ data = {ATTR_SYMBOL: symbol, **attrs}
110
+ if output is not None:
111
+ data[ATTR_OUTPUT] = output
112
+ if prob is not None:
113
+ data[ATTR_PROB] = float(prob)
114
+ return self.graph.add_transition(source, target, **data)
115
+
116
+ def transduce(self, word: Sequence[Any]) -> set[tuple[Any, ...]]:
117
+ from sofic.automata.transducer_simulation import transduce_mealy
118
+
119
+ return transduce_mealy(self, word)
120
+
121
+ def complete(
122
+ self,
123
+ alphabet: frozenset[Any] | None = None,
124
+ *,
125
+ reject: Hashable = ERROR_STATE,
126
+ error_output: Any = ERROR_SYMBOL,
127
+ copy: bool = True,
128
+ ) -> MealyMachine:
129
+ """Return a complete transducer by adding reject/error transitions.
130
+
131
+ If every state already has an outgoing edge for every symbol in
132
+ ``alphabet``, the reject sink is not added — an unused absorbing error
133
+ component would make the driven joint process reducible with a
134
+ non-unique stationary law.
135
+ """
136
+ result = self.copy() if copy else self
137
+ symbols = alphabet if alphabet is not None else result.alphabets()[0]
138
+ if not symbols:
139
+ return result
140
+
141
+ gaps: list[tuple[Hashable, Any]] = []
142
+ for state in list(result.states()):
143
+ outgoing = {
144
+ transition.data.get(ATTR_SYMBOL)
145
+ for transition in result.graph.out_transitions(state)
146
+ if transition.data.get(ATTR_SYMBOL) is not EPSILON
147
+ }
148
+ for symbol in symbols - outgoing:
149
+ gaps.append((state, symbol))
150
+
151
+ result.input_alphabet = result.input_alphabet | frozenset(symbols)
152
+ if not gaps:
153
+ return result
154
+
155
+ result.graph.add_state(reject)
156
+ for state, symbol in gaps:
157
+ result.add_transition(state, reject, symbol, error_output, prob=1.0)
158
+
159
+ for symbol in symbols:
160
+ if not any(
161
+ transition.data.get(ATTR_SYMBOL) == symbol for transition in result.graph.out_transitions(reject)
162
+ ):
163
+ result.add_transition(reject, reject, symbol, error_output, prob=1.0)
164
+
165
+ result.output_alphabet = result.output_alphabet | frozenset({error_output})
166
+ return result
167
+
168
+ def input_machine(self, *, build: bool = False) -> Any:
169
+ """Return a Mealy HMM for the topological input language."""
170
+ return _partial_machine(self, ATTR_SYMBOL, build=build)
171
+
172
+ def output_machine(self, *, build: bool = False) -> Any:
173
+ """Return a Mealy HMM for the topological output language."""
174
+ return _partial_machine(self, ATTR_OUTPUT, build=build)
175
+
176
+ def compose(self, other: MealyMachine, **kwargs: Any) -> MealyMachine:
177
+ """Return the serial composition ``other`` after this transducer."""
178
+ from sofic.automata.transducer_operations import compose_tt
179
+
180
+ return compose_tt((self, other), **kwargs)
181
+
182
+ def joint_machine(self, generator: Any, **kwargs: Any) -> Any:
183
+ """Return the joint input/output generator induced by ``generator``."""
184
+ from sofic.automata.transducer_operations import compose_tg
185
+
186
+ return compose_tg(self, generator, joint=True, **kwargs)
187
+
188
+ def transduce_generator(self, generator: Any, **kwargs: Any) -> Any:
189
+ """Return the output generator induced by driving this transducer."""
190
+ from sofic.automata.transducer_operations import transduce_generator
191
+
192
+ return transduce_generator(self, generator, **kwargs)
193
+
194
+ def to_sofic_relation(self) -> Any:
195
+ """Return the topological support as a product-alphabet sofic relation."""
196
+ from sofic.shifts.sofic_relation import SoficRelation
197
+
198
+ return SoficRelation.from_transducer(self)
199
+
200
+ def to_textile_system(self) -> Any:
201
+ """Return this transducer as a textile system (Nasu 1995)."""
202
+ from sofic.shifts.textile import TextileSystem
203
+
204
+ return TextileSystem.from_transducer(self)
205
+
206
+ def to_wfst(self, *, semiring: str = "probability") -> Any:
207
+ """Return this transducer as a weighted finite-state transducer."""
208
+ from sofic.automata.subsequential import WeightedFiniteStateTransducer
209
+
210
+ return WeightedFiniteStateTransducer.from_transducer(self, semiring=semiring)
211
+
212
+ def to_sliding_block_code(self, *, max_window: int = 4) -> Any:
213
+ """Return the sliding block code realized by this transducer, if it is one.
214
+
215
+ Requires deterministic (right-resolving) outputs with a finite window;
216
+ raises :class:`ValueError` otherwise (e.g. for stochastic channels).
217
+ """
218
+ from sofic.shifts.textile import TextileSystem
219
+
220
+ return TextileSystem.from_transducer(self).induced_code(max_window=max_window)
221
+
222
+ def labeled_transition_matrices(
223
+ self,
224
+ node_ordering: Sequence[Hashable] | None = None,
225
+ *,
226
+ mode: str = "zero",
227
+ ) -> tuple[dict[tuple[Any, Any], np.ndarray], list[Hashable]] | dict[tuple[Any, Any], np.ndarray]:
228
+ """Return matrices keyed by ``(input, output)`` symbol pairs."""
229
+ norder = list(self.states()) if node_ordering is None else list(node_ordering)
230
+ missing = [state for state in norder if not self.graph.has_state(state)]
231
+ if missing:
232
+ raise ValueError(f"unknown states in node_ordering: {missing!r}")
233
+
234
+ if mode not in {"zero", "nan"}:
235
+ raise ValueError("mode must be 'zero' or 'nan'")
236
+
237
+ idx = {state: i for i, state in enumerate(norder)}
238
+ n = len(norder)
239
+ input_by_state, _ = self.alphabets(node=True)
240
+ fill = 0.0 if mode == "zero" else np.nan
241
+
242
+ def default_matrix(input_symbol: Any) -> np.ndarray:
243
+ matrix = np.zeros((n, n), dtype=float)
244
+ if mode == "nan":
245
+ for state in norder:
246
+ if input_symbol not in input_by_state.get(state, frozenset()):
247
+ matrix[idx[state]] = fill
248
+ return matrix
249
+
250
+ matrices: dict[tuple[Any, Any], np.ndarray] = {}
251
+ for transition in self.transitions():
252
+ if transition.source not in idx or transition.target not in idx:
253
+ continue
254
+ input_symbol = transition.data.get(ATTR_SYMBOL, EPSILON)
255
+ output_symbol = transition.data.get(ATTR_OUTPUT, EPSILON)
256
+ key = (input_symbol, output_symbol)
257
+ if key not in matrices:
258
+ matrices[key] = default_matrix(input_symbol)
259
+ matrices[key][idx[transition.source], idx[transition.target]] += _transition_probability(transition.data)
260
+
261
+ if node_ordering is None:
262
+ return matrices, norder
263
+ return matrices
264
+
265
+ def validate_stochastic(self) -> None:
266
+ """Validate probability rows grouped by ``(state, input symbol)``."""
267
+ rows: dict[tuple[Hashable, Any], float] = defaultdict(float)
268
+ for transition in self.transitions():
269
+ prob = _transition_probability(transition.data)
270
+ if prob < 0:
271
+ raise StochasticValidationError(f"negative transducer probability on {transition}")
272
+ rows[(transition.source, transition.data.get(ATTR_SYMBOL, EPSILON))] += prob
273
+ for (state, symbol), total in rows.items():
274
+ if not np.isclose(total, 1.0):
275
+ raise StochasticValidationError(f"transducer row ({state!r}, {symbol!r}) sums to {total}, not 1")
276
+
277
+ def validate(self) -> None:
278
+ super().validate()
279
+ for transition in self.transitions():
280
+ if ATTR_OUTPUT in transition.data:
281
+ out = transition.data[ATTR_OUTPUT]
282
+ if out is not EPSILON:
283
+ self._require(out in self.output_alphabet, f"output {out!r} not in output alphabet")
284
+ if ATTR_SYMBOL in transition.data:
285
+ sym = transition.data[ATTR_SYMBOL]
286
+ if sym is not EPSILON:
287
+ self._require(sym in self.input_alphabet, f"symbol {sym!r} not in input alphabet")
288
+
289
+
290
+ class MooreMachine(Transducer):
291
+ """Output symbols on states."""
292
+
293
+ def add_transition(
294
+ self,
295
+ source: Hashable,
296
+ target: Hashable,
297
+ symbol: Any,
298
+ *,
299
+ prob: float | None = None,
300
+ **attrs: Any,
301
+ ) -> int:
302
+ """Add a transition with input ``symbol``."""
303
+ data = {ATTR_SYMBOL: symbol, **attrs}
304
+ if prob is not None:
305
+ data[ATTR_PROB] = float(prob)
306
+ return self.graph.add_transition(source, target, **data)
307
+
308
+ def set_output(self, state: Hashable, output: Any) -> None:
309
+ """Set a state's Moore output."""
310
+ self.graph.nx.nodes[state][ATTR_OUTPUT] = output
311
+
312
+ def transduce(self, word: Sequence[Any]) -> set[tuple[Any, ...]]:
313
+ from sofic.automata.transducer_simulation import transduce_moore
314
+
315
+ return transduce_moore(self, word)
316
+
317
+ def validate(self) -> None:
318
+ super().validate()
319
+ for state in self.states():
320
+ out = self.graph.state_attrs(state).get(ATTR_OUTPUT)
321
+ if out is not None and out is not EPSILON:
322
+ self._require(out in self.output_alphabet, f"output {out!r} not in output alphabet")
323
+ for transition in self.transitions():
324
+ if ATTR_SYMBOL in transition.data:
325
+ sym = transition.data[ATTR_SYMBOL]
326
+ if sym is not EPSILON:
327
+ self._require(sym in self.input_alphabet, f"symbol {sym!r} not in input alphabet")
328
+
329
+
330
+ def _transition_probability(data: Mapping[str, Any]) -> float:
331
+ return float(data.get(ATTR_PROB, 1.0))
332
+
333
+
334
+ def _partial_machine(machine: MealyMachine, label_attr: str, *, build: bool) -> Any:
335
+ from sofic.generators.epsilon_machine import EpsilonMachine
336
+ from sofic.generators.mealy import MealyHMM
337
+
338
+ states = tuple(machine.states())
339
+ initial_states = tuple(machine.initial_states) or states
340
+ initial = {state: 1.0 / len(initial_states) for state in initial_states} if initial_states else {}
341
+ alphabet = frozenset(
342
+ transition.data[label_attr]
343
+ for transition in machine.transitions()
344
+ if label_attr in transition.data and transition.data[label_attr] is not EPSILON
345
+ )
346
+ result = MealyHMM(initial_distribution=initial, observation_alphabet=alphabet)
347
+ for state in states:
348
+ result.graph.add_state(state)
349
+
350
+ row_counts: dict[Hashable, int] = defaultdict(int)
351
+ for transition in machine.transitions():
352
+ symbol = transition.data.get(label_attr)
353
+ if symbol is None or symbol is EPSILON:
354
+ continue
355
+ row_counts[transition.source] += 1
356
+ for transition in machine.transitions():
357
+ symbol = transition.data.get(label_attr)
358
+ if symbol is None or symbol is EPSILON:
359
+ continue
360
+ prob = 1.0 / row_counts[transition.source] if row_counts[transition.source] else 0.0
361
+ result.graph.add_transition(transition.source, transition.target, **{ATTR_EMISSION: symbol, ATTR_PROB: prob})
362
+
363
+ if build:
364
+ return EpsilonMachine.from_hmm(result)
365
+ return result
@@ -0,0 +1,107 @@
1
+ """Unifilar labeled automata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Sequence
6
+ from typing import Any
7
+
8
+ from sofic.automata.base import LabeledAutomaton
9
+ from sofic.exceptions import UnifilarityError
10
+ from sofic.graph import ATTR_SYMBOL, EPSILON
11
+
12
+
13
+ class UnifilarAutomaton(LabeledAutomaton):
14
+ """At most one outgoing edge per (state, symbol), epsilon excluded."""
15
+
16
+ def is_unifilar(self) -> bool:
17
+ """Return whether this presentation is right-resolving (unifilar)."""
18
+ from sofic.properties import is_unifilar_symbols
19
+
20
+ return is_unifilar_symbols(self)
21
+
22
+ def validate(self) -> None:
23
+ super().validate()
24
+ if self.is_unifilar():
25
+ return
26
+ seen: set[tuple[Hashable, Any]] = set()
27
+ for transition in self.transitions():
28
+ symbol = transition.data.get(ATTR_SYMBOL)
29
+ if symbol is EPSILON or symbol is None:
30
+ continue
31
+ key = (transition.source, symbol)
32
+ if key in seen:
33
+ raise UnifilarityError(f"duplicate unifilar transition on {key}")
34
+ seen.add(key)
35
+
36
+ def recognizes(self, word: Sequence[Any]) -> bool:
37
+ return bool(self._run_nfa(word) & self.accepting_states)
38
+
39
+ def markov_order(self) -> int | float:
40
+ """Markov order ``R`` for this right-resolving (unifilar) presentation."""
41
+ from sofic.generators.synchronization import graph_from_unifilar_automaton, markov_order_from_graph
42
+
43
+ return markov_order_from_graph(graph_from_unifilar_automaton(self))
44
+
45
+ def cryptic_order(self) -> int | float:
46
+ """Cryptic order ``k_chi`` for this right-resolving presentation."""
47
+ from sofic.generators.synchronization import cryptic_order_from_graph, graph_from_unifilar_automaton
48
+
49
+ return cryptic_order_from_graph(graph_from_unifilar_automaton(self))
50
+
51
+ def reset_threshold(self) -> int | float:
52
+ """Reset threshold: shortest synchronizing (reset) word length.
53
+
54
+ The complement of :meth:`markov_order` (shortest vs longest start-to-
55
+ singleton path); ``math.inf`` when not exactly synchronizable. For the
56
+ Cerny bound (complete automata only) see Volkov (2008).
57
+ """
58
+ from sofic.generators.synchronization import graph_from_unifilar_automaton, reset_threshold_from_graph
59
+
60
+ return reset_threshold_from_graph(graph_from_unifilar_automaton(self))
61
+
62
+ def synchronizing_word(self) -> list[Any] | None:
63
+ """Return a shortest synchronizing word, or ``None`` if not exactly synchronizable.
64
+
65
+ The list of symbols has length :meth:`reset_threshold` (Travers &
66
+ Crutchfield, arXiv:1008.4182).
67
+ """
68
+ from sofic.generators.synchronization import (
69
+ graph_from_unifilar_automaton,
70
+ shortest_synchronizing_word_from_graph,
71
+ )
72
+
73
+ return shortest_synchronizing_word_from_graph(graph_from_unifilar_automaton(self))
74
+
75
+ def is_exactly_synchronizable(self) -> bool:
76
+ """Return whether this presentation is exactly synchronizable.
77
+
78
+ True iff a finite synchronizing word exists (the reset threshold is
79
+ finite; Travers & Crutchfield, arXiv:1008.4182). Strictly weaker than
80
+ finite Markov order: see :meth:`is_definite`.
81
+ """
82
+ from sofic.generators.synchronization import graph_from_unifilar_automaton, is_exactly_synchronizable
83
+
84
+ return is_exactly_synchronizable(graph_from_unifilar_automaton(self))
85
+
86
+ def is_asymptotically_synchronizable(self) -> bool:
87
+ """Return whether this presentation is asymptotically synchronizable.
88
+
89
+ True for every finite-state ε-machine (Travers & Crutchfield,
90
+ arXiv:1008.4182); theorem-backed rather than computed.
91
+ """
92
+ from sofic.generators.synchronization import (
93
+ graph_from_unifilar_automaton,
94
+ is_asymptotically_synchronizable_from_graph,
95
+ )
96
+
97
+ return is_asymptotically_synchronizable_from_graph(graph_from_unifilar_automaton(self))
98
+
99
+ def is_definite(self) -> bool:
100
+ """Return whether this presentation is a definite automaton (finite Markov order).
101
+
102
+ Definiteness (Perles-Rabin-Shamir) means the state is fixed by the last
103
+ ``R`` symbols. Implies :meth:`is_exactly_synchronizable`.
104
+ """
105
+ from sofic.generators.synchronization import graph_from_unifilar_automaton, is_definite_from_graph
106
+
107
+ return is_definite_from_graph(graph_from_unifilar_automaton(self))