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,182 @@
1
+ """Generator presentation conversions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+
10
+ if TYPE_CHECKING:
11
+ from sofic.generators.synchronization import TopologicalUnifilarGraph
12
+
13
+ from sofic.automata.dfa import DFA
14
+ from sofic.automata.nfa import NFA
15
+ from sofic.generators.base import HiddenMarkovModel
16
+ from sofic.generators.mealy import MealyHMM
17
+ from sofic.generators.moore import MooreHMM
18
+ from sofic.generators.nmachine import NMachine
19
+ from sofic.generators.pfa import ProbabilisticFiniteAutomaton
20
+ from sofic.generators.quasi_realization import QuasiRealization
21
+ from sofic.graph import (
22
+ ATTR_EMISSION,
23
+ ATTR_EMISSION_DIST,
24
+ ATTR_PROB,
25
+ ATTR_QUASIPROB,
26
+ ATTR_SYMBOL,
27
+ EPSILON,
28
+ TransitionGraph,
29
+ )
30
+ from sofic.shifts.sofic import SoficShift
31
+ from sofic.states import sequential_labels
32
+
33
+
34
+ def moore_to_mealy(moore: MooreHMM) -> MealyHMM:
35
+ """Convert Moore HMM to Mealy with joint P(q', o | q) on departure state."""
36
+ graph = TransitionGraph()
37
+ for state in moore.states():
38
+ graph.add_state(state)
39
+ for transition in moore.transitions():
40
+ source = transition.source
41
+ target = transition.target
42
+ trans_prob = float(transition.data.get(ATTR_PROB, 0.0))
43
+ emission_dist = moore.graph.state_attrs(source).get(ATTR_EMISSION_DIST, {})
44
+ for emission, emit_prob in emission_dist.items():
45
+ joint = trans_prob * float(emit_prob)
46
+ if joint > 0.0:
47
+ graph.add_transition(
48
+ source,
49
+ target,
50
+ **{ATTR_PROB: joint, ATTR_EMISSION: emission},
51
+ )
52
+ return MealyHMM(
53
+ graph=graph,
54
+ initial_distribution=moore.initial_distribution,
55
+ observation_alphabet=moore.observation_alphabet,
56
+ )
57
+
58
+
59
+ def pfa_to_mealy(pfa: ProbabilisticFiniteAutomaton) -> MealyHMM:
60
+ graph = pfa.graph.copy()
61
+ return MealyHMM(
62
+ graph=graph,
63
+ initial_distribution=pfa.initial_distribution,
64
+ observation_alphabet=frozenset(pfa.output_alphabet),
65
+ )
66
+
67
+
68
+ def hmm_to_sofic_shift(hmm: HiddenMarkovModel) -> SoficShift:
69
+ """Strip probabilities from an HMM and keep its labeled support."""
70
+ support = _mealy_support(hmm)
71
+ graph = _support_graph(support, edge_attr=ATTR_SYMBOL)
72
+ return SoficShift(graph=graph, symbol_alphabet=support.observation_alphabet)
73
+
74
+
75
+ def hmm_to_support_nfa(hmm: HiddenMarkovModel) -> NFA:
76
+ """Build an NFA whose language is the finite-word support of an HMM."""
77
+ support = _mealy_support(hmm)
78
+ graph = _support_graph(support, edge_attr=ATTR_SYMBOL)
79
+ states = frozenset(support.states())
80
+ start = _fresh_start_state(states)
81
+ graph.add_state(start)
82
+ for state in states:
83
+ graph.add_transition(start, state, **{ATTR_SYMBOL: EPSILON})
84
+ return NFA(
85
+ graph=graph,
86
+ input_alphabet=support.observation_alphabet,
87
+ initial_states=frozenset({start}),
88
+ accepting_states=states,
89
+ )
90
+
91
+
92
+ def hmm_to_support_dfa(hmm: HiddenMarkovModel) -> DFA:
93
+ """Determinize the HMM support NFA from the all-states subset."""
94
+ support = _mealy_support(hmm)
95
+ states = frozenset(support.states())
96
+ nfa = hmm_to_support_nfa(support)
97
+ nfa.initial_states = states
98
+
99
+ dfa = nfa.determinize(alphabet=support.observation_alphabet)
100
+ empty_subset = frozenset()
101
+ if dfa.graph.has_state(empty_subset):
102
+ dfa.graph.nx.remove_node(empty_subset)
103
+ dfa.accepting_states = dfa.graph.terminal_recurrent_states()
104
+ return dfa
105
+
106
+
107
+ def _mealy_support(hmm: HiddenMarkovModel) -> MealyHMM:
108
+ return hmm.to_mealy()
109
+
110
+
111
+ def _support_graph(hmm: MealyHMM, *, edge_attr: str) -> TransitionGraph:
112
+ graph = TransitionGraph()
113
+ for state in hmm.states():
114
+ graph.add_state(state)
115
+ for transition in hmm.transitions():
116
+ prob = float(transition.data.get(ATTR_PROB, 0.0))
117
+ emission = transition.data.get(ATTR_EMISSION)
118
+ if prob <= 0.0 or emission is None:
119
+ continue
120
+ graph.add_transition(transition.source, transition.target, **{edge_attr: emission})
121
+ return graph
122
+
123
+
124
+ def _fresh_start_state(states: frozenset[Hashable]) -> Hashable:
125
+ start: Hashable = ("__sofic_hmm_start__",)
126
+ suffix = 0
127
+ while start in states:
128
+ suffix += 1
129
+ start = ("__sofic_hmm_start__", suffix)
130
+ return start
131
+
132
+
133
+ def quasi_realization_from_nmachine(nm: NMachine) -> QuasiRealization:
134
+ idx = nm.reindex()
135
+ pi = np.array([nm.initial_quasidistribution.get(s, 0.0) for s in idx.states], dtype=float)
136
+ tau = np.ones(len(idx), dtype=float)
137
+ symbol_maps: dict[Any, np.ndarray] = {}
138
+ for transition in nm.transitions():
139
+ emission = transition.data.get(ATTR_EMISSION)
140
+ if emission is None:
141
+ continue
142
+ matrix = symbol_maps.setdefault(emission, np.zeros((len(idx), len(idx)), dtype=float))
143
+ i = idx.index(transition.source)
144
+ j = idx.index(transition.target)
145
+ matrix[i, j] += float(transition.data.get(ATTR_QUASIPROB, 0.0))
146
+ return QuasiRealization(pi=pi, tau=tau, symbol_maps=symbol_maps)
147
+
148
+
149
+ def nmachine_from_quasi_realization(
150
+ qr: QuasiRealization, observation_alphabet: frozenset[Any] | None = None
151
+ ) -> NMachine:
152
+ states = sequential_labels(len(qr.pi))
153
+ graph = TransitionGraph()
154
+ for state in states:
155
+ graph.add_state(state)
156
+ alphabet = observation_alphabet if observation_alphabet is not None else frozenset(qr.symbol_maps)
157
+ for symbol, matrix in qr.symbol_maps.items():
158
+ for i in range(matrix.shape[0]):
159
+ for j in range(matrix.shape[1]):
160
+ weight = float(matrix[i, j])
161
+ if weight != 0.0:
162
+ graph.add_transition(states[i], states[j], **{ATTR_QUASIPROB: weight, ATTR_EMISSION: symbol})
163
+ initial = {states[i]: float(qr.pi[i]) for i in range(len(qr.pi))}
164
+ return NMachine(
165
+ graph=graph,
166
+ initial_quasidistribution=initial,
167
+ observation_alphabet=alphabet,
168
+ )
169
+
170
+
171
+ def hmm_to_edge_machine(hmm: MealyHMM | MooreHMM, iterations: int = 1, style: int = 0) -> MealyHMM:
172
+ """Convert an HMM to its edge (generator) presentation."""
173
+ from sofic.generators.edge_machine import hmm_to_edge_machine as _build
174
+
175
+ return _build(hmm, iterations=iterations, style=style)
176
+
177
+
178
+ def epsilon_machine_to_unifilar_graph(eps: MealyHMM) -> TopologicalUnifilarGraph:
179
+ """Strip emission-labeled transitions to a topological unifilar graph."""
180
+ from sofic.generators.synchronization import graph_from_epsilon_machine
181
+
182
+ return graph_from_epsilon_machine(eps)
@@ -0,0 +1,245 @@
1
+ """Directional information flow on known stochastic generators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from sofic.generators.base import HiddenMarkovModel, StochasticModel
11
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB
12
+
13
+
14
+ def _require_dit():
15
+ from sofic.generators.measures import require_dit
16
+
17
+ return require_dit("directional flow")
18
+
19
+
20
+ def _pair_block_distribution(generator: HiddenMarkovModel, *, history: int) -> Any:
21
+ """Joint law over flattened ``(x0, y0, x1, y1, ...)`` windows."""
22
+ from sofic.generators.hmm_inference import _stationary_emission_tensors
23
+
24
+ dit = _require_dit()
25
+ # Directional-flow statistics describe the stationary joint process, so weight
26
+ # the initial state by the stationary law rather than ``initial_distribution``.
27
+ pi, joint = _stationary_emission_tensors(generator)
28
+
29
+ block_length = history + 1
30
+ ones = np.ones(len(pi), dtype=float)
31
+ outcomes: list[tuple[Any, ...]] = []
32
+ probs: list[float] = []
33
+
34
+ def walk(
35
+ mass: np.ndarray,
36
+ prefix: tuple[Any, ...],
37
+ steps_remaining: int,
38
+ ) -> None:
39
+ if steps_remaining == 0:
40
+ outcomes.append(prefix)
41
+ probs.append(float(mass @ ones))
42
+ return
43
+ for symbol, matrix in joint.items():
44
+ if not isinstance(symbol, tuple) or len(symbol) != 2:
45
+ raise TypeError("generator must emit length-2 tuple symbols")
46
+ next_mass = mass @ matrix
47
+ if next_mass.sum() <= 0.0:
48
+ continue
49
+ walk(next_mass, prefix + (symbol[0], symbol[1]), steps_remaining - 1)
50
+
51
+ walk(pi.copy(), (), block_length)
52
+ total = sum(probs)
53
+ if total > 0.0:
54
+ probs = [p / total for p in probs]
55
+ return dit.Distribution(outcomes, probs)
56
+
57
+
58
+ def _window_distribution(
59
+ generator: HiddenMarkovModel,
60
+ *,
61
+ history: int,
62
+ ) -> Any:
63
+ if history < 0:
64
+ raise ValueError("history must be nonnegative")
65
+ return _pair_block_distribution(generator, history=history)
66
+
67
+
68
+ def _rv_indices(history: int) -> dict[str, list[int]]:
69
+ """Map window variables to flattened distribution indices."""
70
+ names: dict[str, list[int]] = {}
71
+ for t in range(history + 1):
72
+ names[f"x_{t}"] = [2 * t]
73
+ names[f"y_{t}"] = [2 * t + 1]
74
+ names["x_past"] = [idx for t in range(history) for idx in names[f"x_{t}"]]
75
+ names["y_past"] = [idx for t in range(history) for idx in names[f"y_{t}"]]
76
+ names["x_pres"] = names[f"x_{history}"]
77
+ names["y_pres"] = names[f"y_{history}"]
78
+ return names
79
+
80
+
81
+ def transfer_entropy(
82
+ generator: HiddenMarkovModel,
83
+ *,
84
+ source: str = "x",
85
+ target: str = "y",
86
+ history: int = 1,
87
+ ) -> float:
88
+ """One-step transfer entropy ``I(Y_t; X_{past} | Y_{past})`` on tuple emissions.
89
+
90
+ The generator must emit ``(x, y)`` pairs at each step. ``source`` and
91
+ ``target`` must be ``'x'`` or ``'y'``.
92
+ """
93
+ if source not in {"x", "y"} or target not in {"x", "y"}:
94
+ raise ValueError("source and target must be 'x' or 'y'")
95
+ _require_dit()
96
+ from dit.multivariate import total_correlation as I
97
+
98
+ dist = _window_distribution(generator, history=history)
99
+ idx = _rv_indices(history)
100
+ source_past = idx[f"{source}_past"]
101
+ target_past = idx[f"{target}_past"]
102
+ target_pres = idx[f"{target}_pres"]
103
+ return float(I(dist, [target_pres, source_past], target_past))
104
+
105
+
106
+ def directed_information(
107
+ generator: HiddenMarkovModel,
108
+ *,
109
+ source: str = "x",
110
+ target: str = "y",
111
+ length: int = 1,
112
+ ) -> float:
113
+ """Finite-length directed information ``I(X^{n} -> Y^{n})`` on tuple emissions."""
114
+ if length < 1:
115
+ raise ValueError("length must be positive")
116
+ _require_dit()
117
+ from dit.multivariate import total_correlation as I
118
+
119
+ total = 0.0
120
+ for t in range(length):
121
+ dist = _window_distribution(generator, history=t)
122
+ idx = _rv_indices(t)
123
+ source_block = [index for k in range(t + 1) for index in idx[f"{source}_{k}"]]
124
+ target_pres = idx[f"{target}_{t}"]
125
+ target_past = idx[f"{target}_past"] if t > 0 else []
126
+ total += float(I(dist, [source_block, target_pres], target_past))
127
+ return total
128
+
129
+
130
+ def intrinsic_information_flow(
131
+ generator: HiddenMarkovModel,
132
+ *,
133
+ source: str = "x",
134
+ target: str = "y",
135
+ history: int = 1,
136
+ ) -> float:
137
+ """Intrinsic information flow estimate at finite window length."""
138
+ _require_dit()
139
+ from dit.multivariate import intrinsic_total_correlation as IMI
140
+
141
+ dist = _window_distribution(generator, history=history)
142
+ idx = _rv_indices(history)
143
+ source_past = idx[f"{source}_past"]
144
+ target_past = idx[f"{target}_past"]
145
+ target_pres = idx[f"{target}_pres"]
146
+ return float(IMI(dist, [target_pres, source_past], target_past))
147
+
148
+
149
+ def shared_information_flow(
150
+ generator: HiddenMarkovModel,
151
+ *,
152
+ source: str = "x",
153
+ target: str = "y",
154
+ history: int = 1,
155
+ ) -> float:
156
+ """Shared flow: time-delayed mutual information minus intrinsic flow."""
157
+ _require_dit()
158
+ from dit.multivariate import total_correlation as I
159
+
160
+ dist = _window_distribution(generator, history=history)
161
+ idx = _rv_indices(history)
162
+ source_past = idx[f"{source}_past"]
163
+ target_pres = idx[f"{target}_pres"]
164
+ tdmi = float(I(dist, [source_past, target_pres]))
165
+ intrinsic = intrinsic_information_flow(
166
+ generator,
167
+ source=source,
168
+ target=target,
169
+ history=history,
170
+ )
171
+ return tdmi - intrinsic
172
+
173
+
174
+ def synergistic_information_flow(
175
+ generator: HiddenMarkovModel,
176
+ *,
177
+ source: str = "x",
178
+ target: str = "y",
179
+ history: int = 1,
180
+ ) -> float:
181
+ """Synergistic flow: transfer entropy minus intrinsic flow."""
182
+ te = transfer_entropy(generator, source=source, target=target, history=history)
183
+ intrinsic = intrinsic_information_flow(
184
+ generator,
185
+ source=source,
186
+ target=target,
187
+ history=history,
188
+ )
189
+ return te - intrinsic
190
+
191
+
192
+ def independent_pair_generator(
193
+ left: StochasticModel,
194
+ right: StochasticModel,
195
+ ) -> HiddenMarkovModel:
196
+ """Build an independent pair generator emitting ``(x, y)`` tuple symbols."""
197
+ from sofic.generators.mealy import MealyHMM
198
+ from sofic.graph import TransitionGraph
199
+
200
+ left_states = tuple(left.states())
201
+ right_states = tuple(right.states())
202
+ if not left_states or not right_states:
203
+ raise ValueError("both generators must have at least one state")
204
+
205
+ graph = TransitionGraph()
206
+ for left_state in left_states:
207
+ for right_state in right_states:
208
+ graph.add_state((left_state, right_state))
209
+
210
+ left_idx = {state: i for i, state in enumerate(left_states)}
211
+ right_idx = {state: i for i, state in enumerate(right_states)}
212
+ left_pi = left.stationary_distribution()
213
+ right_pi = right.stationary_distribution()
214
+ initial: dict[tuple[Hashable, Hashable], float] = {}
215
+ for left_state in left_states:
216
+ for right_state in right_states:
217
+ mass = float(left_pi[left_idx[left_state]] * right_pi[right_idx[right_state]])
218
+ if mass > 0.0:
219
+ initial[(left_state, right_state)] = mass
220
+
221
+ alphabet: set[tuple[Any, Any]] = set()
222
+ for left_state in left_states:
223
+ for transition in left.graph.out_transitions(left_state):
224
+ x = transition.data.get(ATTR_EMISSION)
225
+ prob_left = float(transition.data.get(ATTR_PROB, 0.0))
226
+ if prob_left <= 0.0 or x is None:
227
+ continue
228
+ for right_state in right_states:
229
+ for transition_r in right.graph.out_transitions(right_state):
230
+ y = transition_r.data.get(ATTR_EMISSION)
231
+ prob_right = float(transition_r.data.get(ATTR_PROB, 0.0))
232
+ if prob_right <= 0.0 or y is None:
233
+ continue
234
+ alphabet.add((x, y))
235
+ graph.add_transition(
236
+ (left_state, right_state),
237
+ (transition.target, transition_r.target),
238
+ **{ATTR_PROB: prob_left * prob_right, ATTR_EMISSION: (x, y)},
239
+ )
240
+
241
+ return MealyHMM(
242
+ graph=graph,
243
+ initial_distribution=initial,
244
+ observation_alphabet=frozenset(alphabet),
245
+ )
@@ -0,0 +1,36 @@
1
+ """Shared helpers for edge-labeled stochastic generators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from sofic.exceptions import StochasticValidationError
8
+ from sofic.generators.prob import is_symbolic, row_sums_to_one
9
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB
10
+
11
+
12
+ def validate_stochastic_edge_emissions(
13
+ model: Any,
14
+ *,
15
+ alphabet: frozenset[Any],
16
+ alphabet_name: str,
17
+ row_mass_label: str,
18
+ negative_probability_label: str,
19
+ ) -> None:
20
+ """Validate row-stochastic edge probabilities and emission alphabet membership."""
21
+ for state in model.states():
22
+ outgoing = list(model.graph.out_transitions(state))
23
+ probs = [t.data.get(ATTR_PROB, 0.0) for t in outgoing]
24
+ if outgoing and not row_sums_to_one(probs):
25
+ total = sum(probs)
26
+ raise StochasticValidationError(f"{row_mass_label} from {state!r} sum to {total}")
27
+ for transition in outgoing:
28
+ prob = transition.data.get(ATTR_PROB, 0.0)
29
+ if is_symbolic(prob):
30
+ if getattr(prob, "is_negative", None) is True:
31
+ raise StochasticValidationError(f"{negative_probability_label} on {transition}")
32
+ elif prob < 0:
33
+ raise StochasticValidationError(f"{negative_probability_label} on {transition}")
34
+ emission = transition.data.get(ATTR_EMISSION)
35
+ if emission is not None:
36
+ model._require(emission in alphabet, f"emission {emission!r} not in {alphabet_name} alphabet")
@@ -0,0 +1,178 @@
1
+ """Edge-machine (generator) presentation of an HMM.
2
+
3
+ States index labeled transition paths of the source HMM. By default, the
4
+ emitted symbol on a step is pulled from the destination edge state, matching
5
+ cmpy's edge-machine construction.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from typing import Any
12
+
13
+ from sofic.generators.base import HiddenMarkovModel
14
+ from sofic.generators.mealy import MealyHMM
15
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB, TransitionGraph
16
+
17
+ EdgeState = tuple[Any, ...]
18
+
19
+ ATTR_EDGE_SOURCE = "edge_source"
20
+ ATTR_EDGE_TARGET = "edge_target"
21
+ _ATTR_EDGE_SOURCE = ATTR_EDGE_SOURCE
22
+ _ATTR_EDGE_TARGET = ATTR_EDGE_TARGET
23
+
24
+
25
+ def edge_state_label(edge: EdgeState) -> str:
26
+ """Stable string label for an edge-state tuple (dit-friendly state name)."""
27
+ return "\x1e".join(repr(part) for part in edge)
28
+
29
+
30
+ def parse_edge_state_label(label: str) -> EdgeState:
31
+ """Recover an edge-state tuple from :func:`edge_state_label`."""
32
+ parts = label.split("\x1e")
33
+ if not label or not parts:
34
+ raise ValueError(f"not an edge state label: {label!r}")
35
+ return tuple(eval(part) for part in parts)
36
+
37
+
38
+ def hmm_to_edge_machine(hmm: HiddenMarkovModel, iterations: int = 1, style: int = 0) -> MealyHMM:
39
+ """Build the edge machine whose states are source-HMM transition paths.
40
+
41
+ With ``iterations=1``, each state is ``(source, emission, target)``. More
42
+ iterations extend states by sliding-path suffixes, as in cmpy. ``style=0``
43
+ uses the default PULL labels from destination states; ``style=1`` rewrites
44
+ final transition emissions to PUSH labels from source states.
45
+ """
46
+ hmm = hmm.to_mealy()
47
+ iterations = int(iterations)
48
+ if iterations == 0:
49
+ return hmm
50
+ if iterations < 0:
51
+ raise ValueError("iterations must be nonnegative")
52
+
53
+ edge_machine = _edge_machine_iteration(hmm, edge_machine=False)
54
+ for _iteration in range(iterations - 1):
55
+ edge_machine = _edge_machine_iteration(edge_machine, edge_machine=True)
56
+
57
+ if style == 1:
58
+ _push_transition_labels(edge_machine)
59
+ return edge_machine
60
+
61
+
62
+ def _edge_machine_iteration(machine: MealyHMM, *, edge_machine: bool) -> MealyHMM:
63
+ edge_states, edge_probs, states_by_source = _edge_states(machine, edge_machine=edge_machine)
64
+ if not edge_states:
65
+ raise ValueError("HMM has no labeled transitions")
66
+
67
+ graph = _edge_transition_graph(edge_states, edge_probs, states_by_source, edge_machine=edge_machine)
68
+ initial = _edge_initial_distribution(machine, edge_states, edge_probs, edge_machine=edge_machine)
69
+ return MealyHMM(
70
+ graph=graph,
71
+ initial_distribution=initial,
72
+ observation_alphabet=machine.observation_alphabet,
73
+ )
74
+
75
+
76
+ def _edge_states(
77
+ machine: MealyHMM,
78
+ *,
79
+ edge_machine: bool,
80
+ ) -> tuple[list[EdgeState], dict[EdgeState, float], dict[Any, list[EdgeState]]]:
81
+ edge_states: list[EdgeState] = []
82
+ edge_probs: dict[EdgeState, float] = defaultdict(float)
83
+ states_by_source: dict[Any, list[EdgeState]] = defaultdict(list)
84
+ seen: set[EdgeState] = set()
85
+
86
+ for transition in machine.transitions():
87
+ emission = transition.data.get(ATTR_EMISSION)
88
+ if emission is None:
89
+ continue
90
+ state = _state_for_transition(
91
+ transition.source,
92
+ transition.target,
93
+ emission,
94
+ edge_machine=edge_machine,
95
+ )
96
+ if state not in seen:
97
+ seen.add(state)
98
+ edge_states.append(state)
99
+ states_by_source[_state_source(state, edge_machine=edge_machine)].append(state)
100
+ edge_probs[state] += float(transition.data.get(ATTR_PROB, 0.0))
101
+
102
+ return edge_states, dict(edge_probs), dict(states_by_source)
103
+
104
+
105
+ def _state_for_transition(source: Any, target: Any, emission: Any, *, edge_machine: bool) -> EdgeState:
106
+ if edge_machine:
107
+ if not isinstance(source, tuple) or not isinstance(target, tuple):
108
+ raise TypeError("iterated edge-machine states must be tuples")
109
+ return source + target[-2:]
110
+ return (source, emission, target)
111
+
112
+
113
+ def _state_source(state: EdgeState, *, edge_machine: bool) -> Any:
114
+ return state[:-2] if edge_machine else state[0]
115
+
116
+
117
+ def _state_target(state: EdgeState, *, edge_machine: bool) -> Any:
118
+ return state[2:] if edge_machine else state[2]
119
+
120
+
121
+ def _edge_transition_graph(
122
+ edge_states: list[EdgeState],
123
+ edge_probs: dict[EdgeState, float],
124
+ states_by_source: dict[Any, list[EdgeState]],
125
+ *,
126
+ edge_machine: bool,
127
+ ) -> TransitionGraph:
128
+ graph = TransitionGraph()
129
+ for edge in edge_states:
130
+ graph.add_state(
131
+ edge,
132
+ **_edge_state_attrs(edge),
133
+ )
134
+
135
+ for edge_from in edge_states:
136
+ target_state = _state_target(edge_from, edge_machine=edge_machine)
137
+ for edge_to in states_by_source.get(target_state, []):
138
+ graph.add_transition(
139
+ edge_from,
140
+ edge_to,
141
+ **{
142
+ ATTR_PROB: edge_probs[edge_to],
143
+ ATTR_EMISSION: edge_to[-2],
144
+ },
145
+ )
146
+ return graph
147
+
148
+
149
+ def _edge_state_attrs(edge: EdgeState) -> dict[str, Any]:
150
+ if len(edge) != 3:
151
+ return {}
152
+ return {_ATTR_EDGE_SOURCE: edge[0], ATTR_EMISSION: edge[1], _ATTR_EDGE_TARGET: edge[2]}
153
+
154
+
155
+ def _edge_initial_distribution(
156
+ machine: MealyHMM,
157
+ edge_states: list[EdgeState],
158
+ edge_probs: dict[EdgeState, float],
159
+ *,
160
+ edge_machine: bool,
161
+ ) -> dict[EdgeState, float]:
162
+ idx = machine.reindex()
163
+ pi = machine.stationary_distribution()
164
+ initial: dict[EdgeState, float] = defaultdict(float)
165
+ for edge in edge_states:
166
+ source = _state_source(edge, edge_machine=edge_machine)
167
+ if source not in idx:
168
+ continue
169
+ initial[edge] += float(pi[idx.index(source)] * edge_probs[edge])
170
+ total = sum(initial.values())
171
+ if total <= 0.0:
172
+ raise ValueError("edge machine initial distribution is empty")
173
+ return {edge: mass / total for edge, mass in initial.items() if mass > 0.0}
174
+
175
+
176
+ def _push_transition_labels(machine: MealyHMM) -> None:
177
+ for source, _target, _key, data in machine.graph.nx.edges(keys=True, data=True):
178
+ data[ATTR_EMISSION] = source[-2]