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,250 @@
1
+ """Mixed-state presentations of hidden Markov models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from sofic.exceptions import StochasticValidationError
12
+ from sofic.generators.mealy import MealyHMM
13
+ from sofic.generators.prob import (
14
+ as_prob,
15
+ has_symbolic,
16
+ is_positive_mass,
17
+ is_symbolic,
18
+ is_zero,
19
+ simplify_prob,
20
+ sum_probs,
21
+ )
22
+ from sofic.graph import TransitionGraph
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class MixedState:
27
+ """A normalized belief distribution over presentation basis states."""
28
+
29
+ belief: tuple[Any, ...]
30
+
31
+ @classmethod
32
+ def from_vector(
33
+ cls,
34
+ vector: Sequence[Any] | np.ndarray,
35
+ *,
36
+ decimals: int = 12,
37
+ atol: float = 1e-12,
38
+ ) -> MixedState | None:
39
+ """Return a canonical mixed state, or ``None`` if the vector has no mass."""
40
+ flat = list(np.asarray(vector, dtype=object).ravel())
41
+ if has_symbolic(flat):
42
+ total = sum_probs(flat)
43
+ if is_zero(total, atol=atol):
44
+ return None
45
+ normalized = tuple(simplify_prob(as_prob(value) / total) for value in flat)
46
+ return cls(normalized)
47
+
48
+ array = np.asarray([float(value) for value in flat], dtype=float)
49
+ total = float(array.sum())
50
+ if total <= atol:
51
+ return None
52
+ normalized = array / total
53
+ rounded = tuple(round(float(value), decimals) for value in normalized)
54
+ total_rounded = sum(rounded)
55
+ if total_rounded <= atol:
56
+ return None
57
+ if not np.isclose(total_rounded, 1.0, atol=10 ** (-decimals + 2)):
58
+ rounded = tuple(round(value / total_rounded, decimals) for value in rounded)
59
+ return cls(rounded)
60
+
61
+ def as_array(self) -> np.ndarray:
62
+ if has_symbolic(self.belief):
63
+ array = np.empty(len(self.belief), dtype=object)
64
+ for i, value in enumerate(self.belief):
65
+ array[i] = value
66
+ return array
67
+ return np.asarray(self.belief, dtype=float)
68
+
69
+ def is_symbolic(self) -> bool:
70
+ return has_symbolic(self.belief)
71
+
72
+
73
+ def is_pure_mixed_state(
74
+ state: MixedState | Sequence[Any],
75
+ *,
76
+ atol: float = 1e-9,
77
+ ) -> bool:
78
+ """Return whether ``state`` is a vertex of the belief simplex."""
79
+ belief = state.belief if isinstance(state, MixedState) else tuple(state)
80
+ if has_symbolic(belief):
81
+ positives = [value for value in belief if is_positive_mass(value, atol=atol)]
82
+ from sofic.generators.prob import probs_equal
83
+
84
+ return len(positives) == 1 and probs_equal(sum_probs(belief), 1)
85
+ positives = [value for value in belief if value > atol]
86
+ return len(positives) == 1 and np.isclose(sum(belief), 1.0, atol=atol)
87
+
88
+
89
+ def pure_state_index(state: MixedState, *, atol: float = 1e-9) -> int | None:
90
+ """Return the basis index for a pure mixed state, else ``None``."""
91
+ if not is_pure_mixed_state(state, atol=atol):
92
+ return None
93
+ for index, value in enumerate(state.belief):
94
+ if is_positive_mass(value, atol=atol):
95
+ return index
96
+ return None
97
+
98
+
99
+ def mixed_state_entropy(state: MixedState, *, atol: float = 1e-12) -> float:
100
+ """Shannon entropy of a mixed state in bits."""
101
+ from sofic.generators.stochastic import shannon_entropy
102
+
103
+ if state.is_symbolic():
104
+ raise NotImplementedError("mixed_state_entropy is numeric-only; evaluate beliefs first")
105
+ return shannon_entropy(state.as_array(), atol=atol)
106
+
107
+
108
+ class MixedStatePresentation(MealyHMM):
109
+ """Unifilar presentation whose states are beliefs over a source HMM."""
110
+
111
+ basis_states: tuple[Hashable, ...]
112
+ initial_mixed_state: MixedState
113
+ pure_states: frozenset[MixedState]
114
+ recurrent_states: frozenset[MixedState]
115
+ transient_states: frozenset[MixedState]
116
+
117
+ def __init__(
118
+ self,
119
+ *,
120
+ basis_states: Sequence[Hashable],
121
+ initial_mixed_state: MixedState,
122
+ pure_states: frozenset[MixedState],
123
+ recurrent_states: frozenset[MixedState],
124
+ transient_states: frozenset[MixedState],
125
+ **kwargs: Any,
126
+ ) -> None:
127
+ super().__init__(**kwargs)
128
+ self.basis_states = tuple(basis_states)
129
+ self.initial_mixed_state = initial_mixed_state
130
+ self.pure_states = pure_states
131
+ self.recurrent_states = recurrent_states
132
+ self.transient_states = transient_states
133
+
134
+ @classmethod
135
+ def from_presentation(
136
+ cls,
137
+ hmm: MealyHMM,
138
+ *,
139
+ initial_mixed_state: MixedState | Mapping[Hashable, float] | Sequence[float] | None = None,
140
+ ) -> MixedStatePresentation:
141
+ from sofic.generators.mixed_state_construction import build_mixed_state_presentation
142
+
143
+ return build_mixed_state_presentation(hmm, initial_mixed_state=initial_mixed_state)
144
+
145
+ def to_recurrent(self) -> MealyHMM:
146
+ """Return the recurrent component with stationary initial weights.
147
+
148
+ Pure recurrent mixed states are relabeled by their basis states and returned
149
+ as an :class:`~sofic.generators.epsilon_machine.EpsilonMachine`. Otherwise
150
+ the recurrent component remains a unifilar :class:`~sofic.generators.mealy.MealyHMM`
151
+ over mixed states.
152
+ """
153
+ keep = frozenset(self.recurrent_states)
154
+ if not keep:
155
+ raise StochasticValidationError("mixed-state presentation has no recurrent states")
156
+
157
+ state_map, is_epsilon_machine = self._recurrent_state_map(keep)
158
+ graph = TransitionGraph()
159
+ for state in keep:
160
+ graph.add_state(state_map[state], **self.graph.state_attrs(state))
161
+ for state in keep:
162
+ for transition in self.graph.out_transitions(state):
163
+ if transition.target in keep:
164
+ graph.add_transition(
165
+ state_map[state],
166
+ state_map[transition.target],
167
+ **transition.data,
168
+ )
169
+
170
+ initial_distribution = self._recurrent_initial_distribution(keep, state_map)
171
+ constraints = getattr(self, "symbol_constraints", None)
172
+ if is_epsilon_machine:
173
+ from sofic.generators.epsilon_machine import EpsilonMachine
174
+
175
+ recurrent = EpsilonMachine(
176
+ graph=graph,
177
+ initial_distribution=initial_distribution,
178
+ observation_alphabet=self.observation_alphabet,
179
+ symbol_constraints=constraints,
180
+ )
181
+ recurrent.validate()
182
+ return recurrent
183
+
184
+ recurrent = MealyHMM(
185
+ graph=graph,
186
+ initial_distribution=initial_distribution,
187
+ observation_alphabet=self.observation_alphabet,
188
+ symbol_constraints=constraints,
189
+ )
190
+ recurrent.validate_stochastic()
191
+ recurrent._check_unifilar()
192
+ return recurrent
193
+
194
+ def _recurrent_state_map(
195
+ self,
196
+ keep: frozenset[MixedState],
197
+ ) -> tuple[dict[MixedState, Hashable], bool]:
198
+ labels: dict[MixedState, Hashable] = {}
199
+ for state in keep:
200
+ label = self.causal_state(state)
201
+ if label is None:
202
+ return {mixed_state: mixed_state for mixed_state in keep}, False
203
+ labels[state] = label
204
+ return labels, True
205
+
206
+ def _recurrent_initial_distribution(
207
+ self,
208
+ keep: frozenset[MixedState],
209
+ state_map: Mapping[MixedState, Hashable],
210
+ ) -> dict[Hashable, Any]:
211
+ from sofic.generators.prob import simplify_prob
212
+
213
+ idx = self.reindex()
214
+ stationary = self.stationary_distribution()
215
+ initial: dict[Hashable, Any] = {}
216
+ symbolic = stationary.dtype == object or has_symbolic(stationary.ravel())
217
+ for state in keep:
218
+ mass = stationary[idx.index(state)]
219
+ if not is_positive_mass(mass):
220
+ continue
221
+ label = state_map[state]
222
+ if label in initial:
223
+ initial[label] = simplify_prob(as_prob(initial[label]) + as_prob(mass))
224
+ else:
225
+ initial[label] = as_prob(mass)
226
+ if not initial:
227
+ raise StochasticValidationError("recurrent component has no positive stationary mass")
228
+ total = sum_probs(initial.values())
229
+ if symbolic or is_symbolic(total):
230
+ return {state: simplify_prob(as_prob(mass) / total) for state, mass in initial.items()}
231
+ total_f = float(total)
232
+ return {state: float(mass) / total_f for state, mass in initial.items()}
233
+
234
+ def belief(self, state: MixedState) -> tuple[Any, ...]:
235
+ return state.belief
236
+
237
+ def causal_state(self, state: MixedState) -> Hashable | None:
238
+ index = pure_state_index(state)
239
+ if index is None:
240
+ return None
241
+ return self.basis_states[index]
242
+
243
+ def is_pure(self, state: MixedState) -> bool:
244
+ return state in self.pure_states
245
+
246
+ def is_recurrent(self, state: MixedState) -> bool:
247
+ return state in self.recurrent_states
248
+
249
+ def is_transient(self, state: MixedState) -> bool:
250
+ return state in self.transient_states
@@ -0,0 +1,163 @@
1
+ """Build mixed-state presentations via belief-state enumeration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Hashable, Mapping, Sequence
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from sofic.exceptions import StochasticValidationError
12
+ from sofic.generators.base import HiddenMarkovModel
13
+ from sofic.generators.mealy import MealyHMM
14
+ from sofic.generators.mixed_state import (
15
+ MixedState,
16
+ MixedStatePresentation,
17
+ is_pure_mixed_state,
18
+ )
19
+ from sofic.generators.prob import (
20
+ as_prob,
21
+ has_symbolic,
22
+ is_positive_mass,
23
+ matvec,
24
+ simplify_prob,
25
+ sum_probs,
26
+ zeros,
27
+ )
28
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB, TransitionGraph
29
+
30
+
31
+ def _terminal_recurrent_states(graph: TransitionGraph) -> frozenset[Any]:
32
+ return graph.terminal_recurrent_states()
33
+
34
+
35
+ def _resolve_initial_belief(
36
+ hmm: HiddenMarkovModel,
37
+ basis: tuple[Hashable, ...],
38
+ initial_mixed_state: MixedState | Mapping[Hashable, Any] | Sequence[Any] | None,
39
+ ) -> MixedState:
40
+ if initial_mixed_state is None:
41
+ vector = hmm.stationary_distribution()
42
+ elif isinstance(initial_mixed_state, MixedState):
43
+ vector = initial_mixed_state.as_array()
44
+ elif isinstance(initial_mixed_state, Mapping):
45
+ index = {state: i for i, state in enumerate(basis)}
46
+ symbolic = has_symbolic(initial_mixed_state.values())
47
+ vector = zeros((len(basis),), symbolic=symbolic)
48
+ for state, mass in initial_mixed_state.items():
49
+ vector[index[state]] = as_prob(mass)
50
+ else:
51
+ vector = np.asarray(initial_mixed_state, dtype=object if has_symbolic(initial_mixed_state) else float)
52
+
53
+ if len(np.asarray(vector).ravel()) != len(basis):
54
+ raise ValueError(f"initial belief has length {len(np.asarray(vector).ravel())}, expected {len(basis)}")
55
+ mixed = MixedState.from_vector(vector)
56
+ if mixed is None:
57
+ raise ValueError("initial mixed state has zero total mass")
58
+ return mixed
59
+
60
+
61
+ def build_mixed_state_presentation(
62
+ hmm: HiddenMarkovModel,
63
+ *,
64
+ initial_mixed_state: MixedState | Mapping[Hashable, Any] | Sequence[Any] | None = None,
65
+ max_states: int = 10_000,
66
+ ) -> MixedStatePresentation:
67
+ """Enumerate the mixed-state presentation of ``hmm``.
68
+
69
+ States are reachable belief distributions over the source presentation's hidden
70
+ states, updated by Bayes' rule on emitted symbols. The construction follows
71
+ Ellison, Mahoney & Crutchfield (J. Stat. Phys. 2009), Sec. VIII.2.
72
+
73
+ Any Mealy-style HMM (joint edge emissions) is accepted; unifilarity is not required.
74
+ Probabilities may be floats or exact sympy expressions.
75
+
76
+ Parameters
77
+ ----------
78
+ max_states
79
+ Safety cap on enumerated beliefs (symbolic machines can otherwise grow
80
+ without bound when successor beliefs fail to identify).
81
+ """
82
+ if isinstance(hmm, MixedStatePresentation):
83
+ raise TypeError("cannot build a mixed-state presentation from another mixed-state presentation")
84
+ if not isinstance(hmm, MealyHMM):
85
+ raise TypeError(f"mixed-state presentation requires a MealyHMM, not {type(hmm)!r}")
86
+
87
+ from sofic.generators.hmm_inference import _emission_transition_tensors
88
+
89
+ constraints = getattr(hmm, "symbol_constraints", None)
90
+
91
+ idx = hmm.reindex()
92
+ basis = idx.states
93
+ _, joint = _emission_transition_tensors(hmm)
94
+ symbols = tuple(sorted(joint, key=str))
95
+ eta0 = _resolve_initial_belief(hmm, basis, initial_mixed_state)
96
+
97
+ graph = TransitionGraph()
98
+ discovered: dict[MixedState, MixedState] = {}
99
+ queue: deque[MixedState] = deque()
100
+
101
+ def register(state: MixedState) -> MixedState:
102
+ existing = discovered.get(state)
103
+ if existing is not None:
104
+ return existing
105
+ for known in discovered:
106
+ if _beliefs_equal(known, state, constraints=constraints):
107
+ discovered[state] = known
108
+ return known
109
+ if len(discovered) >= max_states:
110
+ raise StochasticValidationError(f"mixed-state presentation exceeded max_states={max_states}")
111
+ discovered[state] = state
112
+ graph.add_state(state)
113
+ queue.append(state)
114
+ return state
115
+
116
+ register(eta0)
117
+
118
+ while queue:
119
+ eta = queue.popleft()
120
+ row = eta.as_array()
121
+ for symbol in symbols:
122
+ matrix = joint[symbol]
123
+ mass = matvec(row, matrix)
124
+ probability = simplify_prob(sum_probs(mass.tolist()))
125
+ if not is_positive_mass(probability):
126
+ continue
127
+ successor = MixedState.from_vector(mass)
128
+ if successor is None:
129
+ continue
130
+ successor = register(successor)
131
+ graph.add_transition(
132
+ eta,
133
+ successor,
134
+ **{ATTR_PROB: as_prob(probability), ATTR_EMISSION: symbol},
135
+ )
136
+
137
+ pure_states = frozenset(state for state in discovered.values() if is_pure_mixed_state(state))
138
+ unique_states = frozenset(discovered.values())
139
+ recurrent_states = _terminal_recurrent_states(graph) & unique_states
140
+ if not recurrent_states:
141
+ recurrent_states = _terminal_recurrent_states(graph)
142
+ reachable = frozenset(discovered.values())
143
+ transient_states = reachable - recurrent_states
144
+
145
+ return MixedStatePresentation(
146
+ graph=graph,
147
+ basis_states=basis,
148
+ initial_mixed_state=eta0,
149
+ pure_states=pure_states,
150
+ recurrent_states=frozenset(recurrent_states),
151
+ transient_states=frozenset(transient_states),
152
+ initial_distribution={eta0: as_prob(1)},
153
+ observation_alphabet=hmm.observation_alphabet,
154
+ symbol_constraints=constraints,
155
+ )
156
+
157
+
158
+ def _beliefs_equal(left: MixedState, right: MixedState, *, constraints: Any = None) -> bool:
159
+ from sofic.generators.prob import probs_equal
160
+
161
+ if len(left.belief) != len(right.belief):
162
+ return False
163
+ return all(probs_equal(a, b, constraints=constraints) for a, b in zip(left.belief, right.belief, strict=True))
@@ -0,0 +1,75 @@
1
+ """Moore-type hidden Markov models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Mapping
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+
10
+ from sofic.exceptions import StochasticValidationError
11
+ from sofic.generators.base import HiddenMarkovModel
12
+ from sofic.generators.mealy import MealyHMM
13
+ from sofic.graph import ATTR_EMISSION_DIST, ATTR_PROB
14
+
15
+ if TYPE_CHECKING:
16
+ from sofic.generators.lumping import LabelsLike, PartitionLike
17
+
18
+
19
+ class MooreHMM(HiddenMarkovModel):
20
+ """HMM with P(o | q) on states and P(q' | q) on edges."""
21
+
22
+ def set_emission_distribution(self, state: Hashable, distribution: Mapping[Any, float]) -> None:
23
+ """Set the state emission law ``P(observation | state)``."""
24
+ self.graph.nx.nodes[state][ATTR_EMISSION_DIST] = dict(distribution)
25
+
26
+ def add_transition(self, source: Hashable, target: Hashable, prob: float, **attrs: Any) -> int:
27
+ """Add an edge carrying transition probability ``P(target | source)``."""
28
+ return self.graph.add_transition(source, target, **{ATTR_PROB: float(prob), **attrs})
29
+
30
+ def is_unifilar(self) -> bool:
31
+ """Return whether the Mealy conversion is row-unifilar."""
32
+ return self.to_mealy().is_unifilar()
33
+
34
+ def to_mealy(self) -> MealyHMM:
35
+ from sofic.generators.conversions import moore_to_mealy
36
+
37
+ return moore_to_mealy(self)
38
+
39
+ def is_lumpable(self, partition: PartitionLike, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
40
+ """Return whether ``partition`` is strongly lumpable for this HMM."""
41
+ from sofic.generators.lumping import is_lumpable
42
+
43
+ return is_lumpable(self, partition, rtol=rtol, atol=atol)
44
+
45
+ def lump(
46
+ self,
47
+ partition: PartitionLike,
48
+ *,
49
+ check: bool = True,
50
+ labels: LabelsLike | None = None,
51
+ rtol: float = 1e-8,
52
+ atol: float = 1e-10,
53
+ ) -> MooreHMM:
54
+ """Aggregate states into blocks, returning the lumped Moore HMM."""
55
+ from sofic.generators.lumping import lump
56
+
57
+ return lump(self, partition, check=check, labels=labels, rtol=rtol, atol=atol)
58
+
59
+ def validate_stochastic(self) -> None:
60
+ super().validate_stochastic()
61
+ for state in self.states():
62
+ attrs = self.graph.state_attrs(state)
63
+ emission_dist = attrs.get(ATTR_EMISSION_DIST)
64
+ if emission_dist is not None:
65
+ total = sum(emission_dist.values())
66
+ if not np.isclose(total, 1.0):
67
+ raise StochasticValidationError(f"emission distribution at {state!r} sums to {total}")
68
+ for symbol, prob in emission_dist.items():
69
+ if prob < 0:
70
+ raise StochasticValidationError(f"negative emission probability at {state!r}")
71
+ self._require(symbol in self.observation_alphabet, f"unknown emission {symbol!r}")
72
+ outgoing = list(self.graph.out_transitions(state))
73
+ trans_total = sum(t.data.get(ATTR_PROB, 0.0) for t in outgoing)
74
+ if outgoing and not np.isclose(trans_total, 1.0):
75
+ raise StochasticValidationError(f"transition probabilities from {state!r} sum to {trans_total}")
@@ -0,0 +1,78 @@
1
+ """Negative machines (n-machines) with quasiprobabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Mapping
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+
10
+ from sofic.exceptions import QuasiStochasticValidationError
11
+ from sofic.generators.base import QuasiStochasticModel
12
+ from sofic.generators.epsilon_machine import EpsilonMachine
13
+ from sofic.graph import ATTR_EMISSION, ATTR_QUASIPROB
14
+
15
+ if TYPE_CHECKING:
16
+ from sofic.generators.quasi_realization import QuasiRealization
17
+
18
+
19
+ class NMachine(QuasiStochasticModel):
20
+ """Mealy-type generator with signed joint quasiprobabilities on edges."""
21
+
22
+ observation_alphabet: frozenset[Any]
23
+
24
+ def __init__(self, observation_alphabet: frozenset[Any] | None = None, **kwargs: Any) -> None:
25
+ super().__init__(**kwargs)
26
+ self.observation_alphabet = observation_alphabet if observation_alphabet is not None else frozenset()
27
+
28
+ def validate_quasistochastic(self) -> None:
29
+ super().validate_quasistochastic()
30
+ for state in self.states():
31
+ outgoing = list(self.graph.out_transitions(state))
32
+ total = sum(t.data.get(ATTR_QUASIPROB, 0.0) for t in outgoing)
33
+ if outgoing and not np.isclose(total, 1.0):
34
+ raise QuasiStochasticValidationError(f"quasi masses from {state!r} sum to {total}")
35
+ marginals: dict[Any, float] = {}
36
+ for transition in outgoing:
37
+ emission = transition.data.get(ATTR_EMISSION)
38
+ if emission is None:
39
+ continue
40
+ marginals[emission] = marginals.get(emission, 0.0) + transition.data.get(ATTR_QUASIPROB, 0.0)
41
+ for emission, mass in marginals.items():
42
+ if mass < -1e-12:
43
+ raise QuasiStochasticValidationError(
44
+ f"negative marginal output probability P({emission!r}|{state!r})"
45
+ )
46
+ self._require(emission in self.observation_alphabet, f"unknown emission {emission!r}")
47
+
48
+ def is_unifilar(self) -> bool:
49
+ """Return whether each state emits at most one edge per symbol."""
50
+ from sofic.properties import is_unifilar_emissions
51
+
52
+ return is_unifilar_emissions(self)
53
+
54
+ @classmethod
55
+ def from_epsilon_machine(
56
+ cls,
57
+ eps: EpsilonMachine,
58
+ splits: Mapping[Hashable, int] | None = None,
59
+ **kwargs: Any,
60
+ ) -> NMachine:
61
+ from sofic.generators.nmachine_construction import build_nmachine
62
+
63
+ return build_nmachine(eps, splits)
64
+
65
+ def coarse_grained_distribution(self) -> dict[Hashable, float]:
66
+ from sofic.generators.nmachine_construction import coarse_grained_distribution
67
+
68
+ eps_states = tuple(
69
+ substate[0] for substate in self.states() if isinstance(substate, tuple) and len(substate) == 2
70
+ )
71
+ if not eps_states:
72
+ eps_states = tuple(self.states())
73
+ return coarse_grained_distribution(self, eps_states)
74
+
75
+ def to_quasi_realization(self) -> QuasiRealization:
76
+ from sofic.generators.quasi_realization import QuasiRealization
77
+
78
+ return QuasiRealization.from_nmachine(self)
@@ -0,0 +1,70 @@
1
+ """N-machine state-splitting construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Mapping
6
+
7
+ from sofic.generators.epsilon_machine import EpsilonMachine
8
+ from sofic.generators.nmachine import NMachine
9
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB, ATTR_QUASIPROB, TransitionGraph
10
+
11
+
12
+ def build_nmachine(
13
+ eps: EpsilonMachine,
14
+ splits: Mapping[Hashable, int] | None = None,
15
+ ) -> NMachine:
16
+ split_counts = dict(splits or {})
17
+ for state in eps.states():
18
+ split_counts.setdefault(state, 1)
19
+
20
+ graph = TransitionGraph()
21
+ coarse_map: dict[tuple[Hashable, int], Hashable] = {}
22
+ for state in eps.states():
23
+ count = split_counts[state]
24
+ for branch in range(count):
25
+ substate = (state, branch)
26
+ graph.add_state(substate)
27
+ coarse_map[substate] = state
28
+
29
+ pi = eps.stationary_distribution()
30
+ idx = eps.reindex()
31
+ initial: dict[tuple[Hashable, int], float] = {}
32
+ for state in eps.states():
33
+ count = split_counts[state]
34
+ mass = float(pi[idx.index(state)]) / count
35
+ for branch in range(count):
36
+ initial[(state, branch)] = mass
37
+
38
+ for state in eps.states():
39
+ count = split_counts[state]
40
+ outgoing = list(eps.graph.out_transitions(state))
41
+ for branch in range(count):
42
+ source = (state, branch)
43
+ for transition in outgoing:
44
+ emission = transition.data.get(ATTR_EMISSION)
45
+ target_state = transition.target
46
+ target_branch = branch % split_counts[target_state]
47
+ prob = float(transition.data.get(ATTR_PROB, 0.0))
48
+ graph.add_transition(
49
+ source,
50
+ (target_state, target_branch),
51
+ **{ATTR_QUASIPROB: prob, ATTR_EMISSION: emission},
52
+ )
53
+
54
+ return NMachine(
55
+ graph=graph,
56
+ initial_quasidistribution=initial,
57
+ observation_alphabet=eps.observation_alphabet,
58
+ )
59
+
60
+
61
+ def coarse_grained_distribution(nm: NMachine, eps_states: tuple[Hashable, ...]) -> dict[Hashable, float]:
62
+ pi = nm.stationary_quasidistribution()
63
+ idx = nm.reindex()
64
+ coarse: dict[Hashable, float] = dict.fromkeys(eps_states, 0.0)
65
+ for substate, mass in zip(idx.states, pi, strict=False):
66
+ if isinstance(substate, tuple) and len(substate) == 2:
67
+ coarse[substate[0]] = coarse.get(substate[0], 0.0) + float(mass)
68
+ else:
69
+ coarse[substate] = coarse.get(substate, 0.0) + float(mass)
70
+ return coarse