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.
- sofic/__init__.py +185 -0
- sofic/automata/__init__.py +207 -0
- sofic/automata/_config_simulation.py +40 -0
- sofic/automata/active.py +611 -0
- sofic/automata/alergia.py +222 -0
- sofic/automata/algorithms.py +376 -0
- sofic/automata/atomaton.py +58 -0
- sofic/automata/base.py +161 -0
- sofic/automata/buchi.py +23 -0
- sofic/automata/buchi_simulation.py +67 -0
- sofic/automata/canonical_dual.py +18 -0
- sofic/automata/canonical_extraction.py +122 -0
- sofic/automata/dfa.py +85 -0
- sofic/automata/dfasat.py +195 -0
- sofic/automata/edsm.py +219 -0
- sofic/automata/enumeration.py +44 -0
- sofic/automata/icdfa.py +421 -0
- sofic/automata/idfa.py +363 -0
- sofic/automata/languages/__init__.py +39 -0
- sofic/automata/languages/_quotient_utils.py +64 -0
- sofic/automata/languages/atoms.py +31 -0
- sofic/automata/languages/automaton_ops.py +243 -0
- sofic/automata/languages/base.py +67 -0
- sofic/automata/languages/operations.py +78 -0
- sofic/automata/languages/quotients.py +66 -0
- sofic/automata/languages/residuals.py +25 -0
- sofic/automata/learning.py +79 -0
- sofic/automata/nfa.py +39 -0
- sofic/automata/nwa.py +343 -0
- sofic/automata/nwa_simulation.py +56 -0
- sofic/automata/observation.py +40 -0
- sofic/automata/papni.py +301 -0
- sofic/automata/regex.py +128 -0
- sofic/automata/rfsa.py +35 -0
- sofic/automata/rpni.py +193 -0
- sofic/automata/subsequential.py +201 -0
- sofic/automata/transducer_operations.py +350 -0
- sofic/automata/transducer_simulation.py +150 -0
- sofic/automata/transducers.py +365 -0
- sofic/automata/unifilar.py +107 -0
- sofic/automata/vpa.py +1373 -0
- sofic/automata/vpa_simulation.py +53 -0
- sofic/base.py +153 -0
- sofic/core.py +47 -0
- sofic/examples/__init__.py +86 -0
- sofic/examples/epsilon_machines.py +1089 -0
- sofic/examples/processes.py +1491 -0
- sofic/examples/shifts.py +144 -0
- sofic/exceptions.py +33 -0
- sofic/generators/__init__.py +115 -0
- sofic/generators/_word_measures.py +94 -0
- sofic/generators/alternative_complexity.py +104 -0
- sofic/generators/base.py +327 -0
- sofic/generators/bidirectional_construction.py +717 -0
- sofic/generators/bidirectional_epsilon_machine.py +689 -0
- sofic/generators/block_convergence.py +668 -0
- sofic/generators/block_entropy.py +578 -0
- sofic/generators/channel_measures.py +75 -0
- sofic/generators/conversions.py +182 -0
- sofic/generators/directional_flow.py +245 -0
- sofic/generators/edge_emissions.py +36 -0
- sofic/generators/edge_machine.py +178 -0
- sofic/generators/epsilon_construction.py +193 -0
- sofic/generators/epsilon_inference.py +703 -0
- sofic/generators/epsilon_machine.py +557 -0
- sofic/generators/epsilon_transducer.py +168 -0
- sofic/generators/epsilon_transducer_construction.py +185 -0
- sofic/generators/epsilon_transducer_inference.py +499 -0
- sofic/generators/hmm_inference.py +719 -0
- sofic/generators/information_diagram.py +428 -0
- sofic/generators/lumping.py +447 -0
- sofic/generators/markov.py +100 -0
- sofic/generators/mealy.py +156 -0
- sofic/generators/measures.py +257 -0
- sofic/generators/minimal_generative_model.py +821 -0
- sofic/generators/mixed_state.py +250 -0
- sofic/generators/mixed_state_construction.py +163 -0
- sofic/generators/moore.py +75 -0
- sofic/generators/nmachine.py +78 -0
- sofic/generators/nmachine_construction.py +70 -0
- sofic/generators/pfa.py +100 -0
- sofic/generators/prob.py +291 -0
- sofic/generators/process_equivalence.py +207 -0
- sofic/generators/quasi_inference.py +74 -0
- sofic/generators/quasi_realization.py +97 -0
- sofic/generators/reversal.py +66 -0
- sofic/generators/stack_hmm.py +426 -0
- sofic/generators/stack_inference.py +509 -0
- sofic/generators/stationary.py +134 -0
- sofic/generators/stochastic.py +65 -0
- sofic/generators/synchronization.py +407 -0
- sofic/generators/topological_epsilon_enumeration.py +349 -0
- sofic/generators/words.py +226 -0
- sofic/graph.py +135 -0
- sofic/indexing.py +31 -0
- sofic/inference/__init__.py +45 -0
- sofic/inference/bayesian/__init__.py +68 -0
- sofic/inference/bayesian/comparison.py +199 -0
- sofic/inference/bayesian/counts.py +219 -0
- sofic/inference/bayesian/diversity.py +254 -0
- sofic/inference/bayesian/epsilon.py +270 -0
- sofic/inference/bayesian/hdp_hmm.py +340 -0
- sofic/inference/bayesian/markov.py +294 -0
- sofic/inference/bayesian/pymc_backend.py +71 -0
- sofic/inference/bayesian/stack_hmm.py +215 -0
- sofic/inference/model_selection.py +365 -0
- sofic/inference/spectral.py +564 -0
- sofic/operations.py +16 -0
- sofic/properties.py +339 -0
- sofic/serialization.py +450 -0
- sofic/shifts/__init__.py +48 -0
- sofic/shifts/algorithms.py +84 -0
- sofic/shifts/base.py +49 -0
- sofic/shifts/cover_construction.py +76 -0
- sofic/shifts/covers.py +47 -0
- sofic/shifts/dyck_algorithms.py +100 -0
- sofic/shifts/dyck_enumeration.py +275 -0
- sofic/shifts/markov_dyck.py +172 -0
- sofic/shifts/parry_construction.py +82 -0
- sofic/shifts/sft.py +104 -0
- sofic/shifts/sft_construction.py +52 -0
- sofic/shifts/sliding_block_code.py +156 -0
- sofic/shifts/sofic.py +111 -0
- sofic/shifts/sofic_dyck.py +110 -0
- sofic/shifts/sofic_relation.py +64 -0
- sofic/shifts/textile.py +104 -0
- sofic/shifts/tmc.py +46 -0
- sofic/shifts/tmc_construction.py +58 -0
- sofic/shifts/topological_anatomy.py +150 -0
- sofic/states.py +27 -0
- sofic/testing/__init__.py +8 -0
- sofic/testing/strategies.py +154 -0
- sofic/viz/__init__.py +16 -0
- sofic/viz/_context.py +345 -0
- sofic/viz/_edge.py +216 -0
- sofic/viz/_format.py +89 -0
- sofic/viz/_labels.py +34 -0
- sofic/viz/_names.py +17 -0
- sofic/viz/_rational.py +20 -0
- sofic/viz/_tikz_compile.py +177 -0
- sofic/viz/_tikz_format.py +122 -0
- sofic/viz/_tikz_layout.py +218 -0
- sofic/viz/assets/vaucanson.tikz +71 -0
- sofic/viz/graphviz.py +158 -0
- sofic/viz/idiagram.py +350 -0
- sofic/viz/tikz.py +381 -0
- sofic-0.1.0.dist-info/METADATA +444 -0
- sofic-0.1.0.dist-info/RECORD +150 -0
- sofic-0.1.0.dist-info/WHEEL +4 -0
- sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
sofic/automata/active.py
ADDED
|
@@ -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)
|