sofic 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. sofic/__init__.py +185 -0
  2. sofic/automata/__init__.py +207 -0
  3. sofic/automata/_config_simulation.py +40 -0
  4. sofic/automata/active.py +611 -0
  5. sofic/automata/alergia.py +222 -0
  6. sofic/automata/algorithms.py +376 -0
  7. sofic/automata/atomaton.py +58 -0
  8. sofic/automata/base.py +161 -0
  9. sofic/automata/buchi.py +23 -0
  10. sofic/automata/buchi_simulation.py +67 -0
  11. sofic/automata/canonical_dual.py +18 -0
  12. sofic/automata/canonical_extraction.py +122 -0
  13. sofic/automata/dfa.py +85 -0
  14. sofic/automata/dfasat.py +195 -0
  15. sofic/automata/edsm.py +219 -0
  16. sofic/automata/enumeration.py +44 -0
  17. sofic/automata/icdfa.py +421 -0
  18. sofic/automata/idfa.py +363 -0
  19. sofic/automata/languages/__init__.py +39 -0
  20. sofic/automata/languages/_quotient_utils.py +64 -0
  21. sofic/automata/languages/atoms.py +31 -0
  22. sofic/automata/languages/automaton_ops.py +243 -0
  23. sofic/automata/languages/base.py +67 -0
  24. sofic/automata/languages/operations.py +78 -0
  25. sofic/automata/languages/quotients.py +66 -0
  26. sofic/automata/languages/residuals.py +25 -0
  27. sofic/automata/learning.py +79 -0
  28. sofic/automata/nfa.py +39 -0
  29. sofic/automata/nwa.py +343 -0
  30. sofic/automata/nwa_simulation.py +56 -0
  31. sofic/automata/observation.py +40 -0
  32. sofic/automata/papni.py +301 -0
  33. sofic/automata/regex.py +128 -0
  34. sofic/automata/rfsa.py +35 -0
  35. sofic/automata/rpni.py +193 -0
  36. sofic/automata/subsequential.py +201 -0
  37. sofic/automata/transducer_operations.py +350 -0
  38. sofic/automata/transducer_simulation.py +150 -0
  39. sofic/automata/transducers.py +365 -0
  40. sofic/automata/unifilar.py +107 -0
  41. sofic/automata/vpa.py +1373 -0
  42. sofic/automata/vpa_simulation.py +53 -0
  43. sofic/base.py +153 -0
  44. sofic/core.py +47 -0
  45. sofic/examples/__init__.py +86 -0
  46. sofic/examples/epsilon_machines.py +1089 -0
  47. sofic/examples/processes.py +1491 -0
  48. sofic/examples/shifts.py +144 -0
  49. sofic/exceptions.py +33 -0
  50. sofic/generators/__init__.py +115 -0
  51. sofic/generators/_word_measures.py +94 -0
  52. sofic/generators/alternative_complexity.py +104 -0
  53. sofic/generators/base.py +327 -0
  54. sofic/generators/bidirectional_construction.py +717 -0
  55. sofic/generators/bidirectional_epsilon_machine.py +689 -0
  56. sofic/generators/block_convergence.py +668 -0
  57. sofic/generators/block_entropy.py +578 -0
  58. sofic/generators/channel_measures.py +75 -0
  59. sofic/generators/conversions.py +182 -0
  60. sofic/generators/directional_flow.py +245 -0
  61. sofic/generators/edge_emissions.py +36 -0
  62. sofic/generators/edge_machine.py +178 -0
  63. sofic/generators/epsilon_construction.py +193 -0
  64. sofic/generators/epsilon_inference.py +703 -0
  65. sofic/generators/epsilon_machine.py +557 -0
  66. sofic/generators/epsilon_transducer.py +168 -0
  67. sofic/generators/epsilon_transducer_construction.py +185 -0
  68. sofic/generators/epsilon_transducer_inference.py +499 -0
  69. sofic/generators/hmm_inference.py +719 -0
  70. sofic/generators/information_diagram.py +428 -0
  71. sofic/generators/lumping.py +447 -0
  72. sofic/generators/markov.py +100 -0
  73. sofic/generators/mealy.py +156 -0
  74. sofic/generators/measures.py +257 -0
  75. sofic/generators/minimal_generative_model.py +821 -0
  76. sofic/generators/mixed_state.py +250 -0
  77. sofic/generators/mixed_state_construction.py +163 -0
  78. sofic/generators/moore.py +75 -0
  79. sofic/generators/nmachine.py +78 -0
  80. sofic/generators/nmachine_construction.py +70 -0
  81. sofic/generators/pfa.py +100 -0
  82. sofic/generators/prob.py +291 -0
  83. sofic/generators/process_equivalence.py +207 -0
  84. sofic/generators/quasi_inference.py +74 -0
  85. sofic/generators/quasi_realization.py +97 -0
  86. sofic/generators/reversal.py +66 -0
  87. sofic/generators/stack_hmm.py +426 -0
  88. sofic/generators/stack_inference.py +509 -0
  89. sofic/generators/stationary.py +134 -0
  90. sofic/generators/stochastic.py +65 -0
  91. sofic/generators/synchronization.py +407 -0
  92. sofic/generators/topological_epsilon_enumeration.py +349 -0
  93. sofic/generators/words.py +226 -0
  94. sofic/graph.py +135 -0
  95. sofic/indexing.py +31 -0
  96. sofic/inference/__init__.py +45 -0
  97. sofic/inference/bayesian/__init__.py +68 -0
  98. sofic/inference/bayesian/comparison.py +199 -0
  99. sofic/inference/bayesian/counts.py +219 -0
  100. sofic/inference/bayesian/diversity.py +254 -0
  101. sofic/inference/bayesian/epsilon.py +270 -0
  102. sofic/inference/bayesian/hdp_hmm.py +340 -0
  103. sofic/inference/bayesian/markov.py +294 -0
  104. sofic/inference/bayesian/pymc_backend.py +71 -0
  105. sofic/inference/bayesian/stack_hmm.py +215 -0
  106. sofic/inference/model_selection.py +365 -0
  107. sofic/inference/spectral.py +564 -0
  108. sofic/operations.py +16 -0
  109. sofic/properties.py +339 -0
  110. sofic/serialization.py +450 -0
  111. sofic/shifts/__init__.py +48 -0
  112. sofic/shifts/algorithms.py +84 -0
  113. sofic/shifts/base.py +49 -0
  114. sofic/shifts/cover_construction.py +76 -0
  115. sofic/shifts/covers.py +47 -0
  116. sofic/shifts/dyck_algorithms.py +100 -0
  117. sofic/shifts/dyck_enumeration.py +275 -0
  118. sofic/shifts/markov_dyck.py +172 -0
  119. sofic/shifts/parry_construction.py +82 -0
  120. sofic/shifts/sft.py +104 -0
  121. sofic/shifts/sft_construction.py +52 -0
  122. sofic/shifts/sliding_block_code.py +156 -0
  123. sofic/shifts/sofic.py +111 -0
  124. sofic/shifts/sofic_dyck.py +110 -0
  125. sofic/shifts/sofic_relation.py +64 -0
  126. sofic/shifts/textile.py +104 -0
  127. sofic/shifts/tmc.py +46 -0
  128. sofic/shifts/tmc_construction.py +58 -0
  129. sofic/shifts/topological_anatomy.py +150 -0
  130. sofic/states.py +27 -0
  131. sofic/testing/__init__.py +8 -0
  132. sofic/testing/strategies.py +154 -0
  133. sofic/viz/__init__.py +16 -0
  134. sofic/viz/_context.py +345 -0
  135. sofic/viz/_edge.py +216 -0
  136. sofic/viz/_format.py +89 -0
  137. sofic/viz/_labels.py +34 -0
  138. sofic/viz/_names.py +17 -0
  139. sofic/viz/_rational.py +20 -0
  140. sofic/viz/_tikz_compile.py +177 -0
  141. sofic/viz/_tikz_format.py +122 -0
  142. sofic/viz/_tikz_layout.py +218 -0
  143. sofic/viz/assets/vaucanson.tikz +71 -0
  144. sofic/viz/graphviz.py +158 -0
  145. sofic/viz/idiagram.py +350 -0
  146. sofic/viz/tikz.py +381 -0
  147. sofic-0.1.0.dist-info/METADATA +444 -0
  148. sofic-0.1.0.dist-info/RECORD +150 -0
  149. sofic-0.1.0.dist-info/WHEEL +4 -0
  150. sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
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
+ )
@@ -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)
@@ -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)")