morphata 1.0.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.
morphata/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """Morphata: Flexible automata representations for regular and omega-regular languages.
2
+
3
+ This package provides:
4
+ - Pure structural automaton interfaces (Automaton, Domain, TransitionRelation)
5
+ - Acceptance condition expressions (morphata.acceptance)
6
+ - HOA format parser (morphata.hoa.parser)
7
+ - Example implementations (morphata.examples)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Hashable, Mapping
13
+ from collections.abc import Set as AbstractSet
14
+ from dataclasses import dataclass
15
+
16
+ from typing_extensions import override
17
+
18
+ from morphata.spec import AcceptanceCondition as AcceptanceCondition
19
+ from morphata.spec import (
20
+ AlternatingTransitions,
21
+ BoolExpr,
22
+ DeterministicTransitions,
23
+ NonDeterministicTransitions,
24
+ UniversalTransitions,
25
+ )
26
+ from morphata.spec import Automaton as Automaton
27
+ from morphata.spec import Domain as Domain
28
+ from morphata.spec import InitialState as InitialState
29
+ from morphata.spec import TransitionRelation as TransitionRelation
30
+
31
+
32
+ @dataclass
33
+ class DeterministicTransitionRelation[Q: Hashable, S: Hashable](DeterministicTransitions[Q, S]):
34
+ data: Mapping[Q, Mapping[S, Q]]
35
+
36
+ @override
37
+ def __call__(self, state: Q, symbol: S) -> Q:
38
+ return self.data[state][symbol]
39
+
40
+
41
+ @dataclass
42
+ class NonDeterministicTransitionRelation[Q: Hashable, S: Hashable](NonDeterministicTransitions[Q, S]):
43
+ data: Mapping[Q, Mapping[S, AbstractSet[Q]]]
44
+
45
+ @override
46
+ def __call__(self, state: Q, symbol: S) -> AbstractSet[Q]:
47
+ return self.data[state][symbol]
48
+
49
+
50
+ @dataclass
51
+ class UniversalTransitionRelation[Q: Hashable, S: Hashable](UniversalTransitions[Q, S]):
52
+ data: Mapping[Q, Mapping[S, AbstractSet[Q]]]
53
+
54
+ @override
55
+ def __call__(self, state: Q, symbol: S) -> AbstractSet[Q]:
56
+ return self.data[state][symbol]
57
+
58
+
59
+ @dataclass
60
+ class AlternatingTransitionRelation[Q: Hashable, S: Hashable](AlternatingTransitions[Q, S]):
61
+ data: Mapping[Q, Mapping[S, BoolExpr[Q]]]
62
+
63
+ @override
64
+ def __call__(self, state: Q, symbol: S) -> BoolExpr[Q]:
65
+ return self.data[state][symbol]
66
+
67
+
68
+ __all__ = [
69
+ "Domain",
70
+ "InitialState",
71
+ "AcceptanceCondition",
72
+ "TransitionRelation",
73
+ "DeterministicTransitions",
74
+ "NonDeterministicTransitions",
75
+ "UniversalTransitions",
76
+ "AlternatingTransitions",
77
+ "Automaton",
78
+ ]
morphata/acceptance.py ADDED
@@ -0,0 +1,222 @@
1
+ """Concrete classes for acceptance conditions
2
+
3
+ Provides abstract and concrete acceptance condition classes for omega-automata
4
+ and finite-word automata.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typing as ty
10
+ from collections.abc import Iterable
11
+
12
+ from attrs import frozen
13
+ from typing_extensions import overload
14
+
15
+ from morphata.spec import AcceptanceCondition, State
16
+
17
+
18
+ @overload
19
+ def acc_from_name(name: ty.Literal["Finite"], arg: Iterable[State], /) -> Finite[State]: ...
20
+
21
+
22
+ @overload
23
+ def acc_from_name(name: ty.Literal["Buchi"], arg: Iterable[State], /) -> Buchi[State]: ...
24
+
25
+
26
+ @overload
27
+ def acc_from_name(name: ty.Literal["co-Buchi"], arg: Iterable[State], /) -> CoBuchi[State]: ...
28
+
29
+
30
+ @overload
31
+ def acc_from_name(name: ty.Literal["generalized-Buchi"], /, *args: Iterable[State]) -> GeneralizedBuchi[State]: ...
32
+
33
+
34
+ @overload
35
+ def acc_from_name(name: ty.Literal["generalized-co-Buchi"], /, *args: Iterable[State]) -> GeneralizedCoBuchi[State]: ...
36
+
37
+
38
+ @overload
39
+ def acc_from_name(name: ty.Literal["Muller"], /, *args: Iterable[State]) -> Muller[State]: ...
40
+
41
+
42
+ @overload
43
+ def acc_from_name(name: ty.Literal["Streett"], /, *args: AccPair[State]) -> Streett[State]: ...
44
+
45
+
46
+ @overload
47
+ def acc_from_name(name: ty.Literal["Rabin"], /, *args: AccPair[State]) -> Rabin[State]: ...
48
+
49
+
50
+ def acc_from_name(name: str, /, *args) -> AcceptanceCondition[State]:
51
+ match name:
52
+ case "Finite":
53
+ assert len(args) == 1, "Finite acceptance condition requires 1 accepting set"
54
+ arg = args[0]
55
+ assert isinstance(arg, Iterable)
56
+ return Finite(frozenset(arg))
57
+ case "Buchi":
58
+ assert len(args) == 1, "Buchi acceptance condition requires 1 accepting set"
59
+ arg = args[0]
60
+ assert isinstance(arg, Iterable)
61
+ return Buchi(frozenset(arg))
62
+ case "generalized-Buchi":
63
+ assert len(args) >= 0 and all(isinstance(arg, Iterable) for arg in args), (
64
+ "Generalized Buchi condition needs a list of accepting sets"
65
+ )
66
+ return GeneralizedBuchi(tuple(frozenset(arg) for arg in args))
67
+ case "co-Buchi":
68
+ assert len(args) == 1, "CoBuchi acceptance condition requires 1 non-accepting set"
69
+ arg = args[0]
70
+ assert isinstance(arg, Iterable)
71
+ return CoBuchi(frozenset(arg))
72
+ case "generalized-co-Buchi":
73
+ assert len(args) >= 0 and all(isinstance(arg, Iterable) for arg in args), (
74
+ "Generalized CoBuchi condition needs a list of non-accepting sets"
75
+ )
76
+ return GeneralizedCoBuchi(tuple(frozenset(arg) for arg in args))
77
+ case "Streett":
78
+ assert len(args) >= 0 and all(isinstance(arg, tuple) and len(arg) == 2 for arg in args), (
79
+ "Streett condition needs a list of 2-tuples of rejecting (Fin) and accepting (Inf) sets"
80
+ )
81
+ return Streett(tuple(AccPair(*(frozenset(s) for s in arg)) for arg in args))
82
+ case "Rabin":
83
+ assert len(args) >= 0 and all(isinstance(arg, tuple) and len(arg) == 2 for arg in args), (
84
+ "Rabin condition needs a list of 2-tuples of rejecting (Fin) and accepting (Inf) sets"
85
+ )
86
+ return Rabin(tuple(AccPair(*(frozenset(s) for s in arg)) for arg in args))
87
+
88
+ case _:
89
+ raise ValueError(f"Unknown/unsupported named acceptance condition: {name} {args=}")
90
+
91
+
92
+ # @frozen
93
+ # class GenericCondition(AcceptanceCondition[State]):
94
+ # acceptance_sets: tuple[frozenset[State], ...]
95
+ # expr: AccExpr
96
+
97
+ # @override
98
+ # def __len__(self) -> int:
99
+ # return self.num_sets
100
+
101
+ # @override
102
+ # def to_expr(self) -> AccExpr:
103
+ # return self.expr
104
+
105
+
106
+ @frozen
107
+ class Finite(AcceptanceCondition[State]):
108
+ """Finite-word acceptance condition.
109
+
110
+ For finite automata, acceptance means reaching a final state. This uses
111
+ a single acceptance set to mark final states.
112
+ """
113
+
114
+ accepting: frozenset[State]
115
+
116
+ @classmethod
117
+ def is_omega_regular(cls) -> bool:
118
+ """Finite-word acceptance is not omega-regular."""
119
+ return False
120
+
121
+
122
+ @frozen
123
+ class Buchi(AcceptanceCondition[State]):
124
+ """Büchi condition: a run, r, is accepting iff inf(r) intersects with `accepting`"""
125
+
126
+ accepting: frozenset[State]
127
+
128
+ @classmethod
129
+ def is_omega_regular(cls) -> bool:
130
+ """Büchi acceptance is omega-regular."""
131
+ return True
132
+
133
+
134
+ @frozen
135
+ class GeneralizedBuchi(AcceptanceCondition[State]):
136
+ """Generalized Büchi condition: a run, r, is accepting iff inf(r) intersects with `accepting[i]` for some i"""
137
+
138
+ accepting: tuple[frozenset[State], ...]
139
+
140
+ @classmethod
141
+ def is_omega_regular(cls) -> bool:
142
+ """Generalized Büchi acceptance is omega-regular."""
143
+ return True
144
+
145
+
146
+ @frozen
147
+ class CoBuchi(AcceptanceCondition[State]):
148
+ """co-Büchi condition: a run, r, is accepting iff inf(r) does not intersect with `rejecting`"""
149
+
150
+ rejecting: frozenset[State]
151
+
152
+ @classmethod
153
+ def is_omega_regular(cls) -> bool:
154
+ """co-Büchi acceptance is omega-regular."""
155
+ return True
156
+
157
+
158
+ @frozen
159
+ class GeneralizedCoBuchi(AcceptanceCondition[State]):
160
+ """Generalized co-Büchi condition: a run, r, is accepting iff inf(r) does not intersect with `rejecting[i]` for some i"""
161
+
162
+ rejecting: tuple[frozenset[State], ...]
163
+
164
+ @classmethod
165
+ def is_omega_regular(cls) -> bool:
166
+ """Generalized co-Büchi acceptance is omega-regular."""
167
+ return True
168
+
169
+
170
+ class AccPair(ty.NamedTuple, ty.Generic[State]):
171
+ """Pair of accepting and rejecting state sets for Rabin/Streett conditions."""
172
+
173
+ rejecting: frozenset[State]
174
+ """States that must not appear infinitely often"""
175
+ accepting: frozenset[State]
176
+ """States that must appear infinitely often"""
177
+
178
+
179
+ @frozen
180
+ class Streett(AcceptanceCondition[State]):
181
+ """Streett condition: a run, r, is accpting iff _for all_ `i`, we have that inf(r) does not intersect with `pairs[i].rejecting` and does intersect with `pairs[i].accepting`"""
182
+
183
+ pairs: tuple[AccPair[State], ...]
184
+
185
+ @property
186
+ def index(self) -> int:
187
+ """Number of pairs in this condition."""
188
+ return len(self.pairs)
189
+
190
+ @classmethod
191
+ def is_omega_regular(cls) -> bool:
192
+ """Streett acceptance is omega-regular."""
193
+ return True
194
+
195
+
196
+ @frozen
197
+ class Rabin(AcceptanceCondition[State]):
198
+ """Rabin condition: a run, r, is accpting iff _for some_ `i`, we have that inf(r) does not intersect with `pairs[i].rejecting` and does intersect with `pairs[i].accepting`"""
199
+
200
+ pairs: tuple[AccPair[State], ...]
201
+
202
+ @property
203
+ def index(self) -> int:
204
+ """Number of pairs in this condition."""
205
+ return len(self.pairs)
206
+
207
+ @classmethod
208
+ def is_omega_regular(cls) -> bool:
209
+ """Rabin acceptance is omega-regular."""
210
+ return True
211
+
212
+
213
+ @frozen
214
+ class Muller(AcceptanceCondition[State]):
215
+ """Muller condition: a run, r, is accepting iff for some `i`, we have that inf(r) is exactly `sets[i]`"""
216
+
217
+ sets: tuple[frozenset[State], ...]
218
+
219
+ @classmethod
220
+ def is_omega_regular(cls) -> bool:
221
+ """Muller acceptance is omega-regular."""
222
+ return True
morphata/automaton.py ADDED
File without changes
File without changes
@@ -0,0 +1,255 @@
1
+ """Implement a simple LTL to alternating automaton procedure.
2
+
3
+ The translation supports
4
+ - LTL to Alternating Büchi Word Automata
5
+ - LTLf to Alternating Finite Automata
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import functools
12
+ import typing as ty
13
+ from collections import deque
14
+ from collections.abc import Collection, Hashable, Iterable, Iterator, Mapping
15
+ from collections.abc import Set as AbstractSet
16
+ from dataclasses import dataclass
17
+
18
+ import logic_asts as logic
19
+ import logic_asts.ltl as ltl
20
+ from typing_extensions import override
21
+
22
+ import morphata
23
+ from morphata.acceptance import Buchi, Finite
24
+
25
+ AP = ty.TypeVar("AP", bound=Hashable)
26
+ type Input[AP] = AbstractSet[AP]
27
+ type BoolExpr[Sym: Hashable] = logic.base.BaseExpr[Sym]
28
+ type LTLExpr[AP] = ltl.LTLExpr[AP]
29
+
30
+
31
+ def ltl_to_automaton(formula: LTLExpr[AP], *, finite: bool = False) -> morphata.Automaton[int, Input[AP]]:
32
+ """Convert an LTL expression into an alternating automaton."""
33
+
34
+ formula = ty.cast(LTLExpr[AP], formula.expand())
35
+ atomic_predicates: frozenset[AP] = frozenset(
36
+ ty.cast(AP, e.name) for e in formula.iter_subtree() if isinstance(e, ltl.Variable)
37
+ )
38
+
39
+ transitions: dict[int, Mapping[Input[AP], BoolExpr[int]]] = dict()
40
+ initial_node = formula
41
+ final_states: set[int] = set()
42
+
43
+ # Now, we want to remap to integer nodes such that:
44
+ # 1. 0 is the initial node
45
+ # 2. the rest of the nodes are contiguous integers
46
+ # 3. Unreachable nodes not there
47
+ mappings: dict[logic.Variable[LTLExpr[AP]] | logic.Not, int] = dict()
48
+
49
+ expr_remap_cache: dict[BoolExpr[LTLExpr[AP]], BoolExpr[int]] = dict()
50
+
51
+ def _remap_bool_expr(node: BoolExpr[LTLExpr[AP]]) -> BoolExpr[int]:
52
+ if node in expr_remap_cache:
53
+ return expr_remap_cache[node]
54
+ match node:
55
+ case logic.Literal():
56
+ return node
57
+ case logic.Variable():
58
+ return logic.Variable(mappings.setdefault(node, len(mappings)))
59
+ case logic.Not(arg):
60
+ # Assumes that the input node was coverted to NNF
61
+ assert isinstance(arg, logic.Variable)
62
+ # Make a new variable for Not(var)
63
+ return logic.Variable(mappings.setdefault(node, len(mappings)))
64
+ case logic.And(args) | logic.Or(args) as nary_node:
65
+ return dataclasses.replace(
66
+ nary_node, args=tuple(_remap_bool_expr(ty.cast(BoolExpr[LTLExpr[AP]], arg)) for arg in args)
67
+ )
68
+ case _:
69
+ raise RuntimeError("unreachable")
70
+
71
+ # Queue containing list of reachable nodes that need to be remapped.
72
+ # The order of insertion in the queue is the reachability order
73
+ queue: deque[logic.Variable[LTLExpr[AP]] | logic.Not] = deque()
74
+ queue.append(logic.Variable(formula))
75
+ # We will build the remap dict first
76
+ while len(queue) > 0:
77
+ # Pop a node_id
78
+ node = queue.popleft()
79
+ # Remap it
80
+ node_id = mappings.setdefault(node, len(mappings))
81
+ if node_id in transitions:
82
+ continue
83
+ # Check if the node is accepting/final or not.
84
+ if isinstance(node, logic.Variable):
85
+ node_is_final = _is_accepting_node_expr(node.name)
86
+ else:
87
+ assert isinstance(node, logic.Not) and isinstance(node.arg, logic.Variable)
88
+ node_is_final = _is_accepting_node_expr(ty.cast(LTLExpr[AP], node.arg.name))
89
+ if node_is_final:
90
+ final_states.add(node_id)
91
+ # Add the reachable nodes in the next step (Variables/Not(Variable)) if they are not already mapped
92
+ reachable: dict[Input[AP], BoolExpr[int]] = dict()
93
+ sym: Input[AP]
94
+ for sym in _powerset(atomic_predicates):
95
+ # Compute the possible successor polynomial
96
+ successor = _aut_expansion_rule(node, sym) # pyrefly: ignore[bad-argument-type]
97
+ # We want to add the NNF leaf nodes to the queue, i.e., Not(var) is a leaf node, iff they are not already visited
98
+ queue.extend((e for e in successor.atomic_predicates(assume_nnf=True) if e not in mappings))
99
+ # pyrefly: ignore[bad-argument-type]
100
+ reachable[sym] = _remap_bool_expr(successor)
101
+ transitions[node_id] = reachable
102
+
103
+ assert mappings[logic.Variable(initial_node)] == 0
104
+ assert 0 in transitions
105
+
106
+ domain = LTLDomain(
107
+ frozenset(transitions.keys()),
108
+ atomic_predicates,
109
+ )
110
+ transition_fn = morphata.AlternatingTransitionRelation(transitions)
111
+ acceptance: Buchi[int] | Finite[int]
112
+ if finite:
113
+ acceptance = Finite(frozenset(final_states))
114
+ else:
115
+ acceptance = Buchi(frozenset(final_states))
116
+ return morphata.Automaton(
117
+ domain=domain, initial=mappings[logic.Variable(initial_node)], delta=transition_fn, acceptance=acceptance
118
+ )
119
+
120
+
121
+ def _is_accepting_node_expr(expr: LTLExpr[AP]) -> bool:
122
+ """
123
+ Nodes that are of the form ~(a U b) are accepting, along with the literal True obviously.
124
+ """
125
+ if isinstance(expr, ltl.Literal):
126
+ return expr.value
127
+
128
+ if isinstance(expr, ltl.Not) and isinstance(expr.arg, ltl.Until):
129
+ return True
130
+
131
+ # G a = ~ F ~a = ~(T U ~a)
132
+ if isinstance(expr, ltl.Always):
133
+ return True
134
+ return False
135
+
136
+
137
+ def _aut_expansion_rule(expr: LTLExpr[AP], symbol: Input[AP]) -> BoolExpr[LTLExpr[AP]]:
138
+ """Symbolic expansion of the automaton state corresponding to a given expression"""
139
+
140
+ def _recurse(_state: LTLExpr[AP]) -> BoolExpr[LTLExpr[AP]]:
141
+ return _aut_expansion_rule(_state, symbol)
142
+
143
+ def _as_poly(arg: logic.Expr) -> BoolExpr[LTLExpr[AP]]:
144
+ return ty.cast(BoolExpr[LTLExpr[AP]], arg)
145
+
146
+ def var(arg: LTLExpr[AP]) -> logic.Variable[LTLExpr[AP]]:
147
+ return logic.Variable(arg)
148
+
149
+ match expr:
150
+ case ltl.Literal(value):
151
+ return logic.Literal(value)
152
+ case ltl.Variable(name):
153
+ return logic.Literal(name in symbol)
154
+ case ltl.Not(arg):
155
+ return logic.Not(_recurse(_as_ltl(arg)))
156
+ case ltl.And(args):
157
+ return functools.reduce(lambda a, b: a & b, map(lambda a: _recurse(_as_ltl(a)), args))
158
+ case ltl.Or(args):
159
+ return functools.reduce(lambda a, b: a | b, map(lambda a: _recurse(_as_ltl(a)), args))
160
+ case ltl.Next(arg, steps):
161
+ assert steps is None or steps == 1, (
162
+ "should expand all intervals using `expand` before using automaton expansion rules"
163
+ )
164
+ return var(_as_ltl(arg))
165
+ case ltl.Always(arg, interval):
166
+ assert interval is None or interval.is_untimed(), (
167
+ "should expand all intervals using `expand` before using automaton expansion rules"
168
+ )
169
+ # Expand untimed always to: arg & X G arg -> arg & X expr
170
+ return _as_poly(_recurse(_as_ltl(arg)) & var(expr))
171
+ case ltl.Eventually(arg, interval):
172
+ assert interval is None or interval.is_untimed(), (
173
+ "should expand all intervals using `expand` before using automaton expansion rules"
174
+ )
175
+ # Expand untimed always to: arg | X F arg
176
+ return _as_poly(_recurse(_as_ltl(arg)) | var(expr))
177
+ case ltl.Until(lhs, rhs, interval):
178
+ assert interval is None or interval.is_untimed(), (
179
+ "should expand all intervals using `expand` before using automaton expansion rules"
180
+ )
181
+ # Expand to: rhs | (lhs & X expr )
182
+ lhs = _as_ltl(lhs)
183
+ rhs = _as_ltl(rhs)
184
+ return _as_poly(_recurse(rhs) | (_recurse(lhs) & var(expr)))
185
+ case _:
186
+ raise TypeError(f"Unknown expression type {type(expr)}")
187
+ raise TypeError(f"Unknown expression type {type(expr)}")
188
+
189
+
190
+ def _powerset[T](elements: Collection[T]) -> Iterable[AbstractSet[T]]:
191
+ from itertools import chain, combinations
192
+
193
+ return chain.from_iterable(map(frozenset, combinations(elements, r)) for r in range(len(elements) + 1))
194
+
195
+
196
+ @dataclass
197
+ class LTLDomain(morphata.Domain[int, Input[AP]]):
198
+ _states: AbstractSet[int]
199
+ atomic_predicates: AbstractSet[AP]
200
+
201
+ @property
202
+ @override
203
+ def states(self) -> Iterable[int] | None:
204
+ yield from self._states
205
+
206
+ @property
207
+ @override
208
+ def symbols(self) -> Iterable[Input[AP]] | None:
209
+ """Return a powerset of the atomic predicats"""
210
+ yield from _powerset(self.atomic_predicates)
211
+
212
+
213
+ def _iter_expr_nnf[Var: Hashable](expr: BoolExpr[Var]) -> Iterator[BoolExpr[Var]]:
214
+ """Iterate over a propositional logic expression after converting it to NNF form. If
215
+ there is a `Not(var)` in the expression, it will not yield `var`.
216
+
217
+ Otherwise, it has the same guarantees as `Expr.iter_subtree`
218
+ """
219
+ expr_nnf = expr.to_nnf()
220
+ assert logic.is_propositional_logic(expr_nnf)
221
+ stack: deque[BoolExpr[Var]] = deque([expr_nnf])
222
+ visited: set[BoolExpr[Var]] = set()
223
+
224
+ while stack:
225
+ subexpr = stack[-1]
226
+ if isinstance(subexpr, logic.Not):
227
+ # Don't put in the arguments of NOT
228
+ assert isinstance(subexpr.arg, logic.Variable), "Expected to receive NNF expression"
229
+ need_to_visit_children = set()
230
+ else:
231
+ need_to_visit_children = {
232
+ ty.cast(BoolExpr[Var], child)
233
+ for child in subexpr.children() # We need to visit `child`
234
+ if child not in visited # if it hasn't already been visited
235
+ }
236
+
237
+ if visited.issuperset(need_to_visit_children):
238
+ # subexpr is a leaf (the set is empty) or it's children have been
239
+ # yielded get rid of it from the stack
240
+ stack.pop()
241
+ # Add subexpr to visited
242
+ visited.add(subexpr)
243
+ # post-order return it
244
+ yield subexpr
245
+ else:
246
+ # mid-level node
247
+ # Add relevant children to stack
248
+ stack.extend(need_to_visit_children)
249
+ # Yield the remaining nodes in the stack in reverse order
250
+ yield from reversed(stack)
251
+
252
+
253
+ def _as_ltl[AP: Hashable](expr: logic.Expr) -> LTLExpr[AP]:
254
+ """Cast logical expression to LTL expression."""
255
+ return ty.cast(LTLExpr[AP], expr)