sofic 0.1.0__py3-none-any.whl

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