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,407 @@
1
+ """Topological synchronization orders for unifilar generators.
2
+
3
+ Markov order and cryptic order are computed via the power-automaton algorithms
4
+ of James, Mahoney, Ellison & Crutchfield (arXiv:1010.5545). Both depend only
5
+ on unifilar graph topology, not transition probabilities.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Hashable, Mapping
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ import networkx as nx
16
+
17
+ from sofic.exceptions import UnifilarityError
18
+ from sofic.graph import ATTR_EMISSION, ATTR_SYMBOL
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class TopologicalUnifilarGraph:
23
+ """Deterministic edge-labeled transition system (right-resolving)."""
24
+
25
+ states: frozenset[Hashable]
26
+ alphabet: frozenset[Any]
27
+ transitions: Mapping[tuple[Hashable, Any], Hashable]
28
+
29
+ def delta(self, state: Hashable, symbol: Any) -> Hashable | None:
30
+ return self.transitions.get((state, symbol))
31
+
32
+ def delta_set(self, states: frozenset[Hashable], symbol: Any) -> frozenset[Hashable]:
33
+ successors: set[Hashable] = set()
34
+ for state in states:
35
+ target = self.delta(state, symbol)
36
+ if target is not None:
37
+ successors.add(target)
38
+ return frozenset(successors)
39
+
40
+ def is_recurrent_pa_state(self, pa_state: frozenset[Hashable]) -> bool:
41
+ return len(pa_state) == 1 and next(iter(pa_state)) in self.states
42
+
43
+
44
+ @dataclass
45
+ class PowerAutomaton:
46
+ """Subset automaton over a :class:`TopologicalUnifilarGraph`."""
47
+
48
+ graph: TopologicalUnifilarGraph
49
+ start: frozenset[Hashable]
50
+ transitions: dict[frozenset[Hashable], dict[Any, frozenset[Hashable]]] = field(default_factory=dict)
51
+
52
+ def is_transient(self, pa_state: frozenset[Hashable]) -> bool:
53
+ return not self.graph.is_recurrent_pa_state(pa_state)
54
+
55
+
56
+ def build_topological_graph_from_transitions(
57
+ states: frozenset[Hashable],
58
+ alphabet: frozenset[Any],
59
+ transitions: Mapping[tuple[Hashable, Any], Hashable],
60
+ ) -> TopologicalUnifilarGraph:
61
+ return TopologicalUnifilarGraph(states=states, alphabet=alphabet, transitions=dict(transitions))
62
+
63
+
64
+ def power_automaton(graph: TopologicalUnifilarGraph) -> PowerAutomaton:
65
+ """Build the power automaton via subset construction from the full state set."""
66
+ start = frozenset(graph.states)
67
+ pa = PowerAutomaton(graph=graph, start=start)
68
+ queue = [start]
69
+ seen = {start}
70
+ while queue:
71
+ current = queue.pop(0)
72
+ pa.transitions.setdefault(current, {})
73
+ for symbol in graph.alphabet:
74
+ successor = graph.delta_set(current, symbol)
75
+ if not successor:
76
+ continue
77
+ pa.transitions[current][symbol] = successor
78
+ if successor not in seen:
79
+ seen.add(successor)
80
+ queue.append(successor)
81
+ return pa
82
+
83
+
84
+ def _power_automaton_digraph(pa: PowerAutomaton) -> nx.MultiDiGraph:
85
+ """Materialize the power automaton as a networkx graph.
86
+
87
+ Nodes are power-automaton states (frozensets of graph states). Each edge
88
+ carries the emitted ``symbol`` that drives the subset transition and a
89
+ ``weight`` of ``-1`` when the source is still unsynchronized (a non-
90
+ singleton subset) and ``0`` once synchronized (a singleton). The negative
91
+ transient weight turns the *longest* synchronizing path (Markov order) into
92
+ a shortest-path problem solvable by Bellman-Ford. A
93
+ :class:`networkx.MultiDiGraph` is used because two subsets can be joined by
94
+ parallel edges labeled with different symbols.
95
+ """
96
+ digraph = nx.MultiDiGraph()
97
+ digraph.add_node(pa.start)
98
+ for source, out_map in pa.transitions.items():
99
+ weight = -1 if pa.is_transient(source) else 0
100
+ for symbol, target in out_map.items():
101
+ digraph.add_edge(source, target, symbol=symbol, weight=weight)
102
+ return digraph
103
+
104
+
105
+ def _states_reaching_synchronization(
106
+ digraph: nx.MultiDiGraph, singletons: set[frozenset[Hashable]]
107
+ ) -> set[frozenset[Hashable]]:
108
+ """Return the singletons together with every node that can reach one.
109
+
110
+ Computed as the ancestors of a virtual sink wired to every singleton, so a
111
+ single reverse traversal covers all synchronized targets at once.
112
+ """
113
+ if not singletons:
114
+ return set()
115
+ reverse = digraph.reverse(copy=True)
116
+ sink = object()
117
+ for node in singletons:
118
+ reverse.add_edge(sink, node)
119
+ return nx.descendants(reverse, sink)
120
+
121
+
122
+ def _bellman_ford_longest_transient_path(pa: PowerAutomaton) -> float:
123
+ """Return longest prefix-free synchronizing word length, or ``math.inf``.
124
+
125
+ Restricts the power automaton to the nodes that can still reach a singleton
126
+ (so a transient loop that never synchronizes is not mistaken for one that
127
+ does), then solves the negative-weight shortest-path problem with networkx's
128
+ Bellman-Ford. A negative cycle reachable from the start within that
129
+ subgraph is an unbounded transient loop on a synchronizing path, i.e. an
130
+ infinite Markov order, which networkx signals via ``NetworkXUnbounded``.
131
+ """
132
+ digraph = _power_automaton_digraph(pa)
133
+ singletons = {node for node in digraph if pa.graph.is_recurrent_pa_state(node)}
134
+ sync_reachable = _states_reaching_synchronization(digraph, singletons)
135
+ if pa.start not in sync_reachable:
136
+ return math.inf
137
+
138
+ subgraph = digraph.subgraph(sync_reachable)
139
+ try:
140
+ dist = nx.single_source_bellman_ford_path_length(subgraph, pa.start, weight="weight")
141
+ except nx.NetworkXUnbounded:
142
+ return math.inf
143
+
144
+ best = min((dist[node] for node in singletons if node in dist), default=math.inf)
145
+ if best == math.inf:
146
+ return math.inf
147
+ return -best
148
+
149
+
150
+ def markov_order_from_graph(graph: TopologicalUnifilarGraph) -> int | float:
151
+ """Longest prefix-free synchronizing word length (Markov order ``R``)."""
152
+ if not graph.states:
153
+ return 0
154
+ pa = power_automaton(graph)
155
+ order = _bellman_ford_longest_transient_path(pa)
156
+ if order == math.inf:
157
+ return math.inf
158
+ return int(order)
159
+
160
+
161
+ def reset_threshold_from_graph(graph: TopologicalUnifilarGraph) -> int | float:
162
+ """Reset threshold: length of the *shortest* synchronizing word.
163
+
164
+ This is the shortest path from the full-support start of the power
165
+ automaton to any singleton (synchronized) state -- the complement of the
166
+ Markov order, which is the *longest* such prefix-free path. Returns ``0``
167
+ for a single-state machine and ``math.inf`` when no finite synchronizing
168
+ word exists (i.e. the presentation is not exactly synchronizable).
169
+
170
+ The shortest-reset-word length is the quantity bounded by the Cerny
171
+ conjecture for complete deterministic automata :cite:`Cerny1964`
172
+ :cite:`Volkov2008`; note that right-resolving epsilon-machine presentations
173
+ are generally *partial*, so that quadratic bound does not apply here. Like
174
+ the Markov order, it is a topological property computed from the power
175
+ automaton :cite:`James2010`.
176
+ """
177
+ if not graph.states:
178
+ return 0
179
+ pa = power_automaton(graph)
180
+ lengths = nx.single_source_shortest_path_length(_power_automaton_digraph(pa), pa.start)
181
+ singleton_lengths = [length for node, length in lengths.items() if pa.graph.is_recurrent_pa_state(node)]
182
+ return min(singleton_lengths, default=math.inf)
183
+
184
+
185
+ def shortest_synchronizing_word_from_graph(
186
+ graph: TopologicalUnifilarGraph,
187
+ ) -> list[Any] | None:
188
+ """Return a shortest synchronizing word, or ``None`` if none exists.
189
+
190
+ A synchronizing (reset) word drives the observer's belief to a single
191
+ state regardless of the start state :cite:`Travers2010`. The returned list
192
+ of emitted symbols has length :func:`reset_threshold_from_graph`; it is
193
+ empty for an already-synchronized single-state machine and ``None`` when
194
+ the presentation is not exactly synchronizable.
195
+ """
196
+ if not graph.states:
197
+ return []
198
+ pa = power_automaton(graph)
199
+ digraph = _power_automaton_digraph(pa)
200
+ node_paths = nx.single_source_shortest_path(digraph, pa.start)
201
+ best_path: list[frozenset[Hashable]] | None = None
202
+ for node, node_path in node_paths.items():
203
+ if pa.graph.is_recurrent_pa_state(node) and (best_path is None or len(node_path) < len(best_path)):
204
+ best_path = node_path
205
+ if best_path is None:
206
+ return None
207
+ ordered_symbols = sorted(graph.alphabet, key=repr)
208
+ word: list[Any] = []
209
+ for source, target in zip(best_path, best_path[1:], strict=False):
210
+ symbol = next(sym for sym in ordered_symbols if graph.delta_set(source, sym) == target)
211
+ word.append(symbol)
212
+ return word
213
+
214
+
215
+ def _predecessors_on_symbol(graph: TopologicalUnifilarGraph, target: Hashable, symbol: Any) -> frozenset[Hashable]:
216
+ return frozenset(state for state in graph.states if graph.delta(state, symbol) == target)
217
+
218
+
219
+ def _ensure_pa_state(
220
+ transitions: dict[frozenset[Hashable], dict[Any, frozenset[Hashable]]],
221
+ states: set[frozenset[Hashable]],
222
+ graph: TopologicalUnifilarGraph,
223
+ pa_state: frozenset[Hashable],
224
+ ) -> None:
225
+ """Ensure ``pa_state`` and its subset successors are present in the PA."""
226
+ queue = [pa_state]
227
+ while queue:
228
+ current = queue.pop(0)
229
+ if current in transitions:
230
+ continue
231
+ out_map: dict[Any, frozenset[Hashable]] = {}
232
+ for symbol in graph.alphabet:
233
+ successor = graph.delta_set(current, symbol)
234
+ if successor:
235
+ out_map[symbol] = successor
236
+ if successor not in transitions:
237
+ queue.append(successor)
238
+ transitions[current] = out_map
239
+ states.add(current)
240
+
241
+
242
+ def _refine_cryptic_pa(pa: PowerAutomaton) -> PowerAutomaton:
243
+ """Apply veracity refinement (James et al., Sec. VI.1) until quiescent."""
244
+ graph = pa.graph
245
+ transitions = {state: dict(out_map) for state, out_map in pa.transitions.items()}
246
+ states = set(transitions)
247
+
248
+ changed = True
249
+ while changed:
250
+ changed = False
251
+ for source in list(states):
252
+ out_map = transitions.get(source, {})
253
+ for symbol, target in list(out_map.items()):
254
+ if not graph.is_recurrent_pa_state(target):
255
+ continue
256
+ target_state = next(iter(target))
257
+ true_sources = _predecessors_on_symbol(graph, target_state, symbol) & source
258
+ if not true_sources:
259
+ del out_map[symbol]
260
+ changed = True
261
+ continue
262
+ if true_sources == source:
263
+ continue
264
+ _ensure_pa_state(transitions, states, graph, true_sources)
265
+ refined_out = transitions.setdefault(true_sources, {})
266
+ if refined_out.get(symbol) != target:
267
+ refined_out[symbol] = target
268
+ changed = True
269
+ if source != pa.start and symbol in out_map:
270
+ del out_map[symbol]
271
+ changed = True
272
+ if not out_map:
273
+ transitions.pop(source, None)
274
+ states.discard(source)
275
+
276
+ return PowerAutomaton(graph=graph, start=pa.start, transitions=transitions)
277
+
278
+
279
+ def _cryptic_order_from_refined_pa(pa: PowerAutomaton) -> int | float:
280
+ refined = _refine_cryptic_pa(pa)
281
+ order = _bellman_ford_longest_transient_path(refined)
282
+ if order == math.inf:
283
+ return math.inf
284
+ return int(order)
285
+
286
+
287
+ def cryptic_order_from_graph(graph: TopologicalUnifilarGraph) -> int | float:
288
+ """Cryptic order ``k_chi`` via refined power automaton (James et al., Sec. VI)."""
289
+ if not graph.states:
290
+ return 0
291
+ pa = power_automaton(graph)
292
+ markov_order = _bellman_ford_longest_transient_path(pa)
293
+ order = _cryptic_order_from_refined_pa(pa)
294
+ if markov_order != math.inf and (order == math.inf or order > markov_order):
295
+ return int(markov_order)
296
+ if order == math.inf:
297
+ return math.inf
298
+ return int(order)
299
+
300
+
301
+ def is_exactly_synchronizable(graph: TopologicalUnifilarGraph) -> bool:
302
+ """Return whether the presentation is exactly synchronizable.
303
+
304
+ An epsilon-machine is *exact* iff it has some finite synchronizing word,
305
+ equivalently iff ``Pr(SYN(M)) = 1`` -- the observer synchronizes to the
306
+ hidden state in finite time for almost every generated sequence
307
+ :cite:`Travers2010`. This holds iff the reset threshold is finite. Note
308
+ this is strictly weaker than finite Markov order (see
309
+ :func:`is_definite_from_graph`): the butterfly process is exact yet has
310
+ infinite Markov order.
311
+ """
312
+ return reset_threshold_from_graph(graph) != math.inf
313
+
314
+
315
+ def is_definite_from_graph(graph: TopologicalUnifilarGraph) -> bool:
316
+ """Return whether the presentation is a definite automaton (finite Markov order).
317
+
318
+ A deterministic automaton is *definite* of degree ``R`` when the current
319
+ state is fixed by the last ``R`` symbols regardless of the start state
320
+ :cite:`Cerny1964`; this coincides with finite Markov order ``R`` for a
321
+ right-resolving epsilon-machine :cite:`James2010`. Definiteness implies
322
+ exact synchronizability, but not conversely.
323
+ """
324
+ return markov_order_from_graph(graph) != math.inf
325
+
326
+
327
+ def is_asymptotically_synchronizable_from_graph(graph: TopologicalUnifilarGraph) -> bool:
328
+ """Return whether the presentation is asymptotically synchronizable.
329
+
330
+ An epsilon-machine is asymptotically synchronizable iff the observer's
331
+ state uncertainty vanishes as ``L -> infinity`` for almost every generated
332
+ sequence (``Pr(WSYN(M)) = 1``) :cite:`Travers2010`. Every finite-state
333
+ epsilon-machine has this property, so this returns ``True`` for any
334
+ non-empty presentation -- it is theorem-backed rather than computed.
335
+ """
336
+ return bool(graph.states)
337
+
338
+
339
+ def graph_from_epsilon_machine(eps: Any) -> TopologicalUnifilarGraph:
340
+ """Strip probabilities from an ε-machine into a topological graph."""
341
+ from sofic.properties import is_unifilar_emissions
342
+
343
+ if not is_unifilar_emissions(eps):
344
+ for transition in eps.transitions():
345
+ emission = transition.data.get(ATTR_EMISSION)
346
+ if emission is None:
347
+ continue
348
+ key = (transition.source, emission)
349
+ for other in eps.graph.out_transitions(transition.source):
350
+ other_emission = other.data.get(ATTR_EMISSION)
351
+ if other_emission == emission and other.target != transition.target:
352
+ raise UnifilarityError(f"non-unifilar duplicate emission {emission!r} from {transition.source!r}")
353
+
354
+ transitions: dict[tuple[Hashable, Any], Hashable] = {}
355
+ alphabet: set[Any] = set()
356
+ for transition in eps.transitions():
357
+ emission = transition.data.get(ATTR_EMISSION)
358
+ if emission is None:
359
+ continue
360
+ key = (transition.source, emission)
361
+ if key in transitions and transitions[key] != transition.target:
362
+ raise UnifilarityError(f"non-unifilar duplicate emission {emission!r} from {transition.source!r}")
363
+ transitions[key] = transition.target
364
+ alphabet.add(emission)
365
+ states = frozenset(eps.states())
366
+ if eps.observation_alphabet:
367
+ alphabet.update(eps.observation_alphabet)
368
+ return TopologicalUnifilarGraph(
369
+ states=states,
370
+ alphabet=frozenset(alphabet),
371
+ transitions=transitions,
372
+ )
373
+
374
+
375
+ def graph_from_unifilar_automaton(aut: Any) -> TopologicalUnifilarGraph:
376
+ """Build a topological graph from a :class:`~sofic.automata.unifilar.UnifilarAutomaton`."""
377
+ from sofic.graph import EPSILON
378
+
379
+ transitions: dict[tuple[Hashable, Any], Hashable] = {}
380
+ alphabet: set[Any] = set()
381
+ for transition in aut.transitions():
382
+ symbol = transition.data.get(ATTR_SYMBOL)
383
+ if symbol is None or symbol is EPSILON:
384
+ continue
385
+ key = (transition.source, symbol)
386
+ if key in transitions and transitions[key] != transition.target:
387
+ raise UnifilarityError(f"non-unifilar duplicate symbol {symbol!r} from {transition.source!r}")
388
+ transitions[key] = transition.target
389
+ alphabet.add(symbol)
390
+ states = frozenset(aut.states())
391
+ if aut.input_alphabet:
392
+ alphabet.update(aut.input_alphabet)
393
+ return TopologicalUnifilarGraph(
394
+ states=states,
395
+ alphabet=frozenset(alphabet),
396
+ transitions=transitions,
397
+ )
398
+
399
+
400
+ def graph_from_sofic_shift(shift: Any) -> TopologicalUnifilarGraph:
401
+ """Build a topological graph from a right-resolving sofic presentation."""
402
+ graph = graph_from_unifilar_automaton(shift)
403
+ if shift.symbol_alphabet:
404
+ missing = graph.alphabet - shift.symbol_alphabet
405
+ if missing:
406
+ raise UnifilarityError(f"shift alphabet does not cover transition symbols: {missing}")
407
+ return graph