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,201 @@
1
+ """Deterministic and weighted finite-state transducers.
2
+
3
+ The finite-automata reading of the transducer stack (Mohri, *Weighted Automata
4
+ Algorithms*, in *Handbook of Weighted Automata*, 2009; Roche & Schabes, *Finite-
5
+ State Language Processing*, 1997):
6
+
7
+ * :class:`SubsequentialTransducer` -- an input-deterministic (sequential)
8
+ transducer augmented with a per-state final-output string, the classic
9
+ subsequential transducer of Schutzenberger/Mohri.
10
+ * :class:`WeightedFiniteStateTransducer` -- a transducer whose edges carry
11
+ weights in a semiring (probability or tropical), generalizing the stochastic
12
+ Mealy machine used by the ε-transducer.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from collections import defaultdict
19
+ from collections.abc import Hashable, Sequence
20
+ from typing import Any
21
+
22
+ from sofic.automata.transducers import MealyMachine
23
+ from sofic.exceptions import SoficValidationError
24
+ from sofic.graph import ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, EPSILON
25
+
26
+ SEMIRINGS = ("probability", "tropical", "log")
27
+
28
+
29
+ class SubsequentialTransducer(MealyMachine):
30
+ """Input-deterministic transducer with a per-state final output string.
31
+
32
+ A subsequential transducer reads its input left to right along the unique
33
+ matching path and, on reaching the end of input, appends the ``final_output``
34
+ string of the state it stops in.
35
+
36
+ Examples
37
+ --------
38
+ >>> from sofic.automata.subsequential import SubsequentialTransducer
39
+ >>> t = SubsequentialTransducer(
40
+ ... input_alphabet=frozenset("ab"),
41
+ ... output_alphabet=frozenset("xy"),
42
+ ... initial_states=frozenset({"q0"}),
43
+ ... final_output={"q0": ("y",)},
44
+ ... )
45
+ >>> t.graph.add_state("q0")
46
+ >>> _ = t.add_transition("q0", "q0", "a", "x")
47
+ >>> sorted(t.transduce("aa"))
48
+ [('x', 'x', 'y')]
49
+ """
50
+
51
+ final_output: dict[Hashable, tuple[Any, ...]]
52
+
53
+ def __init__(
54
+ self,
55
+ input_alphabet: frozenset[Any] | None = None,
56
+ output_alphabet: frozenset[Any] | None = None,
57
+ initial_states: frozenset[Hashable] | None = None,
58
+ final_output: dict[Hashable, Sequence[Any]] | None = None,
59
+ **kwargs: Any,
60
+ ) -> None:
61
+ super().__init__(
62
+ input_alphabet=input_alphabet,
63
+ output_alphabet=output_alphabet,
64
+ initial_states=initial_states,
65
+ **kwargs,
66
+ )
67
+ self.final_output = {state: tuple(word) for state, word in (final_output or {}).items()}
68
+
69
+ def validate(self) -> None:
70
+ super().validate()
71
+ from sofic.properties import is_sequential_transducer
72
+
73
+ self._require(is_sequential_transducer(self), "subsequential transducer must be input-deterministic")
74
+ for state, word in self.final_output.items():
75
+ self._require(self.graph.has_state(state), f"unknown final-output state {state!r}")
76
+ for symbol in word:
77
+ if symbol is EPSILON:
78
+ continue
79
+ self._require(symbol in self.output_alphabet, f"final output {symbol!r} not in output alphabet")
80
+
81
+ def is_subsequential(self) -> bool:
82
+ """Return whether this is a valid subsequential transducer."""
83
+ from sofic.properties import is_subsequential_transducer
84
+
85
+ return is_subsequential_transducer(self)
86
+
87
+ def transduce(self, word: Sequence[Any]) -> set[tuple[Any, ...]]:
88
+ results: set[tuple[Any, ...]] = set()
89
+ for state, output in self._walk(word):
90
+ results.add(output + self.final_output.get(state, ()))
91
+ return results
92
+
93
+ def _walk(self, word: Sequence[Any]) -> set[tuple[Hashable, tuple[Any, ...]]]:
94
+ configs: set[tuple[Hashable, tuple[Any, ...]]] = {(state, ()) for state in self.initial_states}
95
+ for symbol in word:
96
+ nxt: set[tuple[Hashable, tuple[Any, ...]]] = set()
97
+ for state, output in configs:
98
+ for transition in self.graph.out_transitions(state):
99
+ if transition.data.get(ATTR_SYMBOL) != symbol:
100
+ continue
101
+ emitted = transition.data.get(ATTR_OUTPUT)
102
+ extended = output + ((emitted,) if emitted is not None and emitted is not EPSILON else ())
103
+ nxt.add((transition.target, extended))
104
+ configs = nxt
105
+ return configs
106
+
107
+
108
+ class WeightedFiniteStateTransducer(MealyMachine):
109
+ """Finite-state transducer with edge weights in a semiring.
110
+
111
+ The weight of an ``(input, output)`` pair is the semiring sum over all
112
+ matching paths of the semiring product of their edge weights. The
113
+ ``probability`` semiring uses ``(+, x, 0, 1)`` (edge weight = probability);
114
+ the ``tropical`` semiring uses ``(min, +, +inf, 0)`` (edge weight = cost),
115
+ yielding shortest-path / Viterbi weights.
116
+ """
117
+
118
+ semiring: str
119
+
120
+ def __init__(
121
+ self,
122
+ input_alphabet: frozenset[Any] | None = None,
123
+ output_alphabet: frozenset[Any] | None = None,
124
+ initial_states: frozenset[Hashable] | None = None,
125
+ semiring: str = "probability",
126
+ **kwargs: Any,
127
+ ) -> None:
128
+ super().__init__(
129
+ input_alphabet=input_alphabet,
130
+ output_alphabet=output_alphabet,
131
+ initial_states=initial_states,
132
+ **kwargs,
133
+ )
134
+ self.semiring = semiring
135
+
136
+ def validate(self) -> None:
137
+ super().validate()
138
+ if self.semiring not in SEMIRINGS:
139
+ raise SoficValidationError(f"unknown semiring {self.semiring!r}; expected one of {SEMIRINGS}")
140
+
141
+ @classmethod
142
+ def from_transducer(
143
+ cls,
144
+ transducer: MealyMachine,
145
+ *,
146
+ semiring: str = "probability",
147
+ ) -> WeightedFiniteStateTransducer:
148
+ """Build a WFST from a (probability-weighted) Mealy machine.
149
+
150
+ For the ``tropical`` and ``log`` semirings each edge probability ``p`` is
151
+ converted to the cost ``-log p``.
152
+ """
153
+ graph = transducer.graph.copy()
154
+ if semiring in {"tropical", "log"}:
155
+ for _s, _t, data in graph.nx.edges(data=True):
156
+ prob = float(data.get(ATTR_PROB, 1.0))
157
+ data[ATTR_PROB] = math.inf if prob <= 0.0 else -math.log(prob)
158
+ result = cls(
159
+ input_alphabet=frozenset(transducer.input_alphabet),
160
+ output_alphabet=frozenset(transducer.output_alphabet),
161
+ initial_states=frozenset(transducer.initial_states),
162
+ semiring=semiring,
163
+ graph=graph,
164
+ )
165
+ result.validate()
166
+ return result
167
+
168
+ def weight(self, inputs: Sequence[Any], outputs: Sequence[Any]) -> float:
169
+ """Return the total semiring weight of reading ``inputs`` emitting ``outputs``."""
170
+ if len(inputs) != len(outputs):
171
+ return self._zero()
172
+ frontier: dict[Hashable, float] = {state: self._one() for state in self.initial_states}
173
+ for input_symbol, output_symbol in zip(inputs, outputs, strict=True):
174
+ nxt: dict[Hashable, float] = defaultdict(self._zero)
175
+ for state, accumulated in frontier.items():
176
+ for transition in self.graph.out_transitions(state):
177
+ if transition.data.get(ATTR_SYMBOL) != input_symbol:
178
+ continue
179
+ if transition.data.get(ATTR_OUTPUT) != output_symbol:
180
+ continue
181
+ edge = float(transition.data.get(ATTR_PROB, self._one()))
182
+ nxt[transition.target] = self._plus(nxt[transition.target], self._times(accumulated, edge))
183
+ frontier = dict(nxt)
184
+ if not frontier:
185
+ return self._zero()
186
+ total = self._zero()
187
+ for value in frontier.values():
188
+ total = self._plus(total, value)
189
+ return total
190
+
191
+ def _one(self) -> float:
192
+ return 0.0 if self.semiring in {"tropical", "log"} else 1.0
193
+
194
+ def _zero(self) -> float:
195
+ return math.inf if self.semiring in {"tropical", "log"} else 0.0
196
+
197
+ def _times(self, left: float, right: float) -> float:
198
+ return left + right if self.semiring in {"tropical", "log"} else left * right
199
+
200
+ def _plus(self, left: float, right: float) -> float:
201
+ return min(left, right) if self.semiring in {"tropical", "log"} else left + right
@@ -0,0 +1,350 @@
1
+ """Composition and probability-aware helpers for finite-state transducers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Hashable, Iterable, Sequence
7
+ from itertools import product
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from sofic.automata.transducers import ERROR_STATE, ERROR_SYMBOL, MealyMachine
13
+ from sofic.generators.base import HiddenMarkovModel
14
+ from sofic.generators.mealy import MealyHMM
15
+ from sofic.graph import ATTR_EMISSION, ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, EPSILON
16
+
17
+
18
+ def cartesian_product_gg(
19
+ generators: Sequence[HiddenMarkovModel],
20
+ *,
21
+ create_using: type[MealyHMM] | None = None,
22
+ ) -> MealyHMM:
23
+ """Return the Cartesian product generator with tuple-valued emissions."""
24
+ if not generators:
25
+ raise ValueError("at least one generator is required")
26
+ mealy_generators = [generator.to_mealy() for generator in generators]
27
+ cls = create_using or MealyHMM
28
+
29
+ states_by_model = [tuple(generator.states()) for generator in mealy_generators]
30
+ initial: dict[tuple[Hashable, ...], float] = {}
31
+ for state_tuple in product(*states_by_model):
32
+ mass = 1.0
33
+ for generator, state in zip(mealy_generators, state_tuple, strict=True):
34
+ mass *= float(generator.initial_distribution.get(state, 0.0))
35
+ if mass:
36
+ initial[state_tuple] = mass
37
+
38
+ alphabets = [tuple(generator.observation_alphabet) for generator in mealy_generators]
39
+ observation_alphabet = frozenset(product(*alphabets)) if all(alphabets) else frozenset()
40
+ result = cls(initial_distribution=initial, observation_alphabet=observation_alphabet)
41
+ for state_tuple in product(*states_by_model):
42
+ result.graph.add_state(state_tuple)
43
+
44
+ edge_map: dict[tuple[tuple[Hashable, ...], tuple[Hashable, ...], tuple[Any, ...]], float] = defaultdict(float)
45
+ for source_tuple in product(*states_by_model):
46
+ outgoing_groups = [
47
+ list(generator.graph.out_transitions(state))
48
+ for generator, state in zip(mealy_generators, source_tuple, strict=True)
49
+ ]
50
+ for edge_tuple in product(*outgoing_groups):
51
+ target = tuple(edge.target for edge in edge_tuple)
52
+ emission = tuple(edge.data.get(ATTR_EMISSION) for edge in edge_tuple)
53
+ prob = float(np.prod([_prob(edge.data) for edge in edge_tuple]))
54
+ edge_map[(source_tuple, target, emission)] += prob
55
+
56
+ for (source, target, emission), prob in edge_map.items():
57
+ if prob:
58
+ result.graph.add_transition(source, target, **{ATTR_EMISSION: emission, ATTR_PROB: prob})
59
+ return result
60
+
61
+
62
+ def cartesian_product_tt(
63
+ transducers: Sequence[MealyMachine],
64
+ *,
65
+ create_using: type[MealyMachine] | None = None,
66
+ normalize: bool = True,
67
+ ) -> MealyMachine:
68
+ """Return the Cartesian product transducer with tuple-valued input/output symbols."""
69
+ if not transducers:
70
+ raise ValueError("at least one transducer is required")
71
+ cls = create_using or MealyMachine
72
+ states_by_model = [tuple(transducer.states()) for transducer in transducers]
73
+ initial_states = frozenset(product(*[tuple(t.initial_states) for t in transducers]))
74
+ input_alphabet = frozenset(product(*[tuple(t.alphabets()[0]) for t in transducers]))
75
+ output_alphabet = frozenset(product(*[tuple(t.alphabets()[1]) for t in transducers]))
76
+
77
+ edges: list[tuple[Hashable, Hashable, Any, Any, float]] = []
78
+ for source_tuple in product(*states_by_model):
79
+ outgoing_groups = [
80
+ list(transducer.graph.out_transitions(state))
81
+ for transducer, state in zip(transducers, source_tuple, strict=True)
82
+ ]
83
+ for edge_tuple in product(*outgoing_groups):
84
+ target = tuple(edge.target for edge in edge_tuple)
85
+ input_symbol = tuple(_input(edge.data) for edge in edge_tuple)
86
+ output_symbol = tuple(_output(edge.data) for edge in edge_tuple)
87
+ prob = float(np.prod([_prob(edge.data) for edge in edge_tuple]))
88
+ edges.append((source_tuple, target, input_symbol, output_symbol, prob))
89
+
90
+ result = _build_transducer(
91
+ edges,
92
+ cls=cls,
93
+ states=product(*states_by_model),
94
+ initial_states=initial_states,
95
+ input_alphabet=input_alphabet,
96
+ output_alphabet=output_alphabet,
97
+ )
98
+ if normalize:
99
+ _normalize_transducer_rows(result)
100
+ return result
101
+
102
+
103
+ def compose_tt(
104
+ transducers: Sequence[MealyMachine],
105
+ *,
106
+ complete: bool = True,
107
+ create_using: type[MealyMachine] | None = None,
108
+ normalize: bool = True,
109
+ ) -> MealyMachine:
110
+ """Serially compose transducers.
111
+
112
+ ``compose_tt((t0, t1))`` returns the transducer that feeds ``t0``'s output
113
+ into ``t1``. State labels are tuples ordered like the input transducers.
114
+ """
115
+ if not transducers:
116
+ raise ValueError("at least one transducer is required")
117
+ result = transducers[0].copy()
118
+ for transducer in transducers[1:]:
119
+ result = _compose_pair_tt(result, transducer, complete=complete, create_using=create_using, normalize=normalize)
120
+ return result
121
+
122
+
123
+ def compose_tg(
124
+ transducer: MealyMachine,
125
+ generator: HiddenMarkovModel,
126
+ *,
127
+ complete: bool = True,
128
+ joint: bool = True,
129
+ normalize: bool = True,
130
+ create_using: type[MealyHMM] | None = None,
131
+ ) -> MealyHMM:
132
+ """Compose a transducer with a generator.
133
+
134
+ The result is a generator on joint ``(input, output)`` emissions by default.
135
+ Set ``joint=False`` to marginalize to output symbols only.
136
+ """
137
+ gen = generator.to_mealy()
138
+ generator_alphabet = frozenset(gen.observation_alphabet)
139
+ transducer_inputs = transducer.alphabets()[0]
140
+ if generator_alphabet and transducer_inputs and not (generator_alphabet & transducer_inputs):
141
+ raise ValueError("generator outputs do not intersect transducer inputs")
142
+
143
+ work = transducer.complete(generator_alphabet, copy=True) if complete else transducer.copy()
144
+ cls = create_using or MealyHMM
145
+ t_initial = _transducer_initial_distribution(work)
146
+ initial: dict[tuple[Hashable, Hashable], float] = {}
147
+ for g_state, g_mass in gen.initial_distribution.items():
148
+ for t_state, t_mass in t_initial.items():
149
+ mass = float(g_mass) * float(t_mass)
150
+ if mass:
151
+ initial[(g_state, t_state)] = mass
152
+
153
+ states = [(g_state, t_state) for g_state in gen.states() for t_state in work.states()]
154
+ edge_map: dict[tuple[tuple[Hashable, Hashable], tuple[Hashable, Hashable], Any], float] = defaultdict(float)
155
+ for g_state, t_state in states:
156
+ source = (g_state, t_state)
157
+ for t_edge in work.graph.out_transitions(t_state):
158
+ if _input(t_edge.data) is EPSILON:
159
+ emission = _output(t_edge.data)
160
+ edge_map[(source, (g_state, t_edge.target), emission)] += _prob(t_edge.data)
161
+ for g_edge in gen.graph.out_transitions(g_state):
162
+ input_symbol = g_edge.data.get(ATTR_EMISSION)
163
+ if input_symbol is None:
164
+ continue
165
+ for t_edge in work.graph.out_transitions(t_state):
166
+ if _input(t_edge.data) != input_symbol:
167
+ continue
168
+ output_symbol = _output(t_edge.data)
169
+ emission = (input_symbol, output_symbol) if joint else output_symbol
170
+ prob = _prob(g_edge.data) * _prob(t_edge.data)
171
+ edge_map[(source, (g_edge.target, t_edge.target), emission)] += prob
172
+
173
+ observation_alphabet = frozenset(emission for _source, _target, emission in edge_map)
174
+ result = cls(initial_distribution=initial, observation_alphabet=observation_alphabet)
175
+ for state in states:
176
+ result.graph.add_state(state)
177
+ for (source, target, emission), prob in edge_map.items():
178
+ if prob:
179
+ result.graph.add_transition(source, target, **{ATTR_EMISSION: emission, ATTR_PROB: prob})
180
+ if normalize:
181
+ _normalize_hmm_rows(result)
182
+ return result
183
+
184
+
185
+ def transduce_generator(
186
+ transducer: MealyMachine,
187
+ generator: HiddenMarkovModel,
188
+ *,
189
+ complete: bool = True,
190
+ normalize: bool = True,
191
+ create_using: type[MealyHMM] | None = None,
192
+ ) -> MealyHMM:
193
+ """Return the output-only generator induced by driving ``transducer`` with ``generator``."""
194
+ return compose_tg(
195
+ transducer,
196
+ generator,
197
+ complete=complete,
198
+ joint=False,
199
+ normalize=normalize,
200
+ create_using=create_using,
201
+ )
202
+
203
+
204
+ def _compose_pair_tt(
205
+ left: MealyMachine,
206
+ right: MealyMachine,
207
+ *,
208
+ complete: bool,
209
+ create_using: type[MealyMachine] | None,
210
+ normalize: bool,
211
+ ) -> MealyMachine:
212
+ left_work = left.complete(copy=True) if complete else left.copy()
213
+ right_alphabet = right.alphabets()[0] | left_work.alphabets()[1]
214
+ right_work = right.complete(right_alphabet, copy=True) if complete else right.copy()
215
+ cls = create_using or MealyMachine
216
+
217
+ left_states = tuple(left_work.states())
218
+ right_states = tuple(right_work.states())
219
+ states = [(left_state, right_state) for left_state in left_states for right_state in right_states]
220
+ initial_states = frozenset(product(left_work.initial_states, right_work.initial_states))
221
+ input_alphabet = left_work.alphabets()[0]
222
+ output_alphabet = right_work.alphabets()[1]
223
+
224
+ edges: list[tuple[Hashable, Hashable, Any, Any, float]] = []
225
+ for left_state, right_state in states:
226
+ source = (left_state, right_state)
227
+ for right_edge in right_work.graph.out_transitions(right_state):
228
+ if _input(right_edge.data) is EPSILON:
229
+ edges.append(
230
+ (
231
+ source,
232
+ (left_state, right_edge.target),
233
+ EPSILON,
234
+ _output(right_edge.data),
235
+ _prob(right_edge.data),
236
+ )
237
+ )
238
+ for left_edge in left_work.graph.out_transitions(left_state):
239
+ left_input = _input(left_edge.data)
240
+ middle = _output(left_edge.data)
241
+ if middle is EPSILON:
242
+ edges.append((source, (left_edge.target, right_state), left_input, EPSILON, _prob(left_edge.data)))
243
+ continue
244
+ for right_edge in right_work.graph.out_transitions(right_state):
245
+ if _input(right_edge.data) != middle:
246
+ continue
247
+ edges.append(
248
+ (
249
+ source,
250
+ (left_edge.target, right_edge.target),
251
+ left_input,
252
+ _output(right_edge.data),
253
+ _prob(left_edge.data) * _prob(right_edge.data),
254
+ )
255
+ )
256
+
257
+ result = _build_transducer(
258
+ edges,
259
+ cls=cls,
260
+ states=states,
261
+ initial_states=initial_states,
262
+ input_alphabet=input_alphabet,
263
+ output_alphabet=output_alphabet,
264
+ )
265
+ if normalize:
266
+ _normalize_transducer_rows(result)
267
+ return result
268
+
269
+
270
+ def _build_transducer(
271
+ edges: Iterable[tuple[Hashable, Hashable, Any, Any, float]],
272
+ *,
273
+ cls: type[MealyMachine],
274
+ states: Iterable[Hashable],
275
+ initial_states: frozenset[Hashable],
276
+ input_alphabet: frozenset[Any],
277
+ output_alphabet: frozenset[Any],
278
+ ) -> MealyMachine:
279
+ merged: dict[tuple[Hashable, Hashable, Any, Any], float] = defaultdict(float)
280
+ all_states = list(dict.fromkeys(states))
281
+ for source, target, input_symbol, output_symbol, prob in edges:
282
+ all_states.extend([source, target])
283
+ merged[(source, target, input_symbol, output_symbol)] += float(prob)
284
+
285
+ result = cls(
286
+ input_alphabet=frozenset(symbol for symbol in input_alphabet if symbol is not EPSILON),
287
+ output_alphabet=frozenset(symbol for symbol in output_alphabet if symbol is not EPSILON),
288
+ initial_states=initial_states,
289
+ )
290
+ for state in dict.fromkeys(all_states):
291
+ result.graph.add_state(state)
292
+ for (source, target, input_symbol, output_symbol), prob in merged.items():
293
+ if prob:
294
+ result.add_transition(source, target, input_symbol, output_symbol, prob=prob)
295
+ if input_symbol is not EPSILON:
296
+ result.input_alphabet = result.input_alphabet | frozenset({input_symbol})
297
+ if output_symbol is not EPSILON:
298
+ result.output_alphabet = result.output_alphabet | frozenset({output_symbol})
299
+ return result
300
+
301
+
302
+ def _normalize_transducer_rows(transducer: MealyMachine) -> None:
303
+ totals: dict[tuple[Hashable, Any], float] = defaultdict(float)
304
+ for transition in transducer.transitions():
305
+ totals[(transition.source, _input(transition.data))] += _prob(transition.data)
306
+ for source, target, key, data in transducer.graph.nx.edges(keys=True, data=True):
307
+ total = totals[(source, data.get(ATTR_SYMBOL, EPSILON))]
308
+ if total > 0.0:
309
+ transducer.graph.nx[source][target][key][ATTR_PROB] = float(data.get(ATTR_PROB, 1.0)) / total
310
+
311
+
312
+ def _normalize_hmm_rows(hmm: MealyHMM) -> None:
313
+ totals: dict[Hashable, float] = defaultdict(float)
314
+ for transition in hmm.transitions():
315
+ totals[transition.source] += _prob(transition.data)
316
+ for source, target, key, data in hmm.graph.nx.edges(keys=True, data=True):
317
+ total = totals[source]
318
+ if total > 0.0:
319
+ hmm.graph.nx[source][target][key][ATTR_PROB] = float(data.get(ATTR_PROB, 1.0)) / total
320
+
321
+
322
+ def _transducer_initial_distribution(transducer: MealyMachine) -> dict[Hashable, float]:
323
+ states = tuple(transducer.initial_states) or tuple(transducer.states())
324
+ if not states:
325
+ return {}
326
+ mass = 1.0 / len(states)
327
+ return dict.fromkeys(states, mass)
328
+
329
+
330
+ def _input(data: dict[str, Any]) -> Any:
331
+ return data.get(ATTR_SYMBOL, EPSILON)
332
+
333
+
334
+ def _output(data: dict[str, Any]) -> Any:
335
+ return data.get(ATTR_OUTPUT, EPSILON)
336
+
337
+
338
+ def _prob(data: dict[str, Any]) -> float:
339
+ return float(data.get(ATTR_PROB, 1.0))
340
+
341
+
342
+ __all__ = [
343
+ "ERROR_STATE",
344
+ "ERROR_SYMBOL",
345
+ "cartesian_product_gg",
346
+ "cartesian_product_tt",
347
+ "compose_tg",
348
+ "compose_tt",
349
+ "transduce_generator",
350
+ ]
@@ -0,0 +1,150 @@
1
+ """Input/output simulation for Mealy and Moore transducers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Iterator, Sequence
6
+ from typing import Any
7
+
8
+ import networkx as nx
9
+
10
+ from sofic.automata._config_simulation import simulate_configs
11
+ from sofic.automata.transducers import MealyMachine, MooreMachine, Transducer
12
+ from sofic.exceptions import InfiniteTransductionError
13
+ from sofic.graph import ATTR_OUTPUT, ATTR_SYMBOL, EPSILON
14
+
15
+
16
+ def transduce_mealy(mealy: MealyMachine, word: Sequence[Any]) -> set[tuple[Any, ...]]:
17
+ """Return possible output sequences for ``word`` on a Mealy machine.
18
+
19
+ Each transition appends its ``ATTR_OUTPUT`` after the corresponding input symbol.
20
+ """
21
+ return _transduce(transducer=mealy, word=word, moore=False)
22
+
23
+
24
+ def transduce_moore(moore: MooreMachine, word: Sequence[Any]) -> set[tuple[Any, ...]]:
25
+ """Return possible output sequences for ``word`` on a Moore machine.
26
+
27
+ Outputs follow the standard Moore convention: emit the initial state's output,
28
+ then after each input symbol emit the output of the entered state (length
29
+ ``len(word) + 1`` when states carry outputs).
30
+ """
31
+ return _transduce(transducer=moore, word=word, moore=True)
32
+
33
+
34
+ def _state_output(transducer: Transducer, state: Hashable) -> tuple[Any, ...]:
35
+ out = transducer.graph.state_attrs(state).get(ATTR_OUTPUT)
36
+ return (out,) if out is not None and out is not EPSILON else ()
37
+
38
+
39
+ def _transduce(transducer: Transducer, word: Sequence[Any], *, moore: bool) -> set[tuple[Any, ...]]:
40
+ if not transducer.initial_states:
41
+ return set()
42
+
43
+ initial: set[tuple[Hashable, tuple[Any, ...]]] = set()
44
+ for start in transducer.initial_states:
45
+ prefix = _state_output(transducer, start) if moore else ()
46
+ initial.add((start, prefix))
47
+
48
+ def step(config: tuple[Hashable, tuple[Any, ...]], symbol: Any) -> Iterator[tuple[Hashable, tuple[Any, ...]]]:
49
+ state, output_prefix = config
50
+ for transition in transducer.graph.out_transitions(state):
51
+ if transition.data.get(ATTR_SYMBOL) != symbol:
52
+ continue
53
+ if moore:
54
+ extended = output_prefix + _state_output(transducer, transition.target)
55
+ else:
56
+ out = transition.data.get(ATTR_OUTPUT)
57
+ extended = output_prefix + ((out,) if out is not None and out is not EPSILON else ())
58
+ yield (transition.target, extended)
59
+
60
+ stack = simulate_configs(
61
+ initial,
62
+ word,
63
+ step,
64
+ closure=lambda configs: _epsilon_closure(transducer, configs, moore=moore),
65
+ )
66
+ return {output_prefix for _, output_prefix in stack}
67
+
68
+
69
+ def _epsilon_closure(
70
+ transducer: Transducer,
71
+ configs: set[tuple[Hashable, tuple[Any, ...]]],
72
+ *,
73
+ moore: bool,
74
+ ) -> set[tuple[Hashable, tuple[Any, ...]]]:
75
+ if not configs:
76
+ return set()
77
+ starts = {state for state, _prefix in configs}
78
+ if _has_productive_epsilon_cycle(transducer, starts, moore=moore):
79
+ raise InfiniteTransductionError("productive epsilon-input cycle gives infinitely many outputs")
80
+
81
+ closure = set(configs)
82
+ stack = list(configs)
83
+ while stack:
84
+ state, output_prefix = stack.pop()
85
+ for transition in transducer.graph.out_transitions(state):
86
+ if transition.data.get(ATTR_SYMBOL, EPSILON) is not EPSILON:
87
+ continue
88
+ if moore:
89
+ extended = output_prefix + _state_output(transducer, transition.target)
90
+ else:
91
+ out = transition.data.get(ATTR_OUTPUT)
92
+ extended = output_prefix + ((out,) if out is not None and out is not EPSILON else ())
93
+ config = (transition.target, extended)
94
+ if config not in closure:
95
+ closure.add(config)
96
+ stack.append(config)
97
+ return closure
98
+
99
+
100
+ def _has_productive_epsilon_cycle(
101
+ transducer: Transducer,
102
+ starts: set[Hashable],
103
+ *,
104
+ moore: bool,
105
+ ) -> bool:
106
+ eps_graph = nx.MultiDiGraph()
107
+ reachable: set[Hashable] = set(starts)
108
+ stack = list(starts)
109
+ while stack:
110
+ state = stack.pop()
111
+ eps_graph.add_node(state)
112
+ for transition in transducer.graph.out_transitions(state):
113
+ if transition.data.get(ATTR_SYMBOL, EPSILON) is not EPSILON:
114
+ continue
115
+ target = transition.target
116
+ eps_graph.add_edge(state, target, transition=transition)
117
+ if target not in reachable:
118
+ reachable.add(target)
119
+ stack.append(target)
120
+
121
+ for component in nx.strongly_connected_components(eps_graph):
122
+ if len(component) == 1:
123
+ state = next(iter(component))
124
+ has_cycle = eps_graph.has_edge(state, state)
125
+ else:
126
+ has_cycle = True
127
+ if not has_cycle:
128
+ continue
129
+ for source in component:
130
+ for target in eps_graph.successors(source):
131
+ if target not in component:
132
+ continue
133
+ for edge_data in eps_graph.get_edge_data(source, target).values():
134
+ transition = edge_data["transition"]
135
+ if _epsilon_edge_is_productive(transducer, transition.target, transition.data, moore=moore):
136
+ return True
137
+ return False
138
+
139
+
140
+ def _epsilon_edge_is_productive(
141
+ transducer: Transducer,
142
+ target: Hashable,
143
+ data: dict[str, Any],
144
+ *,
145
+ moore: bool,
146
+ ) -> bool:
147
+ if moore:
148
+ return bool(_state_output(transducer, target))
149
+ output = data.get(ATTR_OUTPUT)
150
+ return output is not None and output is not EPSILON