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,611 @@
1
+ r"""Active automata learning: L* and a discrimination-tree (TTT-family) learner.
2
+
3
+ Active learning reconstructs an automaton from a *teacher* answering two kinds of
4
+ query: **membership** ("is this word in the language / what does the machine
5
+ output?") and **equivalence** ("is my hypothesis correct, and if not, give a
6
+ counterexample"). This module provides
7
+
8
+ * oracle protocols (:class:`MembershipOracle`, :class:`EquivalenceOracle` and
9
+ their Mealy analogues) with adapters over sofic models,
10
+ * Angluin's **L\*** :cite:`Angluin1987` for :class:`~sofic.automata.dfa.DFA` and
11
+ its Mealy variant :cite:`Shahbaz2009`, both using the Rivest-Schapire
12
+ counterexample analysis :cite:`RivestSchapire1993`, and
13
+ * a redundancy-free **discrimination-tree** learner in the TTT family
14
+ :cite:`KearnsVazirani1994,Isberner2014`.
15
+
16
+ These complement the existing NL\* átomaton learner
17
+ (:func:`sofic.automata.learning.learn_maximized_prime_atomaton`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable, Iterable, Iterator, Sequence
23
+ from typing import Any, Protocol, runtime_checkable
24
+
25
+ import numpy as np
26
+
27
+ from sofic.automata.dfa import DFA
28
+ from sofic.automata.transducers import MealyMachine
29
+
30
+ __all__ = [
31
+ "MembershipOracle",
32
+ "EquivalenceOracle",
33
+ "MealyMembershipOracle",
34
+ "MealyEquivalenceOracle",
35
+ "FunctionMembershipOracle",
36
+ "LanguageMembershipOracle",
37
+ "FunctionMealyOracle",
38
+ "TransducerOutputOracle",
39
+ "ExhaustiveEquivalenceOracle",
40
+ "RandomWalkEquivalenceOracle",
41
+ "MealyExhaustiveEquivalenceOracle",
42
+ "learn_dfa_lstar",
43
+ "learn_dfa_ttt",
44
+ "learn_mealy_lstar",
45
+ "learn_dfa_from_language",
46
+ "learn_mealy_from_transducer",
47
+ ]
48
+
49
+ Word = tuple[Any, ...]
50
+
51
+
52
+ # --------------------------------------------------------------------------- oracles
53
+
54
+
55
+ @runtime_checkable
56
+ class MembershipOracle(Protocol):
57
+ """Answers whether a word belongs to the target language."""
58
+
59
+ def member(self, word: Sequence[Any]) -> bool: ...
60
+
61
+
62
+ @runtime_checkable
63
+ class EquivalenceOracle(Protocol):
64
+ """Returns a counterexample word where ``hypothesis`` disagrees, or ``None``."""
65
+
66
+ def find_counterexample(self, hypothesis: DFA) -> Word | None: ...
67
+
68
+
69
+ @runtime_checkable
70
+ class MealyMembershipOracle(Protocol):
71
+ """Returns the output word produced by the target for an input word."""
72
+
73
+ def output(self, word: Sequence[Any]) -> Word: ...
74
+
75
+
76
+ @runtime_checkable
77
+ class MealyEquivalenceOracle(Protocol):
78
+ """Returns an input word where the Mealy ``hypothesis`` disagrees, or ``None``."""
79
+
80
+ def find_counterexample(self, hypothesis: MealyMachine) -> Word | None: ...
81
+
82
+
83
+ class FunctionMembershipOracle:
84
+ """Wrap a boolean predicate as a :class:`MembershipOracle`."""
85
+
86
+ def __init__(self, predicate: Callable[[Word], bool]) -> None:
87
+ self._predicate = predicate
88
+
89
+ def member(self, word: Sequence[Any]) -> bool:
90
+ return bool(self._predicate(tuple(word)))
91
+
92
+
93
+ class LanguageMembershipOracle:
94
+ """Membership over any sofic model exposing ``recognizes`` or ``__contains__``.
95
+
96
+ Works with :class:`~sofic.automata.dfa.DFA`, :class:`~sofic.automata.nfa.NFA`,
97
+ átomata, and any :class:`~sofic.automata.languages.base.RegularLanguage`. For
98
+ a sofic shift or ε-machine, pass its support automaton
99
+ (``model.to_support_dfa()``).
100
+ """
101
+
102
+ def __init__(self, model: Any) -> None:
103
+ if hasattr(model, "recognizes"):
104
+ self._member = model.recognizes
105
+ elif hasattr(model, "__contains__"):
106
+ self._member = model.__contains__
107
+ else:
108
+ raise TypeError(f"{type(model).__name__} exposes neither recognizes() nor __contains__()")
109
+
110
+ def member(self, word: Sequence[Any]) -> bool:
111
+ return bool(self._member(tuple(word)))
112
+
113
+
114
+ class FunctionMealyOracle:
115
+ """Wrap an output function as a :class:`MealyMembershipOracle`."""
116
+
117
+ def __init__(self, output: Callable[[Word], Sequence[Any]]) -> None:
118
+ self._output = output
119
+
120
+ def output(self, word: Sequence[Any]) -> Word:
121
+ return tuple(self._output(tuple(word)))
122
+
123
+
124
+ class TransducerOutputOracle:
125
+ """Output oracle backed by a deterministic, complete :class:`MealyMachine`."""
126
+
127
+ def __init__(self, machine: MealyMachine) -> None:
128
+ self._machine = machine
129
+
130
+ def output(self, word: Sequence[Any]) -> Word:
131
+ outputs = self._machine.transduce(tuple(word))
132
+ if not outputs:
133
+ raise ValueError(f"target produced no output for {tuple(word)!r}; is it complete?")
134
+ return next(iter(outputs))
135
+
136
+
137
+ def _words_up_to(max_length: int, alphabet: Sequence[Any]) -> Iterator[Word]:
138
+ frontier: list[Word] = [()]
139
+ yield ()
140
+ for _ in range(max_length):
141
+ nxt: list[Word] = []
142
+ for word in frontier:
143
+ for symbol in alphabet:
144
+ extended = (*word, symbol)
145
+ yield extended
146
+ nxt.append(extended)
147
+ frontier = nxt
148
+
149
+
150
+ class ExhaustiveEquivalenceOracle:
151
+ """Bounded exhaustive equivalence test for DFA hypotheses."""
152
+
153
+ def __init__(self, membership: MembershipOracle, alphabet: Iterable[Any], *, max_length: int = 10) -> None:
154
+ self._membership = membership
155
+ self._alphabet = tuple(sorted(alphabet, key=repr))
156
+ self._max_length = int(max_length)
157
+
158
+ def find_counterexample(self, hypothesis: DFA) -> Word | None:
159
+ for word in _words_up_to(self._max_length, self._alphabet):
160
+ if self._membership.member(word) != hypothesis.recognizes(word):
161
+ return word
162
+ return None
163
+
164
+
165
+ class RandomWalkEquivalenceOracle:
166
+ """Randomized equivalence test drawing random input words for DFA hypotheses."""
167
+
168
+ def __init__(
169
+ self,
170
+ membership: MembershipOracle,
171
+ alphabet: Iterable[Any],
172
+ *,
173
+ num_walks: int = 2000,
174
+ max_steps: int = 30,
175
+ rng: np.random.Generator | int | None = None,
176
+ ) -> None:
177
+ self._membership = membership
178
+ self._alphabet = tuple(sorted(alphabet, key=repr))
179
+ self._num_walks = int(num_walks)
180
+ self._max_steps = int(max_steps)
181
+ self._rng = rng if isinstance(rng, np.random.Generator) else np.random.default_rng(rng)
182
+
183
+ def find_counterexample(self, hypothesis: DFA) -> Word | None:
184
+ n_symbols = len(self._alphabet)
185
+ for _ in range(self._num_walks):
186
+ length = int(self._rng.integers(0, self._max_steps + 1))
187
+ word = tuple(self._alphabet[int(self._rng.integers(0, n_symbols))] for _ in range(length))
188
+ if self._membership.member(word) != hypothesis.recognizes(word):
189
+ return word
190
+ return None
191
+
192
+
193
+ class MealyExhaustiveEquivalenceOracle:
194
+ """Bounded exhaustive equivalence test for Mealy hypotheses."""
195
+
196
+ def __init__(self, oracle: MealyMembershipOracle, alphabet: Iterable[Any], *, max_length: int = 10) -> None:
197
+ self._oracle = oracle
198
+ self._alphabet = tuple(sorted(alphabet, key=repr))
199
+ self._max_length = int(max_length)
200
+
201
+ def find_counterexample(self, hypothesis: MealyMachine) -> Word | None:
202
+ for word in _words_up_to(self._max_length, self._alphabet):
203
+ if not word:
204
+ continue
205
+ produced = hypothesis.transduce(word)
206
+ hyp_out = next(iter(produced)) if produced else None
207
+ if self._oracle.output(word) != hyp_out:
208
+ return word
209
+ return None
210
+
211
+
212
+ # ------------------------------------------------------------------------------ L*
213
+
214
+
215
+ class _MembershipCache:
216
+ def __init__(self, oracle: MembershipOracle) -> None:
217
+ self._oracle = oracle
218
+ self._cache: dict[Word, bool] = {}
219
+
220
+ def member(self, word: Word) -> bool:
221
+ value = self._cache.get(word, None)
222
+ if value is None:
223
+ value = bool(self._oracle.member(word))
224
+ self._cache[word] = value
225
+ return value
226
+
227
+
228
+ def _build_dfa_from_rows(
229
+ access: Iterable[Word],
230
+ experiments: Sequence[Word],
231
+ member: Callable[[Word], bool],
232
+ alphabet: Sequence[Any],
233
+ ) -> DFA:
234
+ def row(word: Word) -> tuple[bool, ...]:
235
+ return tuple(member(word + suffix) for suffix in experiments)
236
+
237
+ representatives: dict[tuple[bool, ...], Word] = {}
238
+ for word in sorted(access, key=lambda w: (len(w), repr(w))):
239
+ representatives.setdefault(row(word), word)
240
+
241
+ dfa = DFA(input_alphabet=frozenset(alphabet))
242
+ for state in representatives.values():
243
+ dfa.graph.add_state(state)
244
+ for state in representatives.values():
245
+ for symbol in alphabet:
246
+ target = representatives[row(state + (symbol,))]
247
+ dfa.add_transition(state, target, symbol)
248
+ dfa.initial_states = frozenset({representatives[row(())]})
249
+ dfa.accepting_states = frozenset(state for signature, state in representatives.items() if signature[0])
250
+ dfa.validate()
251
+ return dfa
252
+
253
+
254
+ def learn_dfa_lstar(
255
+ alphabet: Iterable[Any],
256
+ membership: MembershipOracle,
257
+ equivalence: EquivalenceOracle,
258
+ *,
259
+ max_rounds: int = 100,
260
+ ) -> DFA:
261
+ r"""Learn a minimal DFA with Angluin's L\* algorithm :cite:`Angluin1987`.
262
+
263
+ Maintains a closed and consistent observation table over access prefixes and
264
+ suffix experiments, building a hypothesis DFA and refining it from each
265
+ counterexample until the equivalence oracle is satisfied.
266
+ """
267
+ alphabet = tuple(sorted(alphabet, key=repr))
268
+ cache = _MembershipCache(membership)
269
+ member = cache.member
270
+
271
+ prefixes: set[Word] = {()}
272
+ experiments: list[Word] = [()]
273
+
274
+ def row(word: Word) -> tuple[bool, ...]:
275
+ return tuple(member(word + suffix) for suffix in experiments)
276
+
277
+ for _ in range(max_rounds):
278
+ while True:
279
+ prefix_rows = {row(p) for p in prefixes}
280
+ unclosed = None
281
+ for p in prefixes:
282
+ for symbol in alphabet:
283
+ if row(p + (symbol,)) not in prefix_rows:
284
+ unclosed = p + (symbol,)
285
+ break
286
+ if unclosed is not None:
287
+ break
288
+ if unclosed is not None:
289
+ prefixes.add(unclosed)
290
+ continue
291
+
292
+ inconsistency = _find_inconsistency(prefixes, experiments, alphabet, row, member)
293
+ if inconsistency is not None:
294
+ experiments.append(inconsistency)
295
+ continue
296
+ break
297
+
298
+ closure = set(prefixes)
299
+ for p in list(prefixes):
300
+ for symbol in alphabet:
301
+ closure.add(p + (symbol,))
302
+ hypothesis = _build_dfa_from_rows(closure, experiments, member, alphabet)
303
+
304
+ counterexample = equivalence.find_counterexample(hypothesis)
305
+ if counterexample is None:
306
+ return hypothesis
307
+ for index in range(len(counterexample) + 1):
308
+ prefixes.add(tuple(counterexample[:index]))
309
+
310
+ raise RuntimeError("L* did not converge within max_rounds; check the equivalence oracle")
311
+
312
+
313
+ def _find_inconsistency(
314
+ prefixes: Iterable[Word],
315
+ experiments: Sequence[Word],
316
+ alphabet: Sequence[Any],
317
+ row: Callable[[Word], tuple[bool, ...]],
318
+ member: Callable[[Word], bool],
319
+ ) -> Word | None:
320
+ prefixes = list(prefixes)
321
+ for i, p in enumerate(prefixes):
322
+ for q in prefixes[i + 1 :]:
323
+ if row(p) != row(q):
324
+ continue
325
+ for symbol in alphabet:
326
+ rp, rq = row(p + (symbol,)), row(q + (symbol,))
327
+ if rp != rq:
328
+ for index, suffix in enumerate(experiments):
329
+ if rp[index] != rq[index]:
330
+ return (symbol, *suffix)
331
+ return None
332
+
333
+
334
+ # ----------------------------------------------------------- discrimination-tree (TTT)
335
+
336
+
337
+ class _DTNode:
338
+ __slots__ = ("discriminator", "zero", "one", "access")
339
+
340
+ def __init__(self, *, access: Word | None = None, discriminator: Word | None = None) -> None:
341
+ self.discriminator = discriminator
342
+ self.access = access
343
+ self.zero: _DTNode | None = None
344
+ self.one: _DTNode | None = None
345
+
346
+ @property
347
+ def is_leaf(self) -> bool:
348
+ return self.discriminator is None
349
+
350
+
351
+ def learn_dfa_ttt(
352
+ alphabet: Iterable[Any],
353
+ membership: MembershipOracle,
354
+ equivalence: EquivalenceOracle,
355
+ *,
356
+ max_rounds: int = 100,
357
+ ) -> DFA:
358
+ """Learn a minimal DFA with a discrimination-tree active learner.
359
+
360
+ Uses a binary **discrimination tree** of distinguishing suffixes -- the
361
+ redundancy-free state representation of the TTT family
362
+ :cite:`KearnsVazirani1994,Isberner2014` -- refined by Rivest-Schapire
363
+ counterexample decomposition :cite:`RivestSchapire1993`. Each counterexample
364
+ splits exactly one leaf, so the tree grows to the minimal number of states.
365
+ (Discriminator finalization, TTT's further space optimization, is not
366
+ performed; the learned DFA is identical.)
367
+ """
368
+ alphabet = tuple(sorted(alphabet, key=repr))
369
+ cache = _MembershipCache(membership)
370
+ member = cache.member
371
+
372
+ root = _DTNode(access=())
373
+
374
+ def sift(word: Word) -> _DTNode:
375
+ node = root
376
+ while not node.is_leaf:
377
+ node = node.one if member(word + node.discriminator) else node.zero
378
+ return node
379
+
380
+ def build() -> DFA:
381
+ leaves: list[_DTNode] = []
382
+ stack = [root]
383
+ while stack:
384
+ node = stack.pop()
385
+ if node.is_leaf:
386
+ leaves.append(node)
387
+ else:
388
+ stack.extend((node.zero, node.one))
389
+ dfa = DFA(input_alphabet=frozenset(alphabet))
390
+ for leaf in leaves:
391
+ dfa.graph.add_state(leaf.access)
392
+ for leaf in leaves:
393
+ for symbol in alphabet:
394
+ target = sift(leaf.access + (symbol,))
395
+ dfa.add_transition(leaf.access, target.access, symbol)
396
+ dfa.initial_states = frozenset({sift(()).access})
397
+ dfa.accepting_states = frozenset(leaf.access for leaf in leaves if member(leaf.access))
398
+ dfa.validate()
399
+ return dfa
400
+
401
+ for _ in range(max_rounds):
402
+ hypothesis = build()
403
+ counterexample = equivalence.find_counterexample(hypothesis)
404
+ if counterexample is None:
405
+ return hypothesis
406
+ _split_leaf(counterexample, sift, member, alphabet)
407
+
408
+ raise RuntimeError("TTT did not converge within max_rounds; check the equivalence oracle")
409
+
410
+
411
+ def _hypothesis_access(word: Word, sift: Callable[[Word], _DTNode]) -> Word:
412
+ return sift(word).access
413
+
414
+
415
+ def _split_leaf(
416
+ counterexample: Word,
417
+ sift: Callable[[Word], _DTNode],
418
+ member: Callable[[Word], bool],
419
+ alphabet: Sequence[Any],
420
+ ) -> None:
421
+ counterexample = tuple(counterexample)
422
+ length = len(counterexample)
423
+
424
+ def alpha(index: int) -> Word:
425
+ return _hypothesis_access(counterexample[:index], sift) + counterexample[index:]
426
+
427
+ base = member(alpha(0))
428
+ breakpoint_index = None
429
+ for index in range(length):
430
+ if member(alpha(index + 1)) != base:
431
+ breakpoint_index = index
432
+ break
433
+ if breakpoint_index is None: # pragma: no cover - guaranteed by a valid counterexample
434
+ raise RuntimeError("counterexample analysis found no breakpoint")
435
+
436
+ state_access = _hypothesis_access(counterexample[:breakpoint_index], sift)
437
+ symbol = counterexample[breakpoint_index]
438
+ discriminator = counterexample[breakpoint_index + 1 :]
439
+ new_access = state_access + (symbol,)
440
+
441
+ leaf = sift(new_access)
442
+ old_access = leaf.access
443
+
444
+ old_leaf = _DTNode(access=old_access)
445
+ new_leaf = _DTNode(access=new_access)
446
+ leaf.discriminator = discriminator
447
+ leaf.access = None
448
+ if member(old_access + discriminator):
449
+ leaf.one, leaf.zero = old_leaf, new_leaf
450
+ else:
451
+ leaf.one, leaf.zero = new_leaf, old_leaf
452
+
453
+
454
+ # ------------------------------------------------------------------------ Mealy L*
455
+
456
+
457
+ def learn_mealy_lstar(
458
+ alphabet: Iterable[Any],
459
+ oracle: MealyMembershipOracle,
460
+ equivalence: MealyEquivalenceOracle,
461
+ *,
462
+ max_rounds: int = 100,
463
+ ) -> MealyMachine:
464
+ r"""Learn a minimal Mealy machine with the L\*-Mealy algorithm.
465
+
466
+ The Mealy adaptation of L\* :cite:`Shahbaz2009`: table cells hold the last
467
+ output symbol of an output query, suffix experiments are seeded with the
468
+ single input symbols, and states are distinguished by their output rows.
469
+ """
470
+ alphabet = tuple(sorted(alphabet, key=repr))
471
+ output_cache: dict[Word, Word] = {}
472
+
473
+ def out(word: Word) -> Word:
474
+ value = output_cache.get(word)
475
+ if value is None:
476
+ value = tuple(oracle.output(word))
477
+ output_cache[word] = value
478
+ return value
479
+
480
+ def cell(prefix: Word, suffix: Word) -> Any:
481
+ produced = out(prefix + suffix)
482
+ return produced[-1] if produced else None
483
+
484
+ prefixes: set[Word] = {()}
485
+ experiments: list[Word] = [(symbol,) for symbol in alphabet]
486
+
487
+ def row(word: Word) -> tuple[Any, ...]:
488
+ return tuple(cell(word, suffix) for suffix in experiments)
489
+
490
+ for _ in range(max_rounds):
491
+ while True:
492
+ prefix_rows = {row(p) for p in prefixes}
493
+ unclosed = None
494
+ for p in prefixes:
495
+ for symbol in alphabet:
496
+ if row(p + (symbol,)) not in prefix_rows:
497
+ unclosed = p + (symbol,)
498
+ break
499
+ if unclosed is not None:
500
+ break
501
+ if unclosed is not None:
502
+ prefixes.add(unclosed)
503
+ continue
504
+
505
+ inconsistency = _find_mealy_inconsistency(prefixes, experiments, alphabet, row)
506
+ if inconsistency is not None:
507
+ experiments.append(inconsistency)
508
+ continue
509
+ break
510
+
511
+ hypothesis = _build_mealy(prefixes, alphabet, row, cell)
512
+ counterexample = equivalence.find_counterexample(hypothesis)
513
+ if counterexample is None:
514
+ return hypothesis
515
+ for index in range(1, len(counterexample) + 1):
516
+ prefixes.add(tuple(counterexample[:index]))
517
+
518
+ raise RuntimeError("L*-Mealy did not converge within max_rounds; check the equivalence oracle")
519
+
520
+
521
+ def _find_mealy_inconsistency(
522
+ prefixes: Iterable[Word],
523
+ experiments: Sequence[Word],
524
+ alphabet: Sequence[Any],
525
+ row: Callable[[Word], tuple[Any, ...]],
526
+ ) -> Word | None:
527
+ prefixes = list(prefixes)
528
+ for i, p in enumerate(prefixes):
529
+ for q in prefixes[i + 1 :]:
530
+ if row(p) != row(q):
531
+ continue
532
+ for symbol in alphabet:
533
+ rp, rq = row(p + (symbol,)), row(q + (symbol,))
534
+ if rp != rq:
535
+ for index, suffix in enumerate(experiments):
536
+ if rp[index] != rq[index]:
537
+ return (symbol, *suffix)
538
+ return None
539
+
540
+
541
+ def _build_mealy(
542
+ prefixes: Iterable[Word],
543
+ alphabet: Sequence[Any],
544
+ row: Callable[[Word], tuple[Any, ...]],
545
+ cell: Callable[[Word, Word], Any],
546
+ ) -> MealyMachine:
547
+ representatives: dict[tuple[Any, ...], Word] = {}
548
+ for word in sorted(prefixes, key=lambda w: (len(w), repr(w))):
549
+ representatives.setdefault(row(word), word)
550
+
551
+ outputs: set[Any] = set()
552
+ transitions: list[tuple[Word, Word, Any, Any]] = []
553
+ for state in representatives.values():
554
+ for symbol in alphabet:
555
+ target = representatives[row(state + (symbol,))]
556
+ output = cell(state, (symbol,))
557
+ outputs.add(output)
558
+ transitions.append((state, target, symbol, output))
559
+
560
+ machine = MealyMachine(
561
+ input_alphabet=frozenset(alphabet),
562
+ output_alphabet=frozenset(outputs),
563
+ initial_states=frozenset({representatives[row(())]}),
564
+ )
565
+ for state in representatives.values():
566
+ machine.graph.add_state(state)
567
+ for source, target, symbol, output in transitions:
568
+ machine.add_transition(source, target, symbol, output=output)
569
+ machine.validate()
570
+ return machine
571
+
572
+
573
+ # ------------------------------------------------------------------- convenience
574
+
575
+
576
+ def learn_dfa_from_language(
577
+ target: Any,
578
+ alphabet: Iterable[Any],
579
+ *,
580
+ algorithm: str = "lstar",
581
+ max_length: int = 12,
582
+ max_rounds: int = 100,
583
+ ) -> DFA:
584
+ """Learn a DFA for a sofic language model using a bounded exhaustive teacher.
585
+
586
+ ``target`` is any model accepted by :class:`LanguageMembershipOracle` (a DFA,
587
+ NFA, átomaton, or :class:`~sofic.automata.languages.base.RegularLanguage`);
588
+ for a sofic shift or ε-machine pass ``model.to_support_dfa()``. ``algorithm``
589
+ selects ``"lstar"`` or ``"ttt"``.
590
+ """
591
+ membership = LanguageMembershipOracle(target)
592
+ equivalence = ExhaustiveEquivalenceOracle(membership, alphabet, max_length=max_length)
593
+ if algorithm == "lstar":
594
+ return learn_dfa_lstar(alphabet, membership, equivalence, max_rounds=max_rounds)
595
+ if algorithm == "ttt":
596
+ return learn_dfa_ttt(alphabet, membership, equivalence, max_rounds=max_rounds)
597
+ raise ValueError(f"unknown algorithm {algorithm!r}; use 'lstar' or 'ttt'")
598
+
599
+
600
+ def learn_mealy_from_transducer(
601
+ target: MealyMachine,
602
+ alphabet: Iterable[Any] | None = None,
603
+ *,
604
+ max_length: int = 12,
605
+ max_rounds: int = 100,
606
+ ) -> MealyMachine:
607
+ """Learn a Mealy machine equivalent to ``target`` with a bounded exhaustive teacher."""
608
+ inputs = alphabet if alphabet is not None else target.alphabets()[0]
609
+ oracle = TransducerOutputOracle(target)
610
+ equivalence = MealyExhaustiveEquivalenceOracle(oracle, inputs, max_length=max_length)
611
+ return learn_mealy_lstar(inputs, oracle, equivalence, max_rounds=max_rounds)