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/papni.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Passive visibly-pushdown topology learning via PAPNI preprocessing and RPNI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sofic.automata.dfa import DFA
|
|
10
|
+
from sofic.automata.rpni import learn_dfa_rpni
|
|
11
|
+
from sofic.graph import ATTR_KIND, ATTR_SYMBOL, KIND_CALL, KIND_INTERNAL, KIND_RETURN
|
|
12
|
+
from sofic.shifts.sofic_dyck import SoficDyckShift, TransitionRef, transition_ref
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DyckAlphabet",
|
|
16
|
+
"is_well_matched",
|
|
17
|
+
"learn_sofic_dyck_shift_papni",
|
|
18
|
+
"papni_encode",
|
|
19
|
+
"papni_encode_samples",
|
|
20
|
+
"sofic_dyck_shift_from_papni_dfa",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class DyckAlphabet:
|
|
26
|
+
"""Partition of symbols into call, return, and internal roles."""
|
|
27
|
+
|
|
28
|
+
call_alphabet: frozenset[Any]
|
|
29
|
+
return_alphabet: frozenset[Any]
|
|
30
|
+
internal_alphabet: frozenset[Any]
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
union = self.call_alphabet | self.return_alphabet | self.internal_alphabet
|
|
34
|
+
if len(union) != len(self.call_alphabet) + len(self.return_alphabet) + len(self.internal_alphabet):
|
|
35
|
+
raise ValueError("call, return, and internal alphabets must be disjoint")
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def symbol_alphabet(self) -> frozenset[Any]:
|
|
39
|
+
return self.call_alphabet | self.return_alphabet | self.internal_alphabet
|
|
40
|
+
|
|
41
|
+
def classify(self, symbol: Any) -> str:
|
|
42
|
+
if symbol in self.call_alphabet:
|
|
43
|
+
return KIND_CALL
|
|
44
|
+
if symbol in self.return_alphabet:
|
|
45
|
+
return KIND_RETURN
|
|
46
|
+
if symbol in self.internal_alphabet:
|
|
47
|
+
return KIND_INTERNAL
|
|
48
|
+
raise ValueError(f"symbol {symbol!r} not in Dyck alphabet")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_well_matched(word: Sequence[Any], alphabet: DyckAlphabet) -> bool:
|
|
52
|
+
"""Return whether ``word`` is well-matched under a symbol counter (PAPNI Alg. 1)."""
|
|
53
|
+
counter = 0
|
|
54
|
+
for symbol in word:
|
|
55
|
+
if symbol in alphabet.call_alphabet:
|
|
56
|
+
counter += 1
|
|
57
|
+
elif symbol in alphabet.return_alphabet:
|
|
58
|
+
counter -= 1
|
|
59
|
+
if counter < 0:
|
|
60
|
+
return False
|
|
61
|
+
elif symbol not in alphabet.internal_alphabet:
|
|
62
|
+
return False
|
|
63
|
+
return counter == 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def papni_encode(word: Sequence[Any], alphabet: DyckAlphabet) -> tuple[Any, ...]:
|
|
67
|
+
"""Convert a well-matched word to its stack-aware representation (PAPNI Alg. 2)."""
|
|
68
|
+
if not is_well_matched(word, alphabet):
|
|
69
|
+
raise ValueError("word is not well-matched")
|
|
70
|
+
|
|
71
|
+
encoded: list[Any] = []
|
|
72
|
+
stack: list[Any] = []
|
|
73
|
+
for symbol in word:
|
|
74
|
+
if symbol in alphabet.call_alphabet:
|
|
75
|
+
stack.append(symbol)
|
|
76
|
+
encoded.append(symbol)
|
|
77
|
+
elif symbol in alphabet.return_alphabet:
|
|
78
|
+
if not stack:
|
|
79
|
+
raise ValueError("return with empty stack")
|
|
80
|
+
call_symbol = stack.pop()
|
|
81
|
+
encoded.append((symbol, call_symbol))
|
|
82
|
+
else:
|
|
83
|
+
encoded.append(symbol)
|
|
84
|
+
return tuple(encoded)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def papni_encode_samples(
|
|
88
|
+
samples: Sequence[Sequence[Any]],
|
|
89
|
+
alphabet: DyckAlphabet,
|
|
90
|
+
*,
|
|
91
|
+
drop_non_well_matched: bool = True,
|
|
92
|
+
) -> list[tuple[Any, ...]]:
|
|
93
|
+
"""Filter and encode samples for RPNI over the stack-aware alphabet."""
|
|
94
|
+
encoded: list[tuple[Any, ...]] = []
|
|
95
|
+
for word in samples:
|
|
96
|
+
seq = tuple(word)
|
|
97
|
+
if not is_well_matched(seq, alphabet):
|
|
98
|
+
if drop_non_well_matched:
|
|
99
|
+
continue
|
|
100
|
+
raise ValueError(f"sample {seq!r} is not well-matched")
|
|
101
|
+
encoded.append(papni_encode(seq, alphabet))
|
|
102
|
+
return encoded
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _decode_symbol(symbol: Any, alphabet: DyckAlphabet) -> tuple[str, Any, Any | None]:
|
|
106
|
+
if symbol in alphabet.call_alphabet:
|
|
107
|
+
return KIND_CALL, symbol, None
|
|
108
|
+
if symbol in alphabet.internal_alphabet:
|
|
109
|
+
return KIND_INTERNAL, symbol, None
|
|
110
|
+
if isinstance(symbol, tuple) and len(symbol) == 2:
|
|
111
|
+
return_symbol, call_symbol = symbol
|
|
112
|
+
if return_symbol in alphabet.return_alphabet and call_symbol in alphabet.call_alphabet:
|
|
113
|
+
return KIND_RETURN, return_symbol, call_symbol
|
|
114
|
+
raise ValueError(f"unknown stack-aware symbol {symbol!r}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sofic_dyck_shift_from_papni_dfa(
|
|
118
|
+
dfa: DFA,
|
|
119
|
+
alphabet: DyckAlphabet,
|
|
120
|
+
*,
|
|
121
|
+
positive_traces: Sequence[Sequence[Any]] | None = None,
|
|
122
|
+
) -> SoficDyckShift:
|
|
123
|
+
"""Convert a PAPNI-learned DFA over stack-aware symbols into a ``SoficDyckShift``."""
|
|
124
|
+
if not dfa.initial_states:
|
|
125
|
+
raise ValueError("DFA requires an initial state")
|
|
126
|
+
initial = next(iter(dfa.initial_states))
|
|
127
|
+
reachable = dfa.graph.forward_reachable({initial})
|
|
128
|
+
|
|
129
|
+
shift = SoficDyckShift(
|
|
130
|
+
call_alphabet=alphabet.call_alphabet,
|
|
131
|
+
return_alphabet=alphabet.return_alphabet,
|
|
132
|
+
internal_alphabet=alphabet.internal_alphabet,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
for state in sorted(reachable, key=repr):
|
|
136
|
+
shift.graph.add_state(state)
|
|
137
|
+
|
|
138
|
+
call_by_symbol: dict[Any, list[TransitionRef]] = {symbol: [] for symbol in alphabet.call_alphabet}
|
|
139
|
+
return_by_pair: dict[tuple[Any, Any], list[TransitionRef]] = {}
|
|
140
|
+
|
|
141
|
+
for source in reachable:
|
|
142
|
+
for transition in dfa.graph.out_transitions(source):
|
|
143
|
+
symbol = transition.data.get(ATTR_SYMBOL)
|
|
144
|
+
if symbol is None:
|
|
145
|
+
continue
|
|
146
|
+
kind, visible, matched_call = _decode_symbol(symbol, alphabet)
|
|
147
|
+
target = transition.target
|
|
148
|
+
if kind == KIND_CALL:
|
|
149
|
+
ref = shift.add_call_transition(source, target, visible)
|
|
150
|
+
call_by_symbol[visible].append(ref)
|
|
151
|
+
elif kind == KIND_INTERNAL:
|
|
152
|
+
shift.add_internal_transition(source, target, visible)
|
|
153
|
+
else:
|
|
154
|
+
assert matched_call is not None
|
|
155
|
+
ref = shift.add_return_transition(source, target, visible)
|
|
156
|
+
return_by_pair.setdefault((visible, matched_call), []).append(ref)
|
|
157
|
+
|
|
158
|
+
if positive_traces:
|
|
159
|
+
_infer_matched_edges_from_traces(shift, dfa, alphabet, positive_traces)
|
|
160
|
+
else:
|
|
161
|
+
for (_return_symbol, call_symbol), return_refs in return_by_pair.items():
|
|
162
|
+
for return_ref in return_refs:
|
|
163
|
+
for call_ref in call_by_symbol.get(call_symbol, ()):
|
|
164
|
+
shift.add_matched_pair(call_ref, return_ref)
|
|
165
|
+
|
|
166
|
+
shift = _trim_shift_to_reachable(shift, initial)
|
|
167
|
+
shift.validate()
|
|
168
|
+
return shift
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _trim_shift_to_reachable(shift: SoficDyckShift, initial: Hashable) -> SoficDyckShift:
|
|
172
|
+
"""Drop unreachable states and zero-outdegree control states."""
|
|
173
|
+
if not shift.graph.has_state(initial):
|
|
174
|
+
return shift
|
|
175
|
+
active = set(shift.graph.forward_reachable({initial}))
|
|
176
|
+
|
|
177
|
+
dead = {state for state in active if not any(True for _ in shift.graph.out_transitions(state))}
|
|
178
|
+
active -= dead
|
|
179
|
+
|
|
180
|
+
trimmed = SoficDyckShift(
|
|
181
|
+
call_alphabet=shift.call_alphabet,
|
|
182
|
+
return_alphabet=shift.return_alphabet,
|
|
183
|
+
internal_alphabet=shift.internal_alphabet,
|
|
184
|
+
matched_edges=shift.matched_edges,
|
|
185
|
+
symbol_alphabet=shift.symbol_alphabet,
|
|
186
|
+
)
|
|
187
|
+
for state in active:
|
|
188
|
+
trimmed.graph.add_state(state, **shift.graph.state_attrs(state))
|
|
189
|
+
|
|
190
|
+
edge_map: dict[TransitionRef, TransitionRef] = {}
|
|
191
|
+
for transition in shift.transitions():
|
|
192
|
+
if transition.source not in active:
|
|
193
|
+
continue
|
|
194
|
+
target = transition.target if transition.target in active else transition.source
|
|
195
|
+
data = dict(transition.data)
|
|
196
|
+
key = trimmed.graph.add_transition(transition.source, target, **data)
|
|
197
|
+
edge_map[transition_ref(transition)] = (transition.source, target, key)
|
|
198
|
+
|
|
199
|
+
trimmed.matched_edges = frozenset(
|
|
200
|
+
(edge_map[call_ref], edge_map[return_ref])
|
|
201
|
+
for call_ref, return_ref in shift.matched_edges
|
|
202
|
+
if call_ref in edge_map and return_ref in edge_map
|
|
203
|
+
)
|
|
204
|
+
return trimmed
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _infer_matched_edges_from_traces(
|
|
208
|
+
shift: SoficDyckShift,
|
|
209
|
+
dfa: DFA,
|
|
210
|
+
alphabet: DyckAlphabet,
|
|
211
|
+
traces: Sequence[Sequence[Any]],
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Record matched call-return pairs observed when replaying positive traces."""
|
|
214
|
+
if not dfa.initial_states:
|
|
215
|
+
return
|
|
216
|
+
initial = next(iter(dfa.initial_states))
|
|
217
|
+
|
|
218
|
+
for word in traces:
|
|
219
|
+
if not is_well_matched(word, alphabet):
|
|
220
|
+
continue
|
|
221
|
+
encoded = papni_encode(word, alphabet)
|
|
222
|
+
dfa_state = initial
|
|
223
|
+
config_state = initial
|
|
224
|
+
stack: list[TransitionRef] = []
|
|
225
|
+
|
|
226
|
+
for symbol in encoded:
|
|
227
|
+
kind, visible, matched_call = _decode_symbol(symbol, alphabet)
|
|
228
|
+
successors = list(dfa.graph.out_transitions(dfa_state))
|
|
229
|
+
dfa_transition = next(
|
|
230
|
+
(transition for transition in successors if transition.data.get(ATTR_SYMBOL) == symbol),
|
|
231
|
+
None,
|
|
232
|
+
)
|
|
233
|
+
if dfa_transition is None:
|
|
234
|
+
break
|
|
235
|
+
dfa_state = dfa_transition.target
|
|
236
|
+
|
|
237
|
+
if kind == KIND_CALL:
|
|
238
|
+
ref = _select_transition(shift, config_state, dfa_state, KIND_CALL, visible)
|
|
239
|
+
if ref is None:
|
|
240
|
+
break
|
|
241
|
+
stack.append(ref)
|
|
242
|
+
config_state = dfa_state
|
|
243
|
+
elif kind == KIND_INTERNAL:
|
|
244
|
+
ref = _select_transition(shift, config_state, dfa_state, KIND_INTERNAL, visible)
|
|
245
|
+
if ref is None:
|
|
246
|
+
break
|
|
247
|
+
config_state = dfa_state
|
|
248
|
+
else:
|
|
249
|
+
assert matched_call is not None
|
|
250
|
+
ref = _select_transition(shift, config_state, dfa_state, KIND_RETURN, visible)
|
|
251
|
+
if ref is None or not stack:
|
|
252
|
+
break
|
|
253
|
+
call_ref = stack.pop()
|
|
254
|
+
shift.add_matched_pair(call_ref, ref)
|
|
255
|
+
config_state = dfa_state
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _select_transition(
|
|
259
|
+
shift: SoficDyckShift,
|
|
260
|
+
source: Hashable,
|
|
261
|
+
target: Hashable,
|
|
262
|
+
kind: str,
|
|
263
|
+
symbol: Any,
|
|
264
|
+
) -> TransitionRef | None:
|
|
265
|
+
for transition in shift.graph.out_transitions(source):
|
|
266
|
+
if transition.target != target:
|
|
267
|
+
continue
|
|
268
|
+
if transition.data.get(ATTR_KIND) != kind:
|
|
269
|
+
continue
|
|
270
|
+
if transition.data.get(ATTR_SYMBOL) != symbol:
|
|
271
|
+
continue
|
|
272
|
+
return transition_ref(transition)
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def learn_sofic_dyck_shift_papni(
|
|
277
|
+
positive: Sequence[Sequence[Any]],
|
|
278
|
+
negative: Sequence[Sequence[Any]] | None = None,
|
|
279
|
+
*,
|
|
280
|
+
alphabet: DyckAlphabet,
|
|
281
|
+
) -> SoficDyckShift:
|
|
282
|
+
"""Learn a ``SoficDyckShift`` topology from labeled samples via PAPNI + RPNI."""
|
|
283
|
+
encoded_positive = papni_encode_samples(positive, alphabet)
|
|
284
|
+
if not encoded_positive:
|
|
285
|
+
raise ValueError("no well-matched positive samples remain after PAPNI filtering")
|
|
286
|
+
|
|
287
|
+
encoded_negative: list[tuple[Any, ...]] = []
|
|
288
|
+
if negative:
|
|
289
|
+
for word in negative:
|
|
290
|
+
seq = tuple(word)
|
|
291
|
+
if not is_well_matched(seq, alphabet):
|
|
292
|
+
continue
|
|
293
|
+
encoded_negative.append(papni_encode(seq, alphabet))
|
|
294
|
+
|
|
295
|
+
dfa = learn_dfa_rpni(encoded_positive, encoded_negative)
|
|
296
|
+
well_matched_positive = [tuple(word) for word in positive if is_well_matched(word, alphabet)]
|
|
297
|
+
return sofic_dyck_shift_from_papni_dfa(
|
|
298
|
+
dfa,
|
|
299
|
+
alphabet,
|
|
300
|
+
positive_traces=well_matched_positive,
|
|
301
|
+
)
|
sofic/automata/regex.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Automata to regular-expression conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sofic.automata.base import LabeledAutomaton
|
|
10
|
+
from sofic.graph import ATTR_SYMBOL, EPSILON
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class _Regex:
|
|
15
|
+
kind: str
|
|
16
|
+
parts: tuple[Any, ...] = ()
|
|
17
|
+
|
|
18
|
+
def pattern(self) -> str:
|
|
19
|
+
if self.kind == "empty":
|
|
20
|
+
return "(?!)"
|
|
21
|
+
if self.kind == "epsilon":
|
|
22
|
+
return "(?:)"
|
|
23
|
+
if self.kind == "literal":
|
|
24
|
+
return re.escape(str(self.parts[0]))
|
|
25
|
+
if self.kind == "union":
|
|
26
|
+
return "(?:" + "|".join(part.pattern() for part in self.parts) + ")"
|
|
27
|
+
if self.kind == "concat":
|
|
28
|
+
return "".join(_atom(part) for part in self.parts)
|
|
29
|
+
if self.kind == "star":
|
|
30
|
+
return _atom(self.parts[0]) + "*"
|
|
31
|
+
raise ValueError(f"unknown regex node {self.kind!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_EMPTY = _Regex("empty")
|
|
35
|
+
_EPSILON = _Regex("epsilon")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def automaton_to_regex(automaton: LabeledAutomaton) -> str:
|
|
39
|
+
"""Return a Python-regex-compatible expression for ``automaton``.
|
|
40
|
+
|
|
41
|
+
The result uses ``(?!)`` for the empty language and ``(?:)`` for epsilon.
|
|
42
|
+
Symbols are rendered with ``str(symbol)`` and escaped for Python's
|
|
43
|
+
:mod:`re` engine.
|
|
44
|
+
"""
|
|
45
|
+
start = object()
|
|
46
|
+
final = object()
|
|
47
|
+
states = sorted(automaton.states(), key=repr)
|
|
48
|
+
remaining: list[Any] = [start, *states, final]
|
|
49
|
+
labels: dict[tuple[Any, Any], _Regex] = {}
|
|
50
|
+
|
|
51
|
+
for state in automaton.initial_states:
|
|
52
|
+
labels[(start, state)] = _union(labels.get((start, state), _EMPTY), _EPSILON)
|
|
53
|
+
for state in automaton.accepting_states:
|
|
54
|
+
labels[(state, final)] = _union(labels.get((state, final), _EMPTY), _EPSILON)
|
|
55
|
+
for transition in automaton.transitions():
|
|
56
|
+
symbol = transition.data.get(ATTR_SYMBOL)
|
|
57
|
+
if symbol is None:
|
|
58
|
+
continue
|
|
59
|
+
label = _EPSILON if symbol is EPSILON else _Regex("literal", (symbol,))
|
|
60
|
+
key = (transition.source, transition.target)
|
|
61
|
+
labels[key] = _union(labels.get(key, _EMPTY), label)
|
|
62
|
+
|
|
63
|
+
for state in states:
|
|
64
|
+
others = [candidate for candidate in remaining if candidate != state]
|
|
65
|
+
loop = _star(labels.get((state, state), _EMPTY))
|
|
66
|
+
for source in others:
|
|
67
|
+
left = labels.get((source, state), _EMPTY)
|
|
68
|
+
if left == _EMPTY:
|
|
69
|
+
continue
|
|
70
|
+
for target in others:
|
|
71
|
+
right = labels.get((state, target), _EMPTY)
|
|
72
|
+
if right == _EMPTY:
|
|
73
|
+
continue
|
|
74
|
+
key = (source, target)
|
|
75
|
+
labels[key] = _union(labels.get(key, _EMPTY), _concat(left, loop, right))
|
|
76
|
+
labels = {key: value for key, value in labels.items() if state not in key}
|
|
77
|
+
remaining = others
|
|
78
|
+
|
|
79
|
+
return labels.get((start, final), _EMPTY).pattern()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _union(*terms: _Regex) -> _Regex:
|
|
83
|
+
parts: list[_Regex] = []
|
|
84
|
+
for term in terms:
|
|
85
|
+
if term == _EMPTY:
|
|
86
|
+
continue
|
|
87
|
+
if term.kind == "union":
|
|
88
|
+
parts.extend(term.parts)
|
|
89
|
+
else:
|
|
90
|
+
parts.append(term)
|
|
91
|
+
unique = sorted(set(parts), key=lambda part: part.pattern())
|
|
92
|
+
if not unique:
|
|
93
|
+
return _EMPTY
|
|
94
|
+
if len(unique) == 1:
|
|
95
|
+
return unique[0]
|
|
96
|
+
return _Regex("union", tuple(unique))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _concat(*terms: _Regex) -> _Regex:
|
|
100
|
+
parts: list[_Regex] = []
|
|
101
|
+
for term in terms:
|
|
102
|
+
if term == _EMPTY:
|
|
103
|
+
return _EMPTY
|
|
104
|
+
if term == _EPSILON:
|
|
105
|
+
continue
|
|
106
|
+
if term.kind == "concat":
|
|
107
|
+
parts.extend(term.parts)
|
|
108
|
+
else:
|
|
109
|
+
parts.append(term)
|
|
110
|
+
if not parts:
|
|
111
|
+
return _EPSILON
|
|
112
|
+
if len(parts) == 1:
|
|
113
|
+
return parts[0]
|
|
114
|
+
return _Regex("concat", tuple(parts))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _star(term: _Regex) -> _Regex:
|
|
118
|
+
if term in {_EMPTY, _EPSILON}:
|
|
119
|
+
return _EPSILON
|
|
120
|
+
if term.kind == "star":
|
|
121
|
+
return term
|
|
122
|
+
return _Regex("star", (term,))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _atom(term: _Regex) -> str:
|
|
126
|
+
if term.kind in {"literal", "epsilon", "empty", "star"}:
|
|
127
|
+
return term.pattern()
|
|
128
|
+
return "(?:" + term.pattern() + ")"
|
sofic/automata/rfsa.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Residual and canonical RFSA automata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from sofic.automata.languages.base import RegularLanguage
|
|
8
|
+
from sofic.automata.nfa import NFA
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sofic.automata.observation import ObservationTable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ResidualFiniteStateAutomaton(NFA):
|
|
15
|
+
"""NFA whose states accept residual languages of the recognized language."""
|
|
16
|
+
|
|
17
|
+
def validate(self) -> None:
|
|
18
|
+
super().validate()
|
|
19
|
+
# Phase 2: verify each state's right language is in Res(L(R))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CanonicalRFSA(ResidualFiniteStateAutomaton):
|
|
23
|
+
"""Canonical residual finite-state automaton R(L)."""
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_language(cls, language: RegularLanguage | NFA, **kwargs: Any) -> CanonicalRFSA:
|
|
27
|
+
from sofic.automata.canonical_extraction import canonical_rfsa_from_language
|
|
28
|
+
|
|
29
|
+
return canonical_rfsa_from_language(language)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_observation_table(cls, table: ObservationTable, **kwargs: Any) -> CanonicalRFSA:
|
|
33
|
+
from sofic.automata.canonical_extraction import observation_to_canonical_rfsa
|
|
34
|
+
|
|
35
|
+
return observation_to_canonical_rfsa(table)
|
sofic/automata/rpni.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Passive DFA learning via the RPNI state-merging algorithm."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sofic.automata.dfa import DFA
|
|
10
|
+
|
|
11
|
+
__all__ = ["learn_dfa_rpni"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class _PTANode:
|
|
16
|
+
"""Prefix-tree acceptor node."""
|
|
17
|
+
|
|
18
|
+
transitions: dict[Any, int] = field(default_factory=dict)
|
|
19
|
+
accepting: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_pta(
|
|
23
|
+
positive: Sequence[Sequence[Any]],
|
|
24
|
+
negative: Sequence[Sequence[Any]],
|
|
25
|
+
) -> tuple[list[_PTANode], dict[tuple[Any, ...], int]]:
|
|
26
|
+
"""Build a prefix-tree acceptor from labeled samples."""
|
|
27
|
+
nodes: list[_PTANode] = [_PTANode()]
|
|
28
|
+
index: dict[tuple[Any, ...], int] = {(): 0}
|
|
29
|
+
|
|
30
|
+
def ensure(prefix: tuple[Any, ...]) -> int:
|
|
31
|
+
if prefix not in index:
|
|
32
|
+
index[prefix] = len(nodes)
|
|
33
|
+
nodes.append(_PTANode())
|
|
34
|
+
return index[prefix]
|
|
35
|
+
|
|
36
|
+
for word in positive:
|
|
37
|
+
prefix: tuple[Any, ...] = ()
|
|
38
|
+
for symbol in word:
|
|
39
|
+
node_id = index[prefix]
|
|
40
|
+
next_prefix = prefix + (symbol,)
|
|
41
|
+
if symbol not in nodes[node_id].transitions:
|
|
42
|
+
nodes[node_id].transitions[symbol] = ensure(next_prefix)
|
|
43
|
+
prefix = next_prefix
|
|
44
|
+
nodes[index[prefix]].accepting = True
|
|
45
|
+
|
|
46
|
+
for word in negative:
|
|
47
|
+
prefix = ()
|
|
48
|
+
for symbol in word:
|
|
49
|
+
node_id = index[prefix]
|
|
50
|
+
next_prefix = prefix + (symbol,)
|
|
51
|
+
if symbol not in nodes[node_id].transitions:
|
|
52
|
+
nodes[node_id].transitions[symbol] = ensure(next_prefix)
|
|
53
|
+
prefix = next_prefix
|
|
54
|
+
nodes[index[prefix]].accepting = False
|
|
55
|
+
|
|
56
|
+
return nodes, index
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _collect_alphabet(
|
|
60
|
+
positive: Sequence[Sequence[Any]],
|
|
61
|
+
negative: Sequence[Sequence[Any]],
|
|
62
|
+
) -> tuple[Any, ...]:
|
|
63
|
+
symbols: set[Any] = set()
|
|
64
|
+
for word in positive:
|
|
65
|
+
symbols.update(word)
|
|
66
|
+
for word in negative:
|
|
67
|
+
symbols.update(word)
|
|
68
|
+
return tuple(sorted(symbols, key=repr))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _compatible_merge(
|
|
72
|
+
nodes: list[_PTANode],
|
|
73
|
+
left: int,
|
|
74
|
+
right: int,
|
|
75
|
+
merge_map: dict[int, int],
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""Return whether merging ``right`` into ``left`` is RPNI-compatible."""
|
|
78
|
+
if nodes[left].accepting != nodes[right].accepting:
|
|
79
|
+
return False
|
|
80
|
+
symbols = set(nodes[left].transitions) | set(nodes[right].transitions)
|
|
81
|
+
for symbol in symbols:
|
|
82
|
+
left_target = nodes[left].transitions.get(symbol)
|
|
83
|
+
right_target = nodes[right].transitions.get(symbol)
|
|
84
|
+
if left_target is None or right_target is None:
|
|
85
|
+
if left_target is not None or right_target is not None:
|
|
86
|
+
return False
|
|
87
|
+
continue
|
|
88
|
+
if merge_map.get(left_target, left_target) != merge_map.get(right_target, right_target):
|
|
89
|
+
return False
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _apply_merge(nodes: list[_PTANode], left: int, right: int, merge_map: dict[int, int]) -> None:
|
|
94
|
+
"""Merge PTA node ``right`` into ``left``."""
|
|
95
|
+
merge_map[right] = left
|
|
96
|
+
for symbol, target in nodes[right].transitions.items():
|
|
97
|
+
if symbol not in nodes[left].transitions:
|
|
98
|
+
nodes[left].transitions[symbol] = target
|
|
99
|
+
nodes[right].transitions.clear()
|
|
100
|
+
nodes[right].accepting = nodes[left].accepting
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _rpni_merge(nodes: list[_PTANode]) -> dict[int, int]:
|
|
104
|
+
"""Greedy RPNI state merging on a PTA."""
|
|
105
|
+
merge_map: dict[int, int] = {index: index for index in range(len(nodes))}
|
|
106
|
+
|
|
107
|
+
def representative(node_id: int) -> int:
|
|
108
|
+
while merge_map[node_id] != node_id:
|
|
109
|
+
merge_map[node_id] = merge_map[merge_map[node_id]]
|
|
110
|
+
node_id = merge_map[node_id]
|
|
111
|
+
return node_id
|
|
112
|
+
|
|
113
|
+
changed = True
|
|
114
|
+
while changed:
|
|
115
|
+
changed = False
|
|
116
|
+
reps = sorted({representative(index) for index in range(len(nodes))})
|
|
117
|
+
for left in reps:
|
|
118
|
+
for right in reps:
|
|
119
|
+
if left >= right:
|
|
120
|
+
continue
|
|
121
|
+
if not _compatible_merge(nodes, left, right, merge_map):
|
|
122
|
+
continue
|
|
123
|
+
_apply_merge(nodes, left, right, merge_map)
|
|
124
|
+
changed = True
|
|
125
|
+
break
|
|
126
|
+
if changed:
|
|
127
|
+
break
|
|
128
|
+
return merge_map
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pta_to_dfa(
|
|
132
|
+
nodes: list[_PTANode],
|
|
133
|
+
merge_map: dict[int, int],
|
|
134
|
+
alphabet: Sequence[Any],
|
|
135
|
+
) -> DFA:
|
|
136
|
+
def representative(node_id: int) -> int:
|
|
137
|
+
while merge_map[node_id] != node_id:
|
|
138
|
+
merge_map[node_id] = merge_map[merge_map[node_id]]
|
|
139
|
+
node_id = merge_map[node_id]
|
|
140
|
+
return node_id
|
|
141
|
+
|
|
142
|
+
blocks: dict[int, list[int]] = {}
|
|
143
|
+
for node_id in range(len(nodes)):
|
|
144
|
+
rep = representative(node_id)
|
|
145
|
+
blocks.setdefault(rep, []).append(node_id)
|
|
146
|
+
|
|
147
|
+
rep_to_label: dict[int, Hashable] = {}
|
|
148
|
+
for block_id, (rep, _members) in enumerate(sorted(blocks.items(), key=lambda item: min(item[1]))):
|
|
149
|
+
rep_to_label[rep] = f"q{block_id}"
|
|
150
|
+
|
|
151
|
+
dfa = DFA(input_alphabet=frozenset(alphabet))
|
|
152
|
+
for label in rep_to_label.values():
|
|
153
|
+
dfa.graph.add_state(label)
|
|
154
|
+
|
|
155
|
+
initial = rep_to_label[representative(0)]
|
|
156
|
+
dfa.initial_states = frozenset({initial})
|
|
157
|
+
|
|
158
|
+
accepting: set[Hashable] = set()
|
|
159
|
+
for rep, members in blocks.items():
|
|
160
|
+
label = rep_to_label[rep]
|
|
161
|
+
if any(nodes[member].accepting for member in members):
|
|
162
|
+
accepting.add(label)
|
|
163
|
+
rep_node = nodes[rep]
|
|
164
|
+
for symbol in alphabet:
|
|
165
|
+
target = rep_node.transitions.get(symbol)
|
|
166
|
+
if target is None:
|
|
167
|
+
continue
|
|
168
|
+
target_label = rep_to_label[representative(target)]
|
|
169
|
+
dfa.add_transition(label, target_label, symbol)
|
|
170
|
+
|
|
171
|
+
dfa.accepting_states = frozenset(accepting)
|
|
172
|
+
dfa.validate()
|
|
173
|
+
return dfa
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def learn_dfa_rpni(
|
|
177
|
+
positive: Sequence[Sequence[Any]],
|
|
178
|
+
negative: Sequence[Sequence[Any]] | None = None,
|
|
179
|
+
) -> DFA:
|
|
180
|
+
"""Learn a minimal compatible DFA from positive and negative samples.
|
|
181
|
+
|
|
182
|
+
Implements the classic RPNI greedy merge strategy on a prefix-tree
|
|
183
|
+
acceptor built from the sample sets.
|
|
184
|
+
"""
|
|
185
|
+
pos = [tuple(word) for word in positive]
|
|
186
|
+
neg = [tuple(word) for word in (negative or ())]
|
|
187
|
+
if not pos and not neg:
|
|
188
|
+
raise ValueError("at least one positive or negative sample is required")
|
|
189
|
+
|
|
190
|
+
alphabet = _collect_alphabet(pos, neg)
|
|
191
|
+
nodes, _index = _build_pta(pos, neg)
|
|
192
|
+
merge_map = _rpni_merge(nodes)
|
|
193
|
+
return _pta_to_dfa(nodes, merge_map, alphabet)
|