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
sofic/properties.py ADDED
@@ -0,0 +1,339 @@
1
+ """Structural predicates for sofic state-machine models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Iterable
6
+ from typing import Any
7
+
8
+ import networkx as nx
9
+ import numpy as np
10
+
11
+ from sofic.base import StateMachine
12
+ from sofic.graph import ATTR_EMISSION, ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, EPSILON
13
+
14
+
15
+ def is_unifilar_labeled(
16
+ model: StateMachine,
17
+ *,
18
+ label_attr: str,
19
+ exclude_labels: Iterable[Any] = (),
20
+ ) -> bool:
21
+ """Return whether each state has at most one outgoing edge per label value."""
22
+ excluded = set(exclude_labels)
23
+ seen: set[tuple[Hashable, Any]] = set()
24
+ for transition in model.transitions():
25
+ label = transition.data.get(label_attr)
26
+ if label is None or label in excluded:
27
+ continue
28
+ key = (transition.source, label)
29
+ if key in seen:
30
+ return False
31
+ seen.add(key)
32
+ return True
33
+
34
+
35
+ def is_unifilar_symbols(model: StateMachine) -> bool:
36
+ """Right-resolving on input symbols (ε excluded)."""
37
+ return is_unifilar_labeled(model, label_attr=ATTR_SYMBOL, exclude_labels=(EPSILON,))
38
+
39
+
40
+ def is_unifilar_emissions(model: StateMachine) -> bool:
41
+ """Row-unifilar on edge emissions (CM generator sense)."""
42
+ return is_unifilar_labeled(model, label_attr=ATTR_EMISSION)
43
+
44
+
45
+ def is_counifilar_emissions(model: StateMachine) -> bool:
46
+ """Column-unifilar on edge emissions: ``(target, emission)`` identifies the source."""
47
+ seen: dict[tuple[Hashable, Any], Hashable] = {}
48
+ for transition in model.transitions():
49
+ emission = transition.data.get(ATTR_EMISSION)
50
+ if emission is None:
51
+ continue
52
+ key = (transition.target, emission)
53
+ source = seen.get(key)
54
+ if source is not None and source != transition.source:
55
+ return False
56
+ seen[key] = transition.source
57
+ return True
58
+
59
+
60
+ def is_deterministic_automaton(aut: Any) -> bool:
61
+ """DFA-style determinism: one initial, no ε, unifilar on symbols."""
62
+ if len(aut.initial_states) != 1:
63
+ return False
64
+ for transition in aut.transitions():
65
+ if transition.data.get(ATTR_SYMBOL) is EPSILON:
66
+ return False
67
+ return is_unifilar_symbols(aut)
68
+
69
+
70
+ def is_deterministic_markov(chain: StateMachine) -> bool:
71
+ """Each state has exactly one successor with probability 1."""
72
+ for state in chain.states():
73
+ outgoing = list(chain.graph.out_transitions(state))
74
+ if len(outgoing) != 1:
75
+ return False
76
+ prob = float(outgoing[0].data.get(ATTR_PROB, 0.0))
77
+ if not np.isclose(prob, 1.0):
78
+ return False
79
+ return True
80
+
81
+
82
+ def is_deterministic_transducer(tr: StateMachine) -> bool:
83
+ """At most one transition per (state, input symbol)."""
84
+ return is_unifilar_symbols(tr)
85
+
86
+
87
+ def is_unifilar_transducer(tr: StateMachine) -> bool:
88
+ """Causal (joint) unifilarity: each ``(state, input, output)`` has at most one successor.
89
+
90
+ This is the transducer analog of :func:`is_unifilar_emissions`: appending an
91
+ observed input-output pair ``(x, y)`` determines the next causal state, as in
92
+ the ε-transducer of Barnett & Crutchfield (2015).
93
+ """
94
+ seen: set[tuple[Hashable, Any, Any]] = set()
95
+ for transition in tr.transitions():
96
+ symbol = transition.data.get(ATTR_SYMBOL)
97
+ if symbol is EPSILON:
98
+ continue
99
+ key = (transition.source, symbol, transition.data.get(ATTR_OUTPUT))
100
+ if key in seen:
101
+ return False
102
+ seen.add(key)
103
+ return True
104
+
105
+
106
+ def is_counifilar_transducer(tr: StateMachine) -> bool:
107
+ """Co-unifilar: each ``(target, input, output)`` identifies a unique source."""
108
+ seen: dict[tuple[Hashable, Any, Any], Hashable] = {}
109
+ for transition in tr.transitions():
110
+ symbol = transition.data.get(ATTR_SYMBOL)
111
+ if symbol is EPSILON:
112
+ continue
113
+ key = (transition.target, symbol, transition.data.get(ATTR_OUTPUT))
114
+ source = seen.get(key)
115
+ if source is not None and source != transition.source:
116
+ return False
117
+ seen[key] = transition.source
118
+ return True
119
+
120
+
121
+ def is_sequential_transducer(tr: Any) -> bool:
122
+ """Input-deterministic (sequential) transducer: single initial state, no ε
123
+ input, and at most one outgoing edge per ``(state, input)``."""
124
+ initial = getattr(tr, "initial_states", frozenset())
125
+ if len(initial) > 1:
126
+ return False
127
+ seen: set[tuple[Hashable, Any]] = set()
128
+ for transition in tr.transitions():
129
+ symbol = transition.data.get(ATTR_SYMBOL)
130
+ if symbol is EPSILON:
131
+ return False
132
+ key = (transition.source, symbol)
133
+ if key in seen:
134
+ return False
135
+ seen.add(key)
136
+ return True
137
+
138
+
139
+ def is_subsequential_transducer(tr: Any) -> bool:
140
+ """Sequential transducer equipped with a per-state final-output function."""
141
+ return is_sequential_transducer(tr) and getattr(tr, "final_output", None) is not None
142
+
143
+
144
+ def is_irreducible(model: StateMachine) -> bool:
145
+ """Return whether the directed state graph is strongly connected."""
146
+ graph = _simple_digraph(model)
147
+ if graph.number_of_nodes() <= 1:
148
+ return True
149
+ return bool(nx.is_strongly_connected(graph))
150
+
151
+
152
+ def recurrent_components(model: StateMachine) -> list[frozenset[Hashable]]:
153
+ """Terminal strongly connected components of the directed state graph."""
154
+ graph = _simple_digraph(model)
155
+ components: list[frozenset[Hashable]] = []
156
+ for component in nx.strongly_connected_components(graph):
157
+ exits = any(target not in component for source in component for target in graph.successors(source))
158
+ if not exits:
159
+ components.append(frozenset(component))
160
+ return components
161
+
162
+
163
+ def recurrent_states(model: StateMachine) -> frozenset[Hashable]:
164
+ """States in terminal strongly connected components."""
165
+ states: set[Hashable] = set()
166
+ for component in recurrent_components(model):
167
+ states.update(component)
168
+ return frozenset(states)
169
+
170
+
171
+ def is_ergodic(model: StateMachine, *, weak: bool = True) -> bool:
172
+ """Return weak/strong ergodicity of the internal finite-state dynamics.
173
+
174
+ Weak ergodicity means all positive-mass initial states can reach exactly
175
+ one terminal SCC. Strong ergodicity additionally requires that terminal
176
+ component to be aperiodic.
177
+ """
178
+ graph = _simple_digraph(model)
179
+ components = recurrent_components(model)
180
+ if not components:
181
+ return False
182
+
183
+ initial = getattr(model, "initial_distribution", {})
184
+ starts = {state for state, mass in initial.items() if mass > 0.0} if initial else set(model.states())
185
+
186
+ reachable_terminal: list[frozenset[Hashable]] = []
187
+ for component in components:
188
+ if any(
189
+ source in graph and target in nx.descendants(graph, source) | {source}
190
+ for source in starts
191
+ for target in component
192
+ ):
193
+ reachable_terminal.append(component)
194
+
195
+ if len(reachable_terminal) != 1:
196
+ return False
197
+ if weak:
198
+ return True
199
+
200
+ subgraph = graph.subgraph(reachable_terminal[0]).copy()
201
+ return bool(nx.is_aperiodic(subgraph))
202
+
203
+
204
+ def is_stationary(model: StateMachine, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
205
+ """Return whether ``initial_distribution`` is invariant under internal dynamics."""
206
+ initial_distribution = getattr(model, "initial_distribution", None)
207
+ if initial_distribution is None:
208
+ return False
209
+ vector, transition = _initial_vector_and_transition(model)
210
+ return bool(np.allclose(vector @ transition, vector, rtol=rtol, atol=atol))
211
+
212
+
213
+ def is_detailed_balance(model: StateMachine, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
214
+ """Return whether stationary labeled flows satisfy detailed balance."""
215
+ try:
216
+ pi = np.asarray(model.stationary_distribution(), dtype=float)
217
+ except Exception:
218
+ pi, _transition = _initial_vector_and_transition(model)
219
+
220
+ matrices = _labeled_or_internal_matrices(model)
221
+ for matrix in matrices:
222
+ flow = pi[:, None] * matrix
223
+ if not np.allclose(flow, flow.T, rtol=rtol, atol=atol):
224
+ return False
225
+ return True
226
+
227
+
228
+ def is_periodic(model: StateMachine) -> bool:
229
+ """Return whether every terminal component has graph period greater than one."""
230
+ graph = _simple_digraph(model)
231
+ components = recurrent_components(model)
232
+ if not components:
233
+ return False
234
+ periodic_components = 0
235
+ for component in components:
236
+ subgraph = graph.subgraph(component).copy()
237
+ if subgraph.number_of_nodes() == 0:
238
+ continue
239
+ if nx.is_aperiodic(subgraph):
240
+ return False
241
+ periodic_components += 1
242
+ return periodic_components > 0
243
+
244
+
245
+ def is_strictly_sofic(model: Any) -> bool:
246
+ """Return whether the HMM support appears strictly sofic.
247
+
248
+ For finite right-resolving support presentations, finite Markov order is
249
+ the finite-type case; infinite Markov order is strictly sofic.
250
+ """
251
+ from sofic.generators.conversions import hmm_to_support_dfa
252
+ from sofic.generators.synchronization import (
253
+ build_topological_graph_from_transitions,
254
+ graph_from_unifilar_automaton,
255
+ markov_order_from_graph,
256
+ )
257
+
258
+ support = model.to_sofic_shift()
259
+ if support.is_unifilar():
260
+ transitions: dict[tuple[Hashable, Any], Hashable] = {}
261
+ alphabet = set(support.symbol_alphabet)
262
+ for transition in support.transitions():
263
+ symbol = transition.data.get(ATTR_SYMBOL)
264
+ if symbol is None:
265
+ continue
266
+ transitions[(transition.source, symbol)] = transition.target
267
+ alphabet.add(symbol)
268
+ graph = build_topological_graph_from_transitions(
269
+ states=frozenset(support.states()),
270
+ alphabet=frozenset(alphabet),
271
+ transitions=transitions,
272
+ )
273
+ else:
274
+ graph = graph_from_unifilar_automaton(hmm_to_support_dfa(model))
275
+ return markov_order_from_graph(graph) == float("inf")
276
+
277
+
278
+ def _simple_digraph(model: StateMachine) -> nx.DiGraph:
279
+ graph = nx.DiGraph()
280
+ graph.add_nodes_from(model.states())
281
+ graph.add_edges_from((transition.source, transition.target) for transition in model.transitions())
282
+ return graph
283
+
284
+
285
+ def transition_matrix(
286
+ model: StateMachine,
287
+ *,
288
+ attr: str = ATTR_PROB,
289
+ states: Iterable[Hashable] | None = None,
290
+ ) -> tuple[np.ndarray, list[Hashable]]:
291
+ """Return the dense state-to-state matrix accumulating edge ``attr`` weights.
292
+
293
+ Rows/columns follow ``states`` when given (edges to states outside the set
294
+ are ignored), otherwise all model states in iteration order. Returns the
295
+ matrix together with the ordered state list defining its axes.
296
+ """
297
+ ordered = list(states) if states is not None else list(model.states())
298
+ index = {state: i for i, state in enumerate(ordered)}
299
+ n = len(ordered)
300
+ from sofic.generators.prob import as_prob, has_symbolic, zeros
301
+
302
+ edge_probs = []
303
+ for state in ordered:
304
+ for transition in model.graph.out_transitions(state):
305
+ if transition.target in index:
306
+ edge_probs.append(transition.data.get(attr, 0.0))
307
+ symbolic = has_symbolic(edge_probs)
308
+ matrix = zeros((n, n), symbolic=symbolic)
309
+ for state in ordered:
310
+ i = index[state]
311
+ for transition in model.graph.out_transitions(state):
312
+ j = index.get(transition.target)
313
+ if j is None:
314
+ continue
315
+ matrix[i, j] = as_prob(matrix[i, j]) + as_prob(transition.data.get(attr, 0.0))
316
+ return matrix, ordered
317
+
318
+
319
+ def _initial_vector_and_transition(model: StateMachine) -> tuple[np.ndarray, np.ndarray]:
320
+ idx = model.reindex()
321
+ n = len(idx)
322
+ vector = np.zeros(n, dtype=float)
323
+ for state, mass in getattr(model, "initial_distribution", {}).items():
324
+ vector[idx.index(state)] = float(mass)
325
+ transition, _states = transition_matrix(model, attr=ATTR_PROB, states=idx.states)
326
+ return vector, transition
327
+
328
+
329
+ def _labeled_or_internal_matrices(model: StateMachine) -> list[np.ndarray]:
330
+ observation_alphabet = getattr(model, "observation_alphabet", None)
331
+ to_mealy = getattr(model, "to_mealy", None)
332
+ if observation_alphabet is not None and to_mealy is not None:
333
+ from sofic.generators.hmm_inference import _emission_transition_tensors_from_mealy
334
+
335
+ _pi, matrices = _emission_transition_tensors_from_mealy(to_mealy())
336
+ return list(matrices.values())
337
+
338
+ _initial, transition = _initial_vector_and_transition(model)
339
+ return [transition]