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/base.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Labeled finite automata base class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from collections.abc import Hashable, Iterator, Sequence
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from sofic.base import StateMachine
|
|
10
|
+
from sofic.graph import ATTR_SYMBOL, EPSILON
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sofic.automata.dfa import DFA
|
|
14
|
+
from sofic.automata.nfa import NFA
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LabeledAutomaton(StateMachine):
|
|
18
|
+
"""Finite-word recognizer over an input alphabet."""
|
|
19
|
+
|
|
20
|
+
input_alphabet: frozenset[Any]
|
|
21
|
+
initial_states: frozenset[Hashable]
|
|
22
|
+
accepting_states: frozenset[Hashable]
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
input_alphabet: frozenset[Any] | None = None,
|
|
27
|
+
initial_states: frozenset[Hashable] | None = None,
|
|
28
|
+
accepting_states: frozenset[Hashable] | None = None,
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(**kwargs)
|
|
32
|
+
self.input_alphabet = input_alphabet if input_alphabet is not None else frozenset()
|
|
33
|
+
self.initial_states = initial_states if initial_states is not None else frozenset()
|
|
34
|
+
self.accepting_states = accepting_states if accepting_states is not None else frozenset()
|
|
35
|
+
|
|
36
|
+
def validate(self) -> None:
|
|
37
|
+
for state in self.initial_states | self.accepting_states:
|
|
38
|
+
self._require(self.graph.has_state(state), f"missing state {state!r}")
|
|
39
|
+
for transition in self.transitions():
|
|
40
|
+
symbol = transition.data.get(ATTR_SYMBOL)
|
|
41
|
+
if symbol is not None and symbol is not EPSILON:
|
|
42
|
+
self._require(symbol in self.input_alphabet, f"symbol {symbol!r} not in input alphabet")
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def recognizes(self, word: Sequence[Any]) -> bool:
|
|
46
|
+
"""Return whether ``word`` is accepted."""
|
|
47
|
+
|
|
48
|
+
def words_of_length(self, length: int) -> Iterator[tuple[Any, ...]]:
|
|
49
|
+
"""Yield accepted words of exactly ``length`` symbols."""
|
|
50
|
+
from sofic.automata.enumeration import words_of_length
|
|
51
|
+
|
|
52
|
+
yield from words_of_length(self, length)
|
|
53
|
+
|
|
54
|
+
def iter_language(self, max_length: int | None = None) -> Iterator[tuple[Any, ...]]:
|
|
55
|
+
"""Yield accepted words in nondecreasing length order.
|
|
56
|
+
|
|
57
|
+
If ``max_length`` is omitted, the iterator is unbounded and may not
|
|
58
|
+
terminate for finite languages after yielding their last word.
|
|
59
|
+
"""
|
|
60
|
+
from sofic.automata.enumeration import iter_language
|
|
61
|
+
|
|
62
|
+
yield from iter_language(self, max_length=max_length)
|
|
63
|
+
|
|
64
|
+
def to_regex(self) -> str:
|
|
65
|
+
"""Return a regular expression for the accepted language."""
|
|
66
|
+
from sofic.automata.regex import automaton_to_regex
|
|
67
|
+
|
|
68
|
+
return automaton_to_regex(self)
|
|
69
|
+
|
|
70
|
+
def union(self, other: LabeledAutomaton) -> NFA:
|
|
71
|
+
"""Return an NFA recognizing the union of this language and ``other``."""
|
|
72
|
+
from sofic.automata.languages.automaton_ops import union_nfa
|
|
73
|
+
|
|
74
|
+
return union_nfa(self, other)
|
|
75
|
+
|
|
76
|
+
def intersection(self, other: LabeledAutomaton) -> DFA:
|
|
77
|
+
"""Return a DFA recognizing the intersection with ``other``."""
|
|
78
|
+
from sofic.automata.languages.automaton_ops import intersection_dfa
|
|
79
|
+
|
|
80
|
+
return intersection_dfa(self, other)
|
|
81
|
+
|
|
82
|
+
def intersect(self, other: LabeledAutomaton) -> DFA:
|
|
83
|
+
"""Alias for :meth:`intersection`."""
|
|
84
|
+
return self.intersection(other)
|
|
85
|
+
|
|
86
|
+
def complement(self, alphabet: frozenset[Any] | None = None) -> DFA:
|
|
87
|
+
"""Return a complete DFA recognizing the complement over ``alphabet``."""
|
|
88
|
+
from sofic.automata.languages.automaton_ops import complement_dfa
|
|
89
|
+
|
|
90
|
+
return complement_dfa(self, self.input_alphabet if alphabet is None else alphabet)
|
|
91
|
+
|
|
92
|
+
def difference(self, other: LabeledAutomaton, alphabet: frozenset[Any] | None = None) -> DFA:
|
|
93
|
+
"""Return a DFA recognizing this language minus ``other``."""
|
|
94
|
+
from sofic.automata.languages.automaton_ops import difference_dfa
|
|
95
|
+
|
|
96
|
+
return difference_dfa(self, other, alphabet=alphabet)
|
|
97
|
+
|
|
98
|
+
def concat(self, other: LabeledAutomaton) -> NFA:
|
|
99
|
+
"""Return an NFA recognizing concatenation with ``other``."""
|
|
100
|
+
from sofic.automata.languages.automaton_ops import concat_nfa
|
|
101
|
+
|
|
102
|
+
return concat_nfa(self, other)
|
|
103
|
+
|
|
104
|
+
def concatenate(self, other: LabeledAutomaton) -> NFA:
|
|
105
|
+
"""Alias for :meth:`concat`."""
|
|
106
|
+
return self.concat(other)
|
|
107
|
+
|
|
108
|
+
def kleene_star(self) -> NFA:
|
|
109
|
+
"""Return an NFA recognizing the Kleene star of this language."""
|
|
110
|
+
from sofic.automata.languages.automaton_ops import kleene_star_nfa
|
|
111
|
+
|
|
112
|
+
return kleene_star_nfa(self)
|
|
113
|
+
|
|
114
|
+
def star(self) -> NFA:
|
|
115
|
+
"""Alias for :meth:`kleene_star`."""
|
|
116
|
+
return self.kleene_star()
|
|
117
|
+
|
|
118
|
+
def is_deterministic(self) -> bool:
|
|
119
|
+
"""Return whether this automaton is DFA-deterministic."""
|
|
120
|
+
from sofic.properties import is_deterministic_automaton
|
|
121
|
+
|
|
122
|
+
return is_deterministic_automaton(self)
|
|
123
|
+
|
|
124
|
+
def delta(self, state: Hashable, symbol: Any) -> set[Hashable]:
|
|
125
|
+
targets: set[Hashable] = set()
|
|
126
|
+
for transition in self.graph.out_transitions(state):
|
|
127
|
+
if transition.data.get(ATTR_SYMBOL) == symbol:
|
|
128
|
+
targets.add(transition.target)
|
|
129
|
+
return targets
|
|
130
|
+
|
|
131
|
+
def epsilon_closure(self, states: set[Hashable]) -> set[Hashable]:
|
|
132
|
+
closure = set(states)
|
|
133
|
+
stack = list(states)
|
|
134
|
+
while stack:
|
|
135
|
+
state = stack.pop()
|
|
136
|
+
for target in self.delta(state, EPSILON):
|
|
137
|
+
if target not in closure:
|
|
138
|
+
closure.add(target)
|
|
139
|
+
stack.append(target)
|
|
140
|
+
return closure
|
|
141
|
+
|
|
142
|
+
def _run_nfa(self, word: Sequence[Any], start: set[Hashable] | None = None) -> set[Hashable]:
|
|
143
|
+
seed = set(self.initial_states) if start is None else set(start)
|
|
144
|
+
current = self.epsilon_closure(seed)
|
|
145
|
+
for symbol in word:
|
|
146
|
+
next_states: set[Hashable] = set()
|
|
147
|
+
for state in current:
|
|
148
|
+
next_states.update(self.delta(state, symbol))
|
|
149
|
+
current = self.epsilon_closure(next_states)
|
|
150
|
+
return current
|
|
151
|
+
|
|
152
|
+
def reverse(self) -> NFA:
|
|
153
|
+
"""Return an NFA recognizing the reversed language."""
|
|
154
|
+
from sofic.automata.nfa import NFA
|
|
155
|
+
|
|
156
|
+
return NFA(
|
|
157
|
+
input_alphabet=self.input_alphabet,
|
|
158
|
+
initial_states=frozenset(self.accepting_states),
|
|
159
|
+
accepting_states=frozenset(self.epsilon_closure(set(self.initial_states))),
|
|
160
|
+
graph=self.graph.reverse(),
|
|
161
|
+
)
|
sofic/automata/buchi.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Büchi automata for infinite-word recognition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sofic.automata.nfa import NFA
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BuchiAutomaton(NFA):
|
|
12
|
+
"""ω-automaton with Büchi acceptance on ultimately periodic inputs."""
|
|
13
|
+
|
|
14
|
+
def accepts_lasso(self, prefix: Sequence[Any], loop: Sequence[Any]) -> bool:
|
|
15
|
+
"""Accept if infinitely repeating ``loop`` after ``prefix`` visits accepting states infinitely often."""
|
|
16
|
+
from sofic.automata.buchi_simulation import accepts_lasso_buchi
|
|
17
|
+
|
|
18
|
+
return accepts_lasso_buchi(self, prefix, loop)
|
|
19
|
+
|
|
20
|
+
def accepts_omega(self, word: Sequence[Any]) -> bool:
|
|
21
|
+
from sofic.automata.buchi_simulation import accepts_omega_buchi
|
|
22
|
+
|
|
23
|
+
return accepts_omega_buchi(self, word)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Büchi acceptance for lasso and ultimately periodic ω-words."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sofic.automata.buchi import BuchiAutomaton
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def accepts_lasso_buchi(ba: BuchiAutomaton, prefix: Sequence[Any], loop: Sequence[Any]) -> bool:
|
|
12
|
+
"""Accept if repeating ``loop`` after ``prefix`` visits an accepting state infinitely often."""
|
|
13
|
+
post = ba._run_nfa(prefix)
|
|
14
|
+
if not post:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
reachable = _loop_closure(ba, post, loop)
|
|
18
|
+
candidates = reachable & ba.accepting_states
|
|
19
|
+
if not candidates:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
return any(_can_revisit_on_loop(ba, state, loop) for state in candidates)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def accepts_omega_buchi(ba: BuchiAutomaton, word: Sequence[Any]) -> bool:
|
|
26
|
+
"""Accept ultimately periodic ω-words represented as ``(prefix, loop)``."""
|
|
27
|
+
if (
|
|
28
|
+
len(word) == 2
|
|
29
|
+
and not isinstance(word, (str, bytes))
|
|
30
|
+
and isinstance(word[0], Sequence)
|
|
31
|
+
and not isinstance(word[0], (str, bytes))
|
|
32
|
+
and isinstance(word[1], Sequence)
|
|
33
|
+
and not isinstance(word[1], (str, bytes))
|
|
34
|
+
):
|
|
35
|
+
return accepts_lasso_buchi(ba, word[0], word[1])
|
|
36
|
+
raise NotImplementedError("BuchiAutomaton.accepts_omega supports ultimately periodic inputs as (prefix, loop) only")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _loop_closure(ba: BuchiAutomaton, start: set[Hashable], loop: Sequence[Any]) -> set[Hashable]:
|
|
40
|
+
"""States reachable from ``start`` by reading ``loop`` zero or more times."""
|
|
41
|
+
if not loop:
|
|
42
|
+
return set(start)
|
|
43
|
+
|
|
44
|
+
reachable = set(start)
|
|
45
|
+
frontier = set(start)
|
|
46
|
+
while frontier:
|
|
47
|
+
after = ba._run_nfa(loop, start=frontier)
|
|
48
|
+
new = after - reachable
|
|
49
|
+
if not new:
|
|
50
|
+
break
|
|
51
|
+
reachable |= new
|
|
52
|
+
frontier = new
|
|
53
|
+
return reachable
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _can_revisit_on_loop(ba: BuchiAutomaton, state: Hashable, loop: Sequence[Any]) -> bool:
|
|
57
|
+
"""Return whether ``state`` can be revisited by reading ``loop`` one or more times."""
|
|
58
|
+
if not loop:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
current = {state}
|
|
62
|
+
limit = max(len(list(ba.states())), 1) + 1
|
|
63
|
+
for _ in range(limit):
|
|
64
|
+
current = ba._run_nfa(loop, start=current)
|
|
65
|
+
if state in current:
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Dual constructions between canonical RFSA and maximized prime átomaton."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sofic.automata.atomaton import MaximizedPrimeAtomaton
|
|
6
|
+
from sofic.automata.rfsa import CanonicalRFSA
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def dual_atomaton_from_rfsa(rfsa: CanonicalRFSA) -> MaximizedPrimeAtomaton:
|
|
10
|
+
from sofic.automata.canonical_extraction import maximized_prime_atomaton_from_language
|
|
11
|
+
|
|
12
|
+
return maximized_prime_atomaton_from_language(rfsa)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def dual_rfsa_from_atomaton(atomaton: MaximizedPrimeAtomaton) -> CanonicalRFSA:
|
|
16
|
+
from sofic.automata.canonical_extraction import canonical_rfsa_from_language
|
|
17
|
+
|
|
18
|
+
return canonical_rfsa_from_language(atomaton)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Canonical automaton extraction from languages and observation tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sofic.automata.atomaton import Atomaton, MaximizedPrimeAtomaton
|
|
8
|
+
from sofic.automata.dfa import DFA
|
|
9
|
+
from sofic.automata.languages.automaton_ops import (
|
|
10
|
+
minimal_dfa_from_language,
|
|
11
|
+
)
|
|
12
|
+
from sofic.automata.languages.base import AutomatonLanguage, RegularLanguage, as_language
|
|
13
|
+
from sofic.automata.nfa import NFA
|
|
14
|
+
from sofic.automata.observation import ObservationTable
|
|
15
|
+
from sofic.automata.rfsa import CanonicalRFSA
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _language_automaton(language: RegularLanguage | NFA | DFA) -> NFA | DFA:
|
|
19
|
+
if isinstance(language, (NFA, DFA)):
|
|
20
|
+
return language
|
|
21
|
+
lang = as_language(language) # type: ignore[arg-type]
|
|
22
|
+
if isinstance(lang, AutomatonLanguage):
|
|
23
|
+
return lang.automaton
|
|
24
|
+
raise TypeError(f"cannot extract automaton from {type(language)!r}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def canonical_rfsa_from_language(language: RegularLanguage | NFA | DFA) -> CanonicalRFSA:
|
|
28
|
+
"""Build canonical RFSA from prime residuals of the language.
|
|
29
|
+
|
|
30
|
+
Phase 2 placeholder: currently reuses the minimal DFA structure directly.
|
|
31
|
+
"""
|
|
32
|
+
aut = _language_automaton(language)
|
|
33
|
+
dfa = minimal_dfa_from_language(aut)
|
|
34
|
+
return CanonicalRFSA(
|
|
35
|
+
input_alphabet=dfa.input_alphabet,
|
|
36
|
+
initial_states=dfa.initial_states,
|
|
37
|
+
accepting_states=dfa.accepting_states,
|
|
38
|
+
graph=dfa.graph.copy(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def atomaton_from_language(language: RegularLanguage | NFA | DFA) -> Atomaton:
|
|
43
|
+
"""Build átomaton via double-reversal pipeline."""
|
|
44
|
+
aut = _language_automaton(language)
|
|
45
|
+
dfa = minimal_dfa_from_language(aut)
|
|
46
|
+
rev = dfa.reverse().determinize().minimize()
|
|
47
|
+
atom = rev.reverse().determinize()
|
|
48
|
+
return Atomaton(
|
|
49
|
+
input_alphabet=atom.input_alphabet,
|
|
50
|
+
initial_states=atom.initial_states,
|
|
51
|
+
accepting_states=atom.accepting_states,
|
|
52
|
+
graph=atom.graph.copy(),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def maximized_prime_atomaton_from_language(language: RegularLanguage | NFA | DFA) -> MaximizedPrimeAtomaton:
|
|
57
|
+
"""Build maximized prime átomaton from a language.
|
|
58
|
+
|
|
59
|
+
Phase 2 placeholder: currently reuses the minimal DFA structure directly.
|
|
60
|
+
"""
|
|
61
|
+
aut = _language_automaton(language)
|
|
62
|
+
lang = AutomatonLanguage(minimal_dfa_from_language(aut))
|
|
63
|
+
return MaximizedPrimeAtomaton(
|
|
64
|
+
input_alphabet=lang.automaton.input_alphabet,
|
|
65
|
+
initial_states=lang.automaton.initial_states,
|
|
66
|
+
accepting_states=lang.automaton.accepting_states,
|
|
67
|
+
graph=lang.automaton.graph.copy(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def observation_to_minimal_dfa(table: ObservationTable) -> DFA:
|
|
72
|
+
"""Angluin-style DFA extraction from a closed, consistent table."""
|
|
73
|
+
access = sorted(table.access_words, key=lambda w: (len(w), w))
|
|
74
|
+
experiments = sorted(table.experiments, key=lambda w: (len(w), w))
|
|
75
|
+
|
|
76
|
+
def signature(word: tuple[Any, ...]) -> tuple[bool, ...]:
|
|
77
|
+
return tuple(table.membership.get(word + exp, False) for exp in experiments)
|
|
78
|
+
|
|
79
|
+
classes: dict[tuple[bool, ...], tuple[Any, ...]] = {}
|
|
80
|
+
for word in access:
|
|
81
|
+
sig = signature(word)
|
|
82
|
+
classes.setdefault(sig, word)
|
|
83
|
+
|
|
84
|
+
state_for_word = {word: classes[signature(word)] for word in access}
|
|
85
|
+
alphabet = _infer_alphabet(table)
|
|
86
|
+
dfa = DFA(
|
|
87
|
+
input_alphabet=alphabet,
|
|
88
|
+
initial_states=frozenset({()}),
|
|
89
|
+
accepting_states=frozenset(),
|
|
90
|
+
)
|
|
91
|
+
for word in access:
|
|
92
|
+
dfa.graph.add_state(state_for_word[word])
|
|
93
|
+
for word in access:
|
|
94
|
+
for symbol in alphabet:
|
|
95
|
+
target_word = word + (symbol,)
|
|
96
|
+
if target_word in state_for_word:
|
|
97
|
+
dfa.add_transition(state_for_word[word], state_for_word[target_word], symbol)
|
|
98
|
+
accepting = {state_for_word[word] for word in access if table.membership.get(word, False)}
|
|
99
|
+
dfa.accepting_states = frozenset(accepting)
|
|
100
|
+
return dfa
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def observation_to_canonical_rfsa(table: ObservationTable) -> CanonicalRFSA:
|
|
104
|
+
dfa = observation_to_minimal_dfa(table)
|
|
105
|
+
return canonical_rfsa_from_language(dfa)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def observation_to_atomaton(table: ObservationTable) -> Atomaton:
|
|
109
|
+
dfa = observation_to_minimal_dfa(table)
|
|
110
|
+
return atomaton_from_language(dfa)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def observation_to_maximized_prime_atomaton(table: ObservationTable) -> MaximizedPrimeAtomaton:
|
|
114
|
+
dfa = observation_to_minimal_dfa(table)
|
|
115
|
+
return maximized_prime_atomaton_from_language(dfa)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _infer_alphabet(table: ObservationTable) -> frozenset[Any]:
|
|
119
|
+
symbols: set[Any] = set()
|
|
120
|
+
for word in table.access_words | table.experiments:
|
|
121
|
+
symbols.update(word)
|
|
122
|
+
return frozenset(symbols)
|
sofic/automata/dfa.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Deterministic finite automata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from sofic.automata.base import LabeledAutomaton
|
|
9
|
+
from sofic.exceptions import NonDeterministicError
|
|
10
|
+
from sofic.graph import ATTR_SYMBOL, EPSILON
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sofic.automata.algorithms import MinimizationAlgorithm
|
|
14
|
+
from sofic.automata.nfa import NFA
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DFA(LabeledAutomaton):
|
|
18
|
+
"""Deterministic finite automaton."""
|
|
19
|
+
|
|
20
|
+
def validate(self) -> None:
|
|
21
|
+
super().validate()
|
|
22
|
+
self._check_determinism()
|
|
23
|
+
|
|
24
|
+
def _check_determinism(self) -> None:
|
|
25
|
+
if self.is_deterministic():
|
|
26
|
+
return
|
|
27
|
+
for transition in self.transitions():
|
|
28
|
+
if transition.data.get(ATTR_SYMBOL) is EPSILON:
|
|
29
|
+
raise NonDeterministicError("DFA does not allow epsilon transitions")
|
|
30
|
+
if len(self.initial_states) != 1:
|
|
31
|
+
raise NonDeterministicError("DFA requires exactly one initial state")
|
|
32
|
+
seen: dict[tuple[Hashable, Any], Hashable] = {}
|
|
33
|
+
for transition in self.transitions():
|
|
34
|
+
symbol = transition.data.get(ATTR_SYMBOL)
|
|
35
|
+
if symbol is None:
|
|
36
|
+
continue
|
|
37
|
+
key = (transition.source, symbol)
|
|
38
|
+
if key in seen and seen[key] != transition.target:
|
|
39
|
+
raise NonDeterministicError(f"non-deterministic transition on {key}")
|
|
40
|
+
seen[key] = transition.target
|
|
41
|
+
raise NonDeterministicError("DFA determinism check failed")
|
|
42
|
+
|
|
43
|
+
def add_transition(self, source: Hashable, target: Hashable, symbol: Any, **attrs: Any) -> int:
|
|
44
|
+
if symbol is EPSILON:
|
|
45
|
+
raise NonDeterministicError("DFA does not allow epsilon transitions")
|
|
46
|
+
key = (source, symbol)
|
|
47
|
+
for transition in self.graph.out_transitions(source):
|
|
48
|
+
if transition.data.get(ATTR_SYMBOL) == symbol and transition.target != target:
|
|
49
|
+
raise NonDeterministicError(f"non-deterministic transition on {key}")
|
|
50
|
+
return self.graph.add_transition(source, target, **{ATTR_SYMBOL: symbol, **attrs})
|
|
51
|
+
|
|
52
|
+
def recognizes(self, word: Sequence[Any]) -> bool:
|
|
53
|
+
if not self.initial_states:
|
|
54
|
+
return False
|
|
55
|
+
state = next(iter(self.initial_states))
|
|
56
|
+
for symbol in word:
|
|
57
|
+
successors = self.delta(state, symbol)
|
|
58
|
+
if len(successors) != 1:
|
|
59
|
+
return False
|
|
60
|
+
state = next(iter(successors))
|
|
61
|
+
return state in self.accepting_states
|
|
62
|
+
|
|
63
|
+
def determinize(self) -> DFA:
|
|
64
|
+
from sofic.automata.algorithms import trim
|
|
65
|
+
|
|
66
|
+
return trim(self)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_nfa(cls, nfa: NFA, **kwargs: Any) -> DFA:
|
|
70
|
+
from sofic.automata.algorithms import determinize
|
|
71
|
+
from sofic.automata.nfa import NFA as _NFA
|
|
72
|
+
|
|
73
|
+
if not isinstance(nfa, _NFA):
|
|
74
|
+
raise TypeError("from_nfa requires an NFA")
|
|
75
|
+
return determinize(nfa)
|
|
76
|
+
|
|
77
|
+
def minimize(
|
|
78
|
+
self,
|
|
79
|
+
algorithm: MinimizationAlgorithm = "hopcroft",
|
|
80
|
+
*,
|
|
81
|
+
alphabet: frozenset[Any] | None = None,
|
|
82
|
+
) -> DFA:
|
|
83
|
+
from sofic.automata.algorithms import minimize
|
|
84
|
+
|
|
85
|
+
return minimize(self, algorithm=algorithm, alphabet=alphabet)
|
sofic/automata/dfasat.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Exact minimal-DFA identification via a SAT encoding (DFASAT-style).
|
|
2
|
+
|
|
3
|
+
Given labeled positive/negative sample words, this finds the **minimal** number
|
|
4
|
+
of states of any DFA consistent with the sample -- the exact optimum that the
|
|
5
|
+
RPNI/EDSM heuristics only approximate. Following Heule & Verwer
|
|
6
|
+
:cite:`HeuleVerwer2010`, the augmented prefix-tree acceptor (APTA) is translated
|
|
7
|
+
into a graph-colouring SAT instance -- one Boolean per (APTA node, colour), plus
|
|
8
|
+
accepting-colour and transition variables -- and an external SAT solver is asked
|
|
9
|
+
whether a ``k``-colouring exists. The search runs ``k`` upward from a lower bound
|
|
10
|
+
to the EDSM upper bound (:func:`sofic.automata.edsm.learn_dfa_edsm`); the first
|
|
11
|
+
satisfiable ``k`` is the provably minimal DFA.
|
|
12
|
+
|
|
13
|
+
This requires the optional `python-sat <https://pysathq.github.io/>`_ dependency
|
|
14
|
+
(``pip install sofic[sat]``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Hashable, Sequence
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from sofic.automata.dfa import DFA
|
|
23
|
+
from sofic.automata.edsm import _ACCEPT, _REJECT, _build_apta, _collect_alphabet, learn_dfa_edsm
|
|
24
|
+
|
|
25
|
+
__all__ = ["learn_dfa_sat"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_pysat() -> Any:
|
|
29
|
+
try:
|
|
30
|
+
from pysat.formula import CNF, IDPool
|
|
31
|
+
from pysat.solvers import Solver
|
|
32
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"learn_dfa_sat requires the optional 'python-sat' dependency; install with pip install sofic[sat]"
|
|
35
|
+
) from exc
|
|
36
|
+
return CNF, IDPool, Solver
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _encode(
|
|
40
|
+
label: Sequence[int],
|
|
41
|
+
trans: Sequence[dict[Any, int]],
|
|
42
|
+
alphabet: Sequence[Any],
|
|
43
|
+
k: int,
|
|
44
|
+
cnf_cls: Any,
|
|
45
|
+
pool: Any,
|
|
46
|
+
) -> Any:
|
|
47
|
+
"""Build the graph-colouring CNF for a ``k``-state consistent DFA."""
|
|
48
|
+
cnf = cnf_cls()
|
|
49
|
+
n = len(label)
|
|
50
|
+
sym_index = {symbol: s for s, symbol in enumerate(alphabet)}
|
|
51
|
+
|
|
52
|
+
def x(v: int, i: int) -> int:
|
|
53
|
+
return pool.id(("x", v, i))
|
|
54
|
+
|
|
55
|
+
def a(i: int) -> int:
|
|
56
|
+
return pool.id(("a", i))
|
|
57
|
+
|
|
58
|
+
def y(s: int, i: int, j: int) -> int:
|
|
59
|
+
return pool.id(("y", s, i, j))
|
|
60
|
+
|
|
61
|
+
# (1) each APTA node gets at least one colour.
|
|
62
|
+
for v in range(n):
|
|
63
|
+
cnf.append([x(v, i) for i in range(k)])
|
|
64
|
+
# (2) ... and at most one.
|
|
65
|
+
for v in range(n):
|
|
66
|
+
for i in range(k):
|
|
67
|
+
for j in range(i + 1, k):
|
|
68
|
+
cnf.append([-x(v, i), -x(v, j)])
|
|
69
|
+
# (3) accepting / rejecting consistency of the colour.
|
|
70
|
+
for v in range(n):
|
|
71
|
+
if label[v] == _ACCEPT:
|
|
72
|
+
for i in range(k):
|
|
73
|
+
cnf.append([-x(v, i), a(i)])
|
|
74
|
+
elif label[v] == _REJECT:
|
|
75
|
+
for i in range(k):
|
|
76
|
+
cnf.append([-x(v, i), -a(i)])
|
|
77
|
+
# symmetry break: the root (APTA node 0) is colour 0.
|
|
78
|
+
cnf.append([x(0, 0)])
|
|
79
|
+
# (4) APTA edges force the coloured transition relation.
|
|
80
|
+
for v in range(n):
|
|
81
|
+
for symbol, w in trans[v].items():
|
|
82
|
+
s = sym_index[symbol]
|
|
83
|
+
for i in range(k):
|
|
84
|
+
for j in range(k):
|
|
85
|
+
cnf.append([-x(v, i), -x(w, j), y(s, i, j)])
|
|
86
|
+
# (5) the transition relation is a partial function (at most one target).
|
|
87
|
+
for s in range(len(alphabet)):
|
|
88
|
+
for i in range(k):
|
|
89
|
+
for j1 in range(k):
|
|
90
|
+
for j2 in range(j1 + 1, k):
|
|
91
|
+
cnf.append([-y(s, i, j1), -y(s, i, j2)])
|
|
92
|
+
|
|
93
|
+
return cnf, x, a, y, sym_index
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decode(
|
|
97
|
+
model: set[int],
|
|
98
|
+
label: Sequence[int],
|
|
99
|
+
trans: Sequence[dict[Any, int]],
|
|
100
|
+
alphabet: Sequence[Any],
|
|
101
|
+
k: int,
|
|
102
|
+
x: Any,
|
|
103
|
+
a: Any,
|
|
104
|
+
y: Any,
|
|
105
|
+
sym_index: dict[Any, int],
|
|
106
|
+
) -> DFA:
|
|
107
|
+
n = len(label)
|
|
108
|
+
colour_of = {}
|
|
109
|
+
for v in range(n):
|
|
110
|
+
for i in range(k):
|
|
111
|
+
if x(v, i) in model:
|
|
112
|
+
colour_of[v] = i
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
used = sorted(set(colour_of.values()) | {0})
|
|
116
|
+
names: dict[int, Hashable] = {colour: f"q{colour}" for colour in used}
|
|
117
|
+
|
|
118
|
+
dfa = DFA(input_alphabet=frozenset(alphabet))
|
|
119
|
+
for name in names.values():
|
|
120
|
+
dfa.graph.add_state(name)
|
|
121
|
+
|
|
122
|
+
seen: set[tuple[int, Any]] = set()
|
|
123
|
+
for v in range(n):
|
|
124
|
+
i = colour_of[v]
|
|
125
|
+
for symbol, w in trans[v].items():
|
|
126
|
+
j = colour_of[w]
|
|
127
|
+
key = (i, symbol)
|
|
128
|
+
if key in seen:
|
|
129
|
+
continue
|
|
130
|
+
seen.add(key)
|
|
131
|
+
dfa.add_transition(names[i], names[j], symbol)
|
|
132
|
+
|
|
133
|
+
dfa.initial_states = frozenset({names[colour_of[0]]})
|
|
134
|
+
dfa.accepting_states = frozenset(names[i] for i in used if a(i) in model)
|
|
135
|
+
dfa.validate()
|
|
136
|
+
return dfa
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def learn_dfa_sat(
|
|
140
|
+
positive: Sequence[Sequence[Any]],
|
|
141
|
+
negative: Sequence[Sequence[Any]] | None = None,
|
|
142
|
+
*,
|
|
143
|
+
lower_bound: int = 1,
|
|
144
|
+
upper_bound: int | None = None,
|
|
145
|
+
solver_name: str = "g3",
|
|
146
|
+
) -> DFA:
|
|
147
|
+
"""Learn the exact minimal DFA consistent with labeled samples via SAT.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
positive
|
|
152
|
+
Words that must be accepted.
|
|
153
|
+
negative
|
|
154
|
+
Words that must be rejected.
|
|
155
|
+
lower_bound
|
|
156
|
+
Smallest state count to try (default ``1``).
|
|
157
|
+
upper_bound
|
|
158
|
+
Largest state count to try. Defaults to the number of states of the
|
|
159
|
+
EDSM hypothesis (:func:`sofic.automata.edsm.learn_dfa_edsm`), which is a
|
|
160
|
+
valid upper bound on the minimum.
|
|
161
|
+
solver_name
|
|
162
|
+
Any `python-sat <https://pysathq.github.io/>`_ solver name (default
|
|
163
|
+
``"g3"`` for Glucose 3).
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
DFA
|
|
168
|
+
The provably minimal DFA consistent with the sample
|
|
169
|
+
:cite:`HeuleVerwer2010`.
|
|
170
|
+
"""
|
|
171
|
+
cnf_cls, id_pool, solver_cls = _require_pysat()
|
|
172
|
+
|
|
173
|
+
pos = [tuple(word) for word in positive]
|
|
174
|
+
neg = [tuple(word) for word in (negative or ())]
|
|
175
|
+
if not pos and not neg:
|
|
176
|
+
raise ValueError("at least one positive or negative sample is required")
|
|
177
|
+
|
|
178
|
+
alphabet = _collect_alphabet(pos, neg)
|
|
179
|
+
label, trans = _build_apta(pos, neg)
|
|
180
|
+
|
|
181
|
+
if upper_bound is None:
|
|
182
|
+
upper_bound = len(list(learn_dfa_edsm(pos, neg).states()))
|
|
183
|
+
lower_bound = max(1, int(lower_bound))
|
|
184
|
+
if upper_bound < lower_bound:
|
|
185
|
+
upper_bound = lower_bound
|
|
186
|
+
|
|
187
|
+
for k in range(lower_bound, upper_bound + 1):
|
|
188
|
+
pool = id_pool()
|
|
189
|
+
cnf, x, a, y, sym_index = _encode(label, trans, alphabet, k, cnf_cls, pool)
|
|
190
|
+
with solver_cls(name=solver_name, bootstrap_with=cnf.clauses) as solver:
|
|
191
|
+
if solver.solve():
|
|
192
|
+
model = {lit for lit in solver.get_model() if lit > 0}
|
|
193
|
+
return _decode(model, label, trans, alphabet, k, x, a, y, sym_index)
|
|
194
|
+
|
|
195
|
+
raise RuntimeError(f"no consistent DFA with at most {upper_bound} states (unexpected; check the sample)")
|