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,97 @@
1
+ """Matrix-native quasi-realization (pi, D, tau)."""
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
+
13
+
14
+ class QuasiRealization(QuasiStochasticModel):
15
+ """GPT quasi-realization quadruple (pi, D, tau)."""
16
+
17
+ pi: np.ndarray
18
+ tau: np.ndarray
19
+ symbol_maps: dict[Any, np.ndarray]
20
+
21
+ def __init__(
22
+ self,
23
+ pi: np.ndarray,
24
+ tau: np.ndarray,
25
+ symbol_maps: dict[Any, np.ndarray],
26
+ **kwargs: Any,
27
+ ) -> None:
28
+ super().__init__(**kwargs)
29
+ self.pi = np.asarray(pi, dtype=float)
30
+ self.tau = np.asarray(tau, dtype=float)
31
+ self.symbol_maps = {k: np.asarray(v, dtype=float) for k, v in symbol_maps.items()}
32
+
33
+ def validate_quasistochastic(self) -> None:
34
+ if not np.isclose(self.pi.sum(), 1.0):
35
+ raise QuasiStochasticValidationError(f"pi sums to {self.pi.sum()}, not 1")
36
+
37
+ def transition_matrices(self) -> dict[Any, np.ndarray]:
38
+ return dict(self.symbol_maps)
39
+
40
+ def stationary_quasidistribution(self) -> np.ndarray:
41
+ if self.pi.size == 0:
42
+ return np.array([], dtype=float)
43
+ combined = np.zeros((self.pi.size, self.pi.size), dtype=float)
44
+ for matrix in self.symbol_maps.values():
45
+ combined += matrix
46
+ return _stationary_left_quasivector(combined)
47
+
48
+ def word_probability(self, word: Sequence[Any]) -> float:
49
+ result = self.pi.copy()
50
+ for symbol in word:
51
+ matrix = self.symbol_maps.get(symbol)
52
+ if matrix is None:
53
+ return 0.0
54
+ result = result @ matrix
55
+ return float(result @ self.tau)
56
+
57
+ @classmethod
58
+ def from_nmachine(cls, nm: Any) -> QuasiRealization:
59
+ from sofic.generators.conversions import quasi_realization_from_nmachine
60
+
61
+ return quasi_realization_from_nmachine(nm)
62
+
63
+ def to_nmachine(self) -> Any:
64
+ from sofic.generators.conversions import nmachine_from_quasi_realization
65
+
66
+ return nmachine_from_quasi_realization(self)
67
+
68
+
69
+ def _stationary_left_quasivector(transition: np.ndarray) -> np.ndarray:
70
+ matrix = np.asarray(transition, dtype=float)
71
+ n = matrix.shape[0]
72
+ if matrix.shape != (n, n):
73
+ raise ValueError("transition matrix must be square")
74
+
75
+ eigenvalues, eigenvectors = np.linalg.eig(matrix.T)
76
+ candidates = sorted(range(n), key=lambda i: abs(eigenvalues[i] - 1.0))
77
+ for index in candidates:
78
+ if not np.isclose(eigenvalues[index], 1.0, rtol=1e-9, atol=1e-10):
79
+ continue
80
+ vector = np.real_if_close(eigenvectors[:, index], tol=1000)
81
+ if np.iscomplexobj(vector):
82
+ continue
83
+ distribution = np.asarray(vector, dtype=float)
84
+ total = float(distribution.sum())
85
+ if np.isclose(total, 0.0, atol=1e-12):
86
+ continue
87
+ distribution = distribution / total
88
+ if np.allclose(distribution @ matrix, distribution, rtol=1e-8, atol=1e-10):
89
+ return distribution
90
+
91
+ augmented = np.vstack([matrix.T - np.eye(n), np.ones(n)])
92
+ target = np.zeros(n + 1, dtype=float)
93
+ target[-1] = 1.0
94
+ solution, *_ = np.linalg.lstsq(augmented, target, rcond=None)
95
+ if not np.allclose(solution @ matrix, solution, rtol=1e-8, atol=1e-10):
96
+ raise QuasiStochasticValidationError("failed to compute an invariant stationary quasidistribution")
97
+ return solution
@@ -0,0 +1,66 @@
1
+ """Time-reversal helpers for stochastic generators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypeVar
6
+
7
+ from sofic.base import StateMachine
8
+ from sofic.generators.prob import (
9
+ as_prob,
10
+ has_symbolic,
11
+ is_positive_mass,
12
+ is_zero,
13
+ simplify_prob,
14
+ )
15
+ from sofic.graph import ATTR_EMISSION, ATTR_EMISSION_DIST, ATTR_PROB, TransitionGraph
16
+
17
+ S = TypeVar("S", bound=StateMachine)
18
+
19
+
20
+ def is_markov_like(model: StateMachine) -> bool:
21
+ """Return whether ``model`` has only Markov transition probabilities on edges."""
22
+ for state in model.states():
23
+ if model.graph.state_attrs(state).get(ATTR_EMISSION_DIST) is not None:
24
+ return False
25
+ for transition in model.transitions():
26
+ data = transition.data
27
+ if ATTR_EMISSION in data or ATTR_EMISSION_DIST in data:
28
+ return False
29
+ if ATTR_PROB not in data:
30
+ return False
31
+ return True
32
+
33
+
34
+ def time_reverse_stochastic(model: S) -> S: # noqa: UP047 - keep Python 3.11 compatibility.
35
+ """Build the time-reversed chain using the forward stationary distribution."""
36
+ pi = model.stationary_distribution()
37
+ idx = model.reindex()
38
+ rev = model.copy()
39
+ rev.graph = TransitionGraph()
40
+ for state in idx.states:
41
+ rev.graph.add_state(state)
42
+
43
+ symbolic = pi.dtype == object or has_symbolic(pi.ravel())
44
+ for source in idx.states:
45
+ i = idx.index(source)
46
+ for transition in model.graph.out_transitions(source):
47
+ target = transition.target
48
+ j = idx.index(target)
49
+ prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
50
+ if not is_positive_mass(prob) or is_zero(pi[j]):
51
+ continue
52
+ if symbolic or has_symbolic([prob]):
53
+ rev_prob = simplify_prob(as_prob(pi[i]) * as_prob(prob) / as_prob(pi[j]))
54
+ else:
55
+ rev_prob = float(pi[i] * float(prob) / float(pi[j]))
56
+ attrs: dict[str, Any] = {ATTR_PROB: as_prob(rev_prob)}
57
+ if ATTR_EMISSION in transition.data:
58
+ attrs[ATTR_EMISSION] = transition.data[ATTR_EMISSION]
59
+ rev.graph.add_transition(target, source, **attrs)
60
+
61
+ if hasattr(rev, "initial_distribution"):
62
+ if symbolic:
63
+ rev.initial_distribution = {idx.state(i): as_prob(pi[i]) for i in range(len(idx))}
64
+ else:
65
+ rev.initial_distribution = {idx.state(i): float(pi[i]) for i in range(len(idx))}
66
+ return rev
@@ -0,0 +1,426 @@
1
+ """Hidden Markov generators with visibly pushdown stack state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Mapping, Sequence
6
+ from typing import Any, Literal
7
+
8
+ import numpy as np
9
+
10
+ from sofic.exceptions import StochasticValidationError
11
+ from sofic.generators.base import StochasticModel
12
+ from sofic.generators.stationary import stationary_distribution_from_transition
13
+ from sofic.graph import (
14
+ ATTR_KIND,
15
+ ATTR_PROB,
16
+ ATTR_SYMBOL,
17
+ KIND_CALL,
18
+ KIND_INTERNAL,
19
+ KIND_RETURN,
20
+ Transition,
21
+ TransitionGraph,
22
+ )
23
+ from sofic.shifts.sofic_dyck import MatchedEdge, SoficDyckShift, TransitionRef, transition_ref
24
+
25
+ Configuration = tuple[Hashable, tuple[TransitionRef, ...]]
26
+ _KINDS = frozenset({KIND_CALL, KIND_RETURN, KIND_INTERNAL})
27
+ _TOL = 1e-15
28
+
29
+
30
+ class HiddenMarkovStackModel(StochasticModel):
31
+ """Stochastic visibly-pushdown generator over a finite control graph.
32
+
33
+ The hidden configuration is a finite control state together with an
34
+ unbounded stack of call-edge references. Outgoing edge probabilities are
35
+ interpreted as weights over the transitions enabled by the current stack.
36
+ The enabled weights are normalized at each step, so return transitions that
37
+ are disabled by the stack do not make the stochastic row invalid.
38
+ """
39
+
40
+ call_alphabet: frozenset[Any]
41
+ return_alphabet: frozenset[Any]
42
+ internal_alphabet: frozenset[Any]
43
+ symbol_alphabet: frozenset[Any]
44
+ matched_edges: frozenset[MatchedEdge]
45
+ allow_empty_stack_returns: bool
46
+
47
+ def __init__(
48
+ self,
49
+ call_alphabet: frozenset[Any] | None = None,
50
+ return_alphabet: frozenset[Any] | None = None,
51
+ internal_alphabet: frozenset[Any] | None = None,
52
+ matched_edges: set[MatchedEdge] | frozenset[MatchedEdge] | None = None,
53
+ symbol_alphabet: frozenset[Any] | None = None,
54
+ allow_empty_stack_returns: bool = True,
55
+ **kwargs: Any,
56
+ ) -> None:
57
+ self.call_alphabet = call_alphabet if call_alphabet is not None else frozenset()
58
+ self.return_alphabet = return_alphabet if return_alphabet is not None else frozenset()
59
+ self.internal_alphabet = internal_alphabet if internal_alphabet is not None else frozenset()
60
+ inferred_alphabet = self.call_alphabet | self.return_alphabet | self.internal_alphabet
61
+ self.symbol_alphabet = symbol_alphabet if symbol_alphabet is not None else inferred_alphabet
62
+ self.matched_edges = frozenset(matched_edges or frozenset())
63
+ self.allow_empty_stack_returns = allow_empty_stack_returns
64
+ super().__init__(**kwargs)
65
+
66
+ def validate_stochastic(self) -> None:
67
+ super().validate_stochastic()
68
+ role_alphabet = self.call_alphabet | self.return_alphabet | self.internal_alphabet
69
+ self._require(
70
+ len(self.call_alphabet) + len(self.return_alphabet) + len(self.internal_alphabet) == len(role_alphabet),
71
+ "call, return, and internal alphabets must be disjoint",
72
+ )
73
+ self._require(self.symbol_alphabet == role_alphabet, "symbol_alphabet must equal the visible role alphabets")
74
+
75
+ call_edges: set[TransitionRef] = set()
76
+ return_edges: set[TransitionRef] = set()
77
+ all_edges: set[TransitionRef] = set()
78
+ outgoing_mass = dict.fromkeys(self.states(), 0.0)
79
+ for transition in self.transitions():
80
+ ref = transition_ref(transition)
81
+ all_edges.add(ref)
82
+ kind = transition.data.get(ATTR_KIND)
83
+ symbol = transition.data.get(ATTR_SYMBOL)
84
+ prob = float(transition.data.get(ATTR_PROB, 0.0))
85
+ if prob < 0.0:
86
+ raise StochasticValidationError(f"negative transition probability on {transition}")
87
+ outgoing_mass[transition.source] = outgoing_mass.get(transition.source, 0.0) + prob
88
+ self._require(kind in _KINDS, f"invalid stack edge kind {kind!r}")
89
+ self._require(symbol is not None, "stack generator transitions require a symbol")
90
+ if kind == KIND_CALL:
91
+ self._require(symbol in self.call_alphabet, f"{symbol!r} not in call alphabet")
92
+ call_edges.add(ref)
93
+ elif kind == KIND_RETURN:
94
+ self._require(symbol in self.return_alphabet, f"{symbol!r} not in return alphabet")
95
+ return_edges.add(ref)
96
+ else:
97
+ self._require(symbol in self.internal_alphabet, f"{symbol!r} not in internal alphabet")
98
+
99
+ for state, mass in outgoing_mass.items():
100
+ if mass <= 0.0:
101
+ raise StochasticValidationError(f"outgoing transition mass from {state!r} is not positive")
102
+
103
+ for call_ref, return_ref in self.matched_edges:
104
+ self._require(call_ref in all_edges, f"matched call edge {call_ref!r} is missing")
105
+ self._require(return_ref in all_edges, f"matched return edge {return_ref!r} is missing")
106
+ self._require(call_ref in call_edges, f"matched edge {call_ref!r} is not a call transition")
107
+ self._require(return_ref in return_edges, f"matched edge {return_ref!r} is not a return transition")
108
+
109
+ def add_call_transition(
110
+ self,
111
+ source: Hashable,
112
+ target: Hashable,
113
+ symbol: Any,
114
+ prob: float,
115
+ **attrs: Any,
116
+ ) -> TransitionRef:
117
+ """Add a call transition and return its stable edge reference."""
118
+ data = {**attrs, ATTR_KIND: KIND_CALL, ATTR_SYMBOL: symbol, ATTR_PROB: prob}
119
+ key = self.graph.add_transition(source, target, **data)
120
+ return source, target, key
121
+
122
+ def add_return_transition(
123
+ self,
124
+ source: Hashable,
125
+ target: Hashable,
126
+ symbol: Any,
127
+ prob: float,
128
+ **attrs: Any,
129
+ ) -> TransitionRef:
130
+ """Add a return transition and return its stable edge reference."""
131
+ data = {**attrs, ATTR_KIND: KIND_RETURN, ATTR_SYMBOL: symbol, ATTR_PROB: prob}
132
+ key = self.graph.add_transition(source, target, **data)
133
+ return source, target, key
134
+
135
+ def add_internal_transition(
136
+ self,
137
+ source: Hashable,
138
+ target: Hashable,
139
+ symbol: Any,
140
+ prob: float,
141
+ **attrs: Any,
142
+ ) -> TransitionRef:
143
+ """Add an internal transition and return its stable edge reference."""
144
+ data = {**attrs, ATTR_KIND: KIND_INTERNAL, ATTR_SYMBOL: symbol, ATTR_PROB: prob}
145
+ key = self.graph.add_transition(source, target, **data)
146
+ return source, target, key
147
+
148
+ def add_matched_pair(self, call_ref: TransitionRef, return_ref: TransitionRef) -> None:
149
+ """Mark ``call_ref`` and ``return_ref`` as a legal call-return pair."""
150
+ self.matched_edges = frozenset({*self.matched_edges, (call_ref, return_ref)})
151
+
152
+ def sample(
153
+ self,
154
+ n: int,
155
+ rng: np.random.Generator | None = None,
156
+ ) -> tuple[list[Any], list[Configuration]]:
157
+ """Generate up to ``n`` symbols and the pre-emission configurations."""
158
+ if n < 0:
159
+ raise ValueError("n must be nonnegative")
160
+ generator = rng if rng is not None else np.random.default_rng()
161
+ initial_states = [state for state, mass in self.initial_distribution.items() if mass > 0.0]
162
+ if not initial_states:
163
+ return [], []
164
+ initial_probs = np.array([float(self.initial_distribution[state]) for state in initial_states], dtype=float)
165
+ state = initial_states[int(generator.choice(len(initial_states), p=initial_probs / initial_probs.sum()))]
166
+ config: Configuration = (state, ())
167
+
168
+ observations: list[Any] = []
169
+ configurations: list[Configuration] = []
170
+ for _ in range(n):
171
+ successors = self._normalized_successors(config)
172
+ if not successors:
173
+ break
174
+ probs = np.array([prob for _transition, prob, _next_config in successors], dtype=float)
175
+ transition, _prob, next_config = successors[int(generator.choice(len(successors), p=probs))]
176
+ symbol = transition.data.get(ATTR_SYMBOL)
177
+ if symbol is None:
178
+ break
179
+ configurations.append(config)
180
+ observations.append(symbol)
181
+ config = next_config
182
+ return observations, configurations
183
+
184
+ def word_probability(self, word: Sequence[Any]) -> float:
185
+ """Return the probability of emitting ``word`` from the initial law."""
186
+ word = tuple(word)
187
+ if not word:
188
+ return float(sum(self.initial_distribution.values()))
189
+ if any(symbol not in self.symbol_alphabet for symbol in word):
190
+ return 0.0
191
+
192
+ current: dict[Configuration, float] = {
193
+ (state, ()): float(prob) for state, prob in self.initial_distribution.items() if prob > _TOL
194
+ }
195
+ for symbol in word:
196
+ next_masses: dict[Configuration, float] = {}
197
+ for config, mass in current.items():
198
+ for transition, prob, next_config in self._normalized_successors(config):
199
+ if transition.data.get(ATTR_SYMBOL) != symbol:
200
+ continue
201
+ next_masses[next_config] = next_masses.get(next_config, 0.0) + mass * prob
202
+ current = {config: mass for config, mass in next_masses.items() if mass > _TOL}
203
+ if not current:
204
+ return 0.0
205
+ return float(sum(current.values()))
206
+
207
+ def words_of_length(self, length: int) -> dict[tuple[Any, ...], float]:
208
+ """Return emitted words of ``length`` and their probabilities."""
209
+ if length < 0:
210
+ raise ValueError("length must be nonnegative")
211
+ if length == 0:
212
+ total = float(sum(self.initial_distribution.values()))
213
+ return {(): total} if total > _TOL else {}
214
+
215
+ layers: dict[tuple[Any, ...], dict[Configuration, float]] = {
216
+ (): {(state, ()): float(prob) for state, prob in self.initial_distribution.items() if prob > _TOL}
217
+ }
218
+ for _ in range(length):
219
+ next_layers: dict[tuple[Any, ...], dict[Configuration, float]] = {}
220
+ for prefix, configs in layers.items():
221
+ for config, mass in configs.items():
222
+ for transition, prob, next_config in self._normalized_successors(config):
223
+ symbol = transition.data.get(ATTR_SYMBOL)
224
+ if symbol is None:
225
+ continue
226
+ next_prefix = prefix + (symbol,)
227
+ bucket = next_layers.setdefault(next_prefix, {})
228
+ bucket[next_config] = bucket.get(next_config, 0.0) + mass * prob
229
+ layers = next_layers
230
+ if not layers:
231
+ break
232
+
233
+ distribution: dict[tuple[Any, ...], float] = {}
234
+ for word, configs in layers.items():
235
+ probability = float(sum(configs.values()))
236
+ if probability > _TOL:
237
+ distribution[word] = probability
238
+ return distribution
239
+
240
+ def reachable_configurations(self, max_stack_depth: int) -> tuple[Configuration, ...]:
241
+ """Return configurations reachable from the initial law up to stack depth."""
242
+ if max_stack_depth < 0:
243
+ raise ValueError("max_stack_depth must be nonnegative")
244
+ starts = [(state, ()) for state, prob in self.initial_distribution.items() if prob > _TOL]
245
+ configurations: list[Configuration] = []
246
+ seen: set[Configuration] = set()
247
+ queue = list(starts)
248
+ for config in starts:
249
+ seen.add(config)
250
+
251
+ while queue:
252
+ config = queue.pop(0)
253
+ configurations.append(config)
254
+ for _transition, _prob, next_config in self._normalized_successors(
255
+ config,
256
+ max_stack_depth=max_stack_depth,
257
+ ):
258
+ if next_config in seen:
259
+ continue
260
+ seen.add(next_config)
261
+ queue.append(next_config)
262
+ return tuple(configurations)
263
+
264
+ def configuration_transition_matrix(self, max_stack_depth: int) -> np.ndarray:
265
+ """Return the finite-depth transition matrix over reachable configurations."""
266
+ matrix, _configs = self._configuration_transition_matrix_and_configs(max_stack_depth)
267
+ return matrix
268
+
269
+ def stationary_distribution(
270
+ self,
271
+ max_stack_depth: int,
272
+ marginal: Literal["control", "configuration"] = "control",
273
+ ) -> np.ndarray:
274
+ """Return a finite-depth stationary distribution.
275
+
276
+ ``marginal="configuration"`` returns the distribution over truncated
277
+ stack configurations. ``marginal="control"`` sums those masses over the
278
+ finite control states in this model's normal state order.
279
+ """
280
+ matrix, configs = self._configuration_transition_matrix_and_configs(max_stack_depth)
281
+ pi = stationary_distribution_from_transition(matrix)
282
+ if marginal == "configuration":
283
+ return pi
284
+ if marginal != "control":
285
+ raise ValueError("marginal must be 'control' or 'configuration'")
286
+
287
+ idx = self.reindex()
288
+ control = np.zeros(len(idx), dtype=float)
289
+ for mass, (state, _stack) in zip(pi, configs, strict=True):
290
+ control[idx.index(state)] += float(mass)
291
+ return control
292
+
293
+ def to_sofic_dyck_shift(self) -> SoficDyckShift:
294
+ """Strip probabilities and return the positive-probability Dyck support."""
295
+ graph = TransitionGraph()
296
+ for state in self.states():
297
+ graph.add_state(state, **self.graph.state_attrs(state))
298
+
299
+ edge_map: dict[TransitionRef, TransitionRef] = {}
300
+ for transition in self.transitions():
301
+ if float(transition.data.get(ATTR_PROB, 0.0)) <= 0.0:
302
+ continue
303
+ data = {key: value for key, value in transition.data.items() if key != ATTR_PROB}
304
+ key = graph.add_transition(transition.source, transition.target, **data)
305
+ edge_map[transition_ref(transition)] = (transition.source, transition.target, key)
306
+
307
+ matched_edges = frozenset(
308
+ (edge_map[call_ref], edge_map[return_ref])
309
+ for call_ref, return_ref in self.matched_edges
310
+ if call_ref in edge_map and return_ref in edge_map
311
+ )
312
+ return SoficDyckShift(
313
+ graph=graph,
314
+ call_alphabet=self.call_alphabet,
315
+ return_alphabet=self.return_alphabet,
316
+ internal_alphabet=self.internal_alphabet,
317
+ matched_edges=matched_edges,
318
+ symbol_alphabet=self.symbol_alphabet,
319
+ )
320
+
321
+ @classmethod
322
+ def from_sofic_dyck_shift(
323
+ cls,
324
+ shift: SoficDyckShift,
325
+ probabilities: Mapping[TransitionRef, float],
326
+ initial_distribution: Mapping[Hashable, float] | None = None,
327
+ **kwargs: Any,
328
+ ) -> HiddenMarkovStackModel:
329
+ """Build a stochastic stack model by assigning probabilities to a Dyck shift."""
330
+ graph = TransitionGraph()
331
+ for state in shift.states():
332
+ graph.add_state(state, **shift.graph.state_attrs(state))
333
+
334
+ edge_map: dict[TransitionRef, TransitionRef] = {}
335
+ for transition in shift.transitions():
336
+ ref = transition_ref(transition)
337
+ if ref not in probabilities:
338
+ raise ValueError(f"missing probability for transition {ref!r}")
339
+ data = dict(transition.data)
340
+ data[ATTR_PROB] = float(probabilities[ref])
341
+ key = graph.add_transition(transition.source, transition.target, **data)
342
+ edge_map[ref] = (transition.source, transition.target, key)
343
+
344
+ matched_edges = frozenset(
345
+ (edge_map[call_ref], edge_map[return_ref]) for call_ref, return_ref in shift.matched_edges
346
+ )
347
+ initial = (
348
+ dict(initial_distribution) if initial_distribution is not None else _uniform_initial_distribution(shift)
349
+ )
350
+ return cls(
351
+ graph=graph,
352
+ initial_distribution=initial,
353
+ call_alphabet=shift.call_alphabet,
354
+ return_alphabet=shift.return_alphabet,
355
+ internal_alphabet=shift.internal_alphabet,
356
+ matched_edges=matched_edges,
357
+ symbol_alphabet=shift.symbol_alphabet,
358
+ **kwargs,
359
+ )
360
+
361
+ def _configuration_transition_matrix_and_configs(
362
+ self,
363
+ max_stack_depth: int,
364
+ ) -> tuple[np.ndarray, tuple[Configuration, ...]]:
365
+ configs = self.reachable_configurations(max_stack_depth)
366
+ matrix = np.zeros((len(configs), len(configs)), dtype=float)
367
+ config_index = {config: i for i, config in enumerate(configs)}
368
+ for i, config in enumerate(configs):
369
+ successors = self._normalized_successors(config, max_stack_depth=max_stack_depth)
370
+ if not successors:
371
+ matrix[i, i] = 1.0
372
+ continue
373
+ for _transition, prob, next_config in successors:
374
+ matrix[i, config_index[next_config]] += prob
375
+ return matrix, configs
376
+
377
+ def _normalized_successors(
378
+ self,
379
+ config: Configuration,
380
+ max_stack_depth: int | None = None,
381
+ ) -> tuple[tuple[Transition, float, Configuration], ...]:
382
+ state, stack = config
383
+ weighted: list[tuple[Transition, float, Configuration]] = []
384
+ total = 0.0
385
+ for transition in self.graph.out_transitions(state):
386
+ weight = float(transition.data.get(ATTR_PROB, 0.0))
387
+ if weight <= 0.0:
388
+ continue
389
+ next_stack = self._next_stack(transition, stack, max_stack_depth=max_stack_depth)
390
+ if next_stack is None:
391
+ continue
392
+ weighted.append((transition, weight, (transition.target, next_stack)))
393
+ total += weight
394
+ if total <= 0.0:
395
+ return ()
396
+ return tuple((transition, weight / total, next_config) for transition, weight, next_config in weighted)
397
+
398
+ def _next_stack(
399
+ self,
400
+ transition: Transition,
401
+ stack: tuple[TransitionRef, ...],
402
+ max_stack_depth: int | None = None,
403
+ ) -> tuple[TransitionRef, ...] | None:
404
+ kind = transition.data.get(ATTR_KIND)
405
+ ref = transition_ref(transition)
406
+ if kind == KIND_CALL:
407
+ if max_stack_depth is not None and len(stack) >= max_stack_depth:
408
+ return None
409
+ return stack + (ref,)
410
+ if kind == KIND_RETURN:
411
+ if not stack:
412
+ return stack if self.allow_empty_stack_returns else None
413
+ if (stack[-1], ref) in self.matched_edges:
414
+ return stack[:-1]
415
+ return None
416
+ if kind == KIND_INTERNAL:
417
+ return stack
418
+ return None
419
+
420
+
421
+ def _uniform_initial_distribution(shift: SoficDyckShift) -> dict[Hashable, float]:
422
+ states = tuple(shift.states())
423
+ if not states:
424
+ return {}
425
+ probability = 1.0 / len(states)
426
+ return dict.fromkeys(states, probability)