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,100 @@
1
+ """Probabilistic finite automata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Sequence
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from sofic.generators.base import StochasticModel
11
+ from sofic.generators.edge_emissions import validate_stochastic_edge_emissions
12
+ from sofic.generators.mealy import MealyHMM
13
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB
14
+
15
+
16
+ class ProbabilisticFiniteAutomaton(StochasticModel):
17
+ """String generator with joint symbol+probability on edges."""
18
+
19
+ output_alphabet: frozenset[Any]
20
+
21
+ def __init__(self, output_alphabet: frozenset[Any] | None = None, **kwargs: Any) -> None:
22
+ super().__init__(**kwargs)
23
+ self.output_alphabet = output_alphabet if output_alphabet is not None else frozenset()
24
+
25
+ def add_transition(self, source: Hashable, target: Hashable, symbol: Any, prob: float, **attrs: Any) -> int:
26
+ """Add an edge carrying joint output probability ``P(target, symbol | source)``."""
27
+ return self.graph.add_transition(
28
+ source,
29
+ target,
30
+ **{ATTR_EMISSION: symbol, ATTR_PROB: float(prob), **attrs},
31
+ )
32
+
33
+ def validate_stochastic(self) -> None:
34
+ super().validate_stochastic()
35
+ validate_stochastic_edge_emissions(
36
+ self,
37
+ alphabet=self.output_alphabet,
38
+ alphabet_name="output",
39
+ row_mass_label="outgoing masses",
40
+ negative_probability_label="negative probability",
41
+ )
42
+
43
+ def is_unifilar(self) -> bool:
44
+ """Return whether each state emits at most one edge per symbol."""
45
+ from sofic.properties import is_unifilar_emissions
46
+
47
+ return is_unifilar_emissions(self)
48
+
49
+ def to_mealy(self) -> MealyHMM:
50
+ from sofic.generators.conversions import pfa_to_mealy
51
+
52
+ return pfa_to_mealy(self)
53
+
54
+ def string_probability(self, word: Sequence[Any]) -> float:
55
+ if not word:
56
+ return sum(self.initial_distribution.values())
57
+ idx = self.reindex()
58
+ n = len(idx)
59
+ mass = np.zeros(n, dtype=float)
60
+ for state, prob in self.initial_distribution.items():
61
+ mass[idx.index(state)] = float(prob)
62
+ for symbol in word:
63
+ updated = np.zeros(n, dtype=float)
64
+ for transition in self.transitions():
65
+ if transition.data.get(ATTR_EMISSION) != symbol:
66
+ continue
67
+ i = idx.index(transition.source)
68
+ j = idx.index(transition.target)
69
+ updated[j] += mass[i] * float(transition.data.get(ATTR_PROB, 0.0))
70
+ mass = updated
71
+ return float(mass.sum())
72
+
73
+ def words_of_length(self, length: int) -> dict[tuple[Any, ...], float]:
74
+ """Return output words of ``length`` and their probabilities."""
75
+ from sofic.generators.words import pfa_words_of_length
76
+
77
+ return pfa_words_of_length(self, length)
78
+
79
+ def sample(self, n: int, rng: np.random.Generator | None = None) -> list[Any]:
80
+ generator = rng if rng is not None else np.random.default_rng()
81
+ idx = self.reindex()
82
+ probs = np.array([self.initial_distribution.get(s, 0.0) for s in idx.states], dtype=float)
83
+ if probs.sum() <= 0.0:
84
+ return []
85
+ state = int(generator.choice(len(idx), p=probs / probs.sum()))
86
+ output: list[Any] = []
87
+ for _ in range(n):
88
+ outgoing = list(self.graph.out_transitions(idx.state(state)))
89
+ if not outgoing:
90
+ break
91
+ edge_probs = np.array([float(t.data.get(ATTR_PROB, 0.0)) for t in outgoing], dtype=float)
92
+ if edge_probs.sum() <= 0.0:
93
+ break
94
+ edge_probs /= edge_probs.sum()
95
+ edge = outgoing[int(generator.choice(len(outgoing), p=edge_probs))]
96
+ emission = edge.data.get(ATTR_EMISSION)
97
+ if emission is not None:
98
+ output.append(emission)
99
+ state = idx.index(edge.target)
100
+ return output
@@ -0,0 +1,291 @@
1
+ """Probability scalars: floats or sympy expressions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ Prob = Any # float | sympy.Expr
11
+
12
+
13
+ def _sympy():
14
+ try:
15
+ import sympy
16
+ except ImportError as exc: # pragma: no cover - optional dependency
17
+ raise ImportError("Symbolic probabilities require sympy. Install with: pip install sofic[symbolic]") from exc
18
+ return sympy
19
+
20
+
21
+ def is_symbolic(value: Any) -> bool:
22
+ """Return whether ``value`` is an exact sympy expression (not a Python float).
23
+
24
+ Exact rationals / integers and expressions with free symbols are symbolic.
25
+ ``sympy.Float`` is treated as numeric and will be coerced to ``float``.
26
+ """
27
+ try:
28
+ import sympy
29
+ except ImportError:
30
+ return False
31
+ if not isinstance(value, sympy.Expr):
32
+ return False
33
+ return not isinstance(value, sympy.Float)
34
+
35
+
36
+ def has_symbolic(values: Iterable[Any]) -> bool:
37
+ """Return whether any entry in ``values`` is symbolic."""
38
+ return any(is_symbolic(value) for value in values)
39
+
40
+
41
+ def as_prob(value: Any) -> Prob:
42
+ """Coerce ``value`` to a stored probability: sympy Expr pass-through, else float.
43
+
44
+ Plain Python ``int`` values are left unchanged so they compose with sympy
45
+ without introducing ``Float`` coefficients (``0 + a`` stays exact).
46
+ """
47
+ if is_symbolic(value):
48
+ return value
49
+ if isinstance(value, bool):
50
+ return float(value)
51
+ if isinstance(value, int):
52
+ return value
53
+ try:
54
+ import sympy
55
+
56
+ if isinstance(value, sympy.Basic):
57
+ return float(value)
58
+ except ImportError:
59
+ pass
60
+ return float(value)
61
+
62
+
63
+ def simplify_prob(value: Prob) -> Prob:
64
+ """Simplify a symbolic probability; return floats/ints unchanged."""
65
+ if not is_symbolic(value):
66
+ return value
67
+ sp = _sympy()
68
+ return sp.simplify(value)
69
+
70
+
71
+ def is_zero(value: Prob, *, atol: float = 1e-15) -> bool:
72
+ """Return whether ``value`` is (numerically or symbolically) zero."""
73
+ if is_symbolic(value):
74
+ sp = _sympy()
75
+ return sp.simplify(value) == 0
76
+ if isinstance(value, int):
77
+ return value == 0
78
+ try:
79
+ return abs(float(value)) <= atol
80
+ except (TypeError, ValueError):
81
+ return False
82
+
83
+
84
+ def is_positive_mass(value: Prob, *, atol: float = 1e-15) -> bool:
85
+ """Return whether ``value`` carries positive probability mass.
86
+
87
+ Symbolic expressions with free symbols are treated as positive unless they
88
+ simplify to a non-positive number (matching dit's free-symbol convention).
89
+ """
90
+ if is_zero(value, atol=atol):
91
+ return False
92
+ if is_symbolic(value):
93
+ sp = _sympy()
94
+ simplified = sp.simplify(value)
95
+ if simplified.free_symbols:
96
+ return simplified.is_nonpositive is not True
97
+ try:
98
+ return float(simplified) > atol
99
+ except (TypeError, ValueError):
100
+ return simplified != 0
101
+ return float(value) > atol
102
+
103
+
104
+ def probs_equal(
105
+ left: Prob,
106
+ right: Prob,
107
+ *,
108
+ rtol: float = 1e-9,
109
+ atol: float = 1e-12,
110
+ constraints: SymbolConstraints | None = None,
111
+ ) -> bool:
112
+ """Equality for probability scalars (exact sympy simplify, else numeric close).
113
+
114
+ When ``constraints`` are supplied and either operand is symbolic, equality is
115
+ tested modulo the constraint ideal (see :class:`SymbolConstraints`), so
116
+ expressions that coincide only under a parameter's minimal polynomial are
117
+ recognized as equal.
118
+ """
119
+ if is_symbolic(left) or is_symbolic(right):
120
+ sp = _sympy()
121
+ if constraints is not None:
122
+ return constraints.equal(left, right)
123
+ return sp.simplify(sp.sympify(left) - sp.sympify(right)) == 0
124
+ return bool(np.isclose(float(left), float(right), rtol=rtol, atol=atol))
125
+
126
+
127
+ def sum_probs(values: Iterable[Prob]) -> Prob:
128
+ """Sum probability scalars, preserving sympy when any term is symbolic."""
129
+ items = list(values)
130
+ if not items:
131
+ return 0
132
+ if has_symbolic(items):
133
+ sp = _sympy()
134
+ total = sp.Integer(0)
135
+ for value in items:
136
+ total += sp.sympify(value)
137
+ return simplify_prob(total)
138
+ return float(sum(float(value) for value in items))
139
+
140
+
141
+ def row_sums_to_one(probs: Sequence[Prob], *, atol: float = 1e-9) -> bool:
142
+ """Return whether ``probs`` sum to one (exact sympy or numeric close)."""
143
+ if not probs:
144
+ return True
145
+ total = sum_probs(probs)
146
+ if is_symbolic(total) or has_symbolic(probs):
147
+ sp = _sympy()
148
+ # Free symbols: accept rows that are identically 1, else skip (dit-style).
149
+ if getattr(total, "free_symbols", None):
150
+ return True
151
+ return sp.simplify(sp.sympify(total) - 1) == 0
152
+ return bool(np.isclose(float(total), 1.0, atol=atol))
153
+
154
+
155
+ def zeros(shape: tuple[int, ...], *, symbolic: bool = False) -> np.ndarray:
156
+ """Allocate a probability array (object dtype when ``symbolic``)."""
157
+ if symbolic:
158
+ sp = _sympy()
159
+ array = np.empty(shape, dtype=object)
160
+ array.fill(sp.Integer(0))
161
+ return array
162
+ return np.zeros(shape, dtype=float)
163
+
164
+
165
+ def array_sum(array: np.ndarray) -> Prob:
166
+ """Sum an array, using Python reduction for object (sympy) arrays."""
167
+ flat = np.asarray(array).ravel()
168
+ if flat.dtype == object or has_symbolic(flat):
169
+ return sum_probs(flat.tolist())
170
+ return float(flat.sum())
171
+
172
+
173
+ def matvec(row: np.ndarray, matrix: np.ndarray) -> np.ndarray:
174
+ """Row-vector times matrix, supporting object-dtype sympy entries."""
175
+ if row.dtype == object or matrix.dtype == object or has_symbolic(row) or has_symbolic(matrix.ravel()):
176
+ sp = _sympy()
177
+ n = matrix.shape[1]
178
+ out = np.empty(n, dtype=object)
179
+ for j in range(n):
180
+ total = sp.Integer(0)
181
+ for i in range(matrix.shape[0]):
182
+ total += sp.sympify(row[i]) * sp.sympify(matrix[i, j])
183
+ out[j] = simplify_prob(total)
184
+ return out
185
+ return row @ matrix
186
+
187
+
188
+ def canonical_prob_key(value: Prob, constraints: SymbolConstraints | None = None) -> Any:
189
+ """Hash-stable key for partition signatures (simplified sympy or float).
190
+
191
+ With ``constraints`` the key is taken in the constraint residue field, so
192
+ two probabilities that are equal modulo the constraints share a key.
193
+ """
194
+ if constraints is not None:
195
+ return constraints.key(value)
196
+ if is_symbolic(value):
197
+ sp = _sympy()
198
+ return sp.srepr(sp.simplify(value))
199
+ return float(value)
200
+
201
+
202
+ class SymbolConstraints:
203
+ """Algebraic side-relations (each ``expr == 0``) over probability symbols.
204
+
205
+ Used to compare symbolic probabilities modulo the ideal they generate, so
206
+ that states which coincide only under a parameter's minimal polynomial (for
207
+ example the Misiurewicz root ``a**3 - 2*a - 2 == 0``) are recognized as
208
+ equal when de-duplicating beliefs or merging causal states.
209
+
210
+ When the relations reduce to a single univariate polynomial in one symbol,
211
+ equality and keys are computed exactly in the residue field
212
+ ``Q[s] / (minpoly)`` via ``p * q^{-1} mod minpoly``. Otherwise a
213
+ Groebner-basis ideal-membership test (with a plain ``simplify`` fallback) is
214
+ used for equality, and the simplified ``srepr`` for keys.
215
+ """
216
+
217
+ def __init__(self, relations: Iterable[Any]) -> None:
218
+ sp = _sympy()
219
+ rels = tuple(sp.sympify(relation) for relation in relations)
220
+ self.relations = rels
221
+ symbols: set[Any] = set()
222
+ for relation in rels:
223
+ symbols |= relation.free_symbols
224
+ self._symbols = tuple(sorted(symbols, key=str))
225
+ self._var: Any | None = None
226
+ self._minpoly: Any | None = None
227
+ if len(rels) == 1 and len(self._symbols) == 1:
228
+ var = self._symbols[0]
229
+ try:
230
+ self._minpoly = sp.Poly(rels[0], var, domain=sp.QQ)
231
+ self._var = var
232
+ except sp.PolynomialError:
233
+ self._minpoly = None
234
+ self._var = None
235
+
236
+ def __repr__(self) -> str:
237
+ return f"SymbolConstraints({list(self.relations)!r})"
238
+
239
+ def __eq__(self, other: Any) -> bool:
240
+ return isinstance(other, SymbolConstraints) and self.relations == other.relations
241
+
242
+ def __hash__(self) -> int:
243
+ return hash(self.relations)
244
+
245
+ def _residue_key(self, value: Prob) -> tuple[Any, ...] | None:
246
+ """Residue-field representative coefficients, or ``None`` if unavailable."""
247
+ if self._minpoly is None or self._var is None:
248
+ return None
249
+ if value.free_symbols - {self._var}:
250
+ return None
251
+ sp = _sympy()
252
+ from sympy.polys.polyerrors import NotInvertible
253
+
254
+ try:
255
+ num, den = sp.fraction(sp.together(value))
256
+ num_poly = sp.Poly(num, self._var, domain=sp.QQ)
257
+ den_poly = sp.Poly(den, self._var, domain=sp.QQ)
258
+ den_inv = sp.invert(den_poly, self._minpoly)
259
+ rep = sp.rem((num_poly * den_inv).as_expr(), self._minpoly.as_expr(), self._var)
260
+ coeffs = sp.Poly(rep, self._var, domain=sp.QQ).all_coeffs()
261
+ except (sp.PolynomialError, NotInvertible, ValueError, ZeroDivisionError):
262
+ return None
263
+ return tuple(coeffs)
264
+
265
+ def key(self, value: Prob) -> Any:
266
+ """Canonical, hashable key equal for probabilities equal modulo the ideal."""
267
+ if not is_symbolic(value):
268
+ return float(value)
269
+ residue = self._residue_key(value)
270
+ if residue is not None:
271
+ return ("mod", residue)
272
+ return canonical_prob_key(value)
273
+
274
+ def equal(self, left: Prob, right: Prob) -> bool:
275
+ """Return whether ``left`` and ``right`` are equal modulo the constraints."""
276
+ sp = _sympy()
277
+ left_residue = self._residue_key(sp.sympify(left)) if is_symbolic(left) else None
278
+ right_residue = self._residue_key(sp.sympify(right)) if is_symbolic(right) else None
279
+ if left_residue is not None and right_residue is not None:
280
+ return left_residue == right_residue
281
+ diff = sp.sympify(left) - sp.sympify(right)
282
+ if self.relations:
283
+ try:
284
+ num = sp.numer(sp.together(diff))
285
+ basis = sp.groebner(self.relations, *self._symbols, order="lex")
286
+ _, remainder = sp.reduced(sp.expand(num), basis, *self._symbols)
287
+ if sp.simplify(remainder) == 0:
288
+ return True
289
+ except (sp.PolynomialError, ValueError, ZeroDivisionError):
290
+ pass
291
+ return sp.simplify(diff) == 0
@@ -0,0 +1,207 @@
1
+ """Finite-state process equivalence for HMM presentations.
2
+
3
+ The algorithm follows the sufficient history/future word-list construction used
4
+ by CMPy, based on Dan Upper's finite-dimensional process-equivalence test.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import deque
10
+ from collections.abc import Hashable, Mapping, Sequence
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ from sofic.generators.base import HiddenMarkovModel
17
+ from sofic.generators.hmm_inference import _emission_transition_tensors_from_mealy
18
+ from sofic.generators.words import _start_vector
19
+
20
+ _DEFAULT_RTOL = 1e-9
21
+ _DEFAULT_ATOL = 1e-12
22
+
23
+
24
+ def is_equal_process(
25
+ g1: HiddenMarkovModel,
26
+ g2: HiddenMarkovModel,
27
+ *,
28
+ start1: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
29
+ start2: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
30
+ rtol: float | None = None,
31
+ atol: float | None = None,
32
+ ) -> bool:
33
+ """Return whether two finite HMMs generate the same word process.
34
+
35
+ The test compares finite bases for the history and future spaces rather
36
+ than brute-force enumerating all words up to a fixed cutoff.
37
+ """
38
+ if set(g1.observation_alphabet) != set(g2.observation_alphabet):
39
+ return False
40
+
41
+ rtol = _DEFAULT_RTOL if rtol is None else float(rtol)
42
+ atol = _DEFAULT_ATOL if atol is None else float(atol)
43
+
44
+ hf1 = _HistoryFutureWordList.from_hmm(g1, start=start1)
45
+ hf2 = _HistoryFutureWordList.from_hmm(g2, start=start2)
46
+
47
+ history_words = _sorted_words(set(hf1.history_word_list()) | set(hf2.history_word_list()))
48
+ future_words = _sorted_words(set(hf1.future_word_list()) | set(hf2.future_word_list()))
49
+
50
+ if not _compare_future_probabilities(hf1, hf2, future_words, rtol=rtol, atol=atol):
51
+ return False
52
+ if not _compare_conditional_tables(hf1, hf2, history_words, future_words, rtol=rtol, atol=atol):
53
+ return False
54
+
55
+ one_step_future_words = set(future_words)
56
+ for word in future_words:
57
+ for symbol in hf1.alphabet:
58
+ one_step_future_words.add((symbol,) + word)
59
+ extended_future_words = _sorted_words(one_step_future_words)
60
+ return _compare_conditional_tables(hf1, hf2, history_words, extended_future_words, rtol=rtol, atol=atol)
61
+
62
+
63
+ @dataclass
64
+ class _HistoryFutureWordList:
65
+ alphabet: tuple[Any, ...]
66
+ matrices: dict[Any, np.ndarray]
67
+ start: np.ndarray
68
+ _word_matrices: dict[tuple[Any, ...], np.ndarray] = field(default_factory=dict)
69
+ _future_words: list[tuple[Any, ...]] | None = None
70
+ _history_words: list[tuple[Any, ...]] | None = None
71
+
72
+ @classmethod
73
+ def from_hmm(
74
+ cls,
75
+ hmm: HiddenMarkovModel,
76
+ *,
77
+ start: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
78
+ ) -> _HistoryFutureWordList:
79
+ mealy = hmm.to_mealy()
80
+ pi, matrices = _emission_transition_tensors_from_mealy(mealy)
81
+ return cls(
82
+ alphabet=tuple(sorted(mealy.observation_alphabet, key=repr)),
83
+ matrices=matrices,
84
+ start=_start_vector(mealy, pi, start),
85
+ )
86
+
87
+ @property
88
+ def dimension(self) -> int:
89
+ return len(self.start)
90
+
91
+ def word_matrix(self, word: tuple[Any, ...]) -> np.ndarray:
92
+ if not self._word_matrices:
93
+ self._word_matrices[()] = np.eye(self.dimension, dtype=float)
94
+ cached = self._word_matrices.get(word)
95
+ if cached is not None:
96
+ return cached
97
+ if not word:
98
+ return self._word_matrices[()]
99
+
100
+ prefix = word[:-1]
101
+ prefix_matrix = self.word_matrix(prefix)
102
+ symbol_matrix = self.matrices.get(word[-1])
103
+ if symbol_matrix is None:
104
+ matrix = np.zeros((self.dimension, self.dimension), dtype=float)
105
+ else:
106
+ matrix = prefix_matrix @ symbol_matrix
107
+ self._word_matrices[word] = matrix
108
+ return matrix
109
+
110
+ def future_vector(self, word: tuple[Any, ...]) -> np.ndarray:
111
+ return self.word_matrix(word) @ np.ones(self.dimension, dtype=float)
112
+
113
+ def history_vector(self, word: tuple[Any, ...]) -> np.ndarray:
114
+ vector = self.start @ self.word_matrix(word)
115
+ total = float(vector.sum())
116
+ if total != 0.0:
117
+ vector = vector / total
118
+ return vector
119
+
120
+ def future_word_list(self) -> list[tuple[Any, ...]]:
121
+ if self._future_words is not None:
122
+ return list(self._future_words)
123
+
124
+ queue: deque[tuple[Any, ...]] = deque([()])
125
+ words: list[tuple[Any, ...]] = []
126
+ basis: list[np.ndarray] = []
127
+ rank = 0
128
+ while queue:
129
+ word = queue.popleft()
130
+ candidate = self.future_vector(word)
131
+ matrix = np.vstack([*basis, candidate]) if basis else np.asarray([candidate])
132
+ new_rank = np.linalg.matrix_rank(matrix)
133
+ if new_rank <= rank:
134
+ continue
135
+ rank = int(new_rank)
136
+ basis.append(candidate)
137
+ words.append(word)
138
+ for symbol in self.alphabet:
139
+ queue.append((symbol,) + word)
140
+
141
+ self._future_words = _sorted_words(words)
142
+ return list(self._future_words)
143
+
144
+ def history_word_list(self) -> list[tuple[Any, ...]]:
145
+ if self._history_words is not None:
146
+ return list(self._history_words)
147
+
148
+ queue: deque[tuple[Any, ...]] = deque([()])
149
+ words: list[tuple[Any, ...]] = []
150
+ basis: list[np.ndarray] = []
151
+ rank = 0
152
+ while queue:
153
+ word = queue.popleft()
154
+ candidate = self.history_vector(word)
155
+ matrix = np.vstack([*basis, candidate]) if basis else np.asarray([candidate])
156
+ new_rank = np.linalg.matrix_rank(matrix)
157
+ if new_rank <= rank:
158
+ continue
159
+ rank = int(new_rank)
160
+ basis.append(candidate)
161
+ words.append(word)
162
+ for symbol in self.alphabet:
163
+ queue.append(word + (symbol,))
164
+
165
+ self._history_words = _sorted_words(words)
166
+ return list(self._history_words)
167
+
168
+ def future_matrix(self, words: Sequence[tuple[Any, ...]]) -> np.ndarray:
169
+ if not words:
170
+ return np.zeros((self.dimension, 0), dtype=float)
171
+ return np.column_stack([self.future_vector(word) for word in words])
172
+
173
+ def history_matrix(self, words: Sequence[tuple[Any, ...]]) -> np.ndarray:
174
+ if not words:
175
+ return np.zeros((0, self.dimension), dtype=float)
176
+ return np.vstack([self.history_vector(word) for word in words])
177
+
178
+
179
+ def _compare_future_probabilities(
180
+ hf1: _HistoryFutureWordList,
181
+ hf2: _HistoryFutureWordList,
182
+ future_words: Sequence[tuple[Any, ...]],
183
+ *,
184
+ rtol: float,
185
+ atol: float,
186
+ ) -> bool:
187
+ probs1 = hf1.start @ hf1.future_matrix(future_words)
188
+ probs2 = hf2.start @ hf2.future_matrix(future_words)
189
+ return bool(np.allclose(probs1, probs2, rtol=rtol, atol=atol))
190
+
191
+
192
+ def _compare_conditional_tables(
193
+ hf1: _HistoryFutureWordList,
194
+ hf2: _HistoryFutureWordList,
195
+ history_words: Sequence[tuple[Any, ...]],
196
+ future_words: Sequence[tuple[Any, ...]],
197
+ *,
198
+ rtol: float,
199
+ atol: float,
200
+ ) -> bool:
201
+ table1 = hf1.history_matrix(history_words) @ hf1.future_matrix(future_words)
202
+ table2 = hf2.history_matrix(history_words) @ hf2.future_matrix(future_words)
203
+ return bool(np.allclose(table1, table2, rtol=rtol, atol=atol))
204
+
205
+
206
+ def _sorted_words(words: Sequence[tuple[Any, ...]] | set[tuple[Any, ...]]) -> list[tuple[Any, ...]]:
207
+ return sorted(words, key=lambda word: (len(word), tuple(repr(symbol) for symbol in word)))
@@ -0,0 +1,74 @@
1
+ """Matrix operations for quasi-stochastic generators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from sofic.exceptions import QuasiStochasticValidationError
11
+ from sofic.generators.base import QuasiStochasticModel
12
+ from sofic.graph import ATTR_EMISSION, ATTR_QUASIPROB
13
+
14
+
15
+ def transition_matrices(model: QuasiStochasticModel) -> dict[Any, np.ndarray]:
16
+ idx = model.reindex()
17
+ n = len(idx)
18
+ matrices: dict[Any, np.ndarray] = {}
19
+ for transition in model.transitions():
20
+ emission = transition.data.get(ATTR_EMISSION)
21
+ if emission is None:
22
+ continue
23
+ matrix = matrices.setdefault(emission, np.zeros((n, n), dtype=float))
24
+ i = idx.index(transition.source)
25
+ j = idx.index(transition.target)
26
+ matrix[i, j] += float(transition.data.get(ATTR_QUASIPROB, 0.0))
27
+ return matrices
28
+
29
+
30
+ def stationary_quasidistribution(model: QuasiStochasticModel) -> np.ndarray:
31
+ idx = model.reindex()
32
+ n = len(idx)
33
+ if n == 0:
34
+ return np.array([], dtype=float)
35
+
36
+ combined = np.zeros((n, n), dtype=float)
37
+ for matrix in transition_matrices(model).values():
38
+ combined += matrix
39
+
40
+ distribution = np.zeros(n, dtype=float)
41
+ for state, mass in model.initial_quasidistribution.items():
42
+ distribution[idx.index(state)] = float(mass)
43
+
44
+ if distribution.sum() <= 0.0:
45
+ distribution = np.full(n, 1.0 / n, dtype=float)
46
+
47
+ for _ in range(10_000):
48
+ updated = distribution @ combined
49
+ if np.allclose(updated, distribution, rtol=1e-10, atol=1e-12):
50
+ distribution = updated
51
+ break
52
+ distribution = updated
53
+
54
+ total = distribution.sum()
55
+ if abs(total) <= 0.0:
56
+ raise QuasiStochasticValidationError("failed to compute stationary quasidistribution")
57
+ return distribution / total
58
+
59
+
60
+ def word_probability(model: QuasiStochasticModel, word: Sequence[Any]) -> float:
61
+ idx = model.reindex()
62
+ n = len(idx)
63
+ pi = np.zeros(n, dtype=float)
64
+ for state, mass in model.initial_quasidistribution.items():
65
+ pi[idx.index(state)] = float(mass)
66
+ ones = np.ones(n, dtype=float)
67
+ matrices = transition_matrices(model)
68
+ result = pi
69
+ for symbol in word:
70
+ matrix = matrices.get(symbol)
71
+ if matrix is None:
72
+ return 0.0
73
+ result = result @ matrix
74
+ return float(result @ ones)