morphata 2.2.1__tar.gz → 2.2.2__tar.gz
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-2.2.1 → morphata-2.2.2}/CHANGELOG.md +23 -0
- {morphata-2.2.1 → morphata-2.2.2}/PKG-INFO +1 -1
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/_version.py +2 -2
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/symbolic/automaton.py +32 -5
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/symbolic/utils.py +19 -4
- {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_compose.py +95 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_language_equivalence.py +51 -1
- {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_symbolic_automaton.py +148 -0
- {morphata-2.2.1 → morphata-2.2.2}/.basedpyright/baseline.json +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.dockerignore +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.editorconfig +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.envrc +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.envrc.recommended +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.fdignore +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.gitattributes +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.gitignore +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.mailmap +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.python-version +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/.rstcheck.cfg +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/AGENTS.md +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/CLAUDE.md +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/CONTRIBUTING.md +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/LICENSE +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/README.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/bench/README.md +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/bench/__init__.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/bench/conftest.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/bench/test_equivalence_perf.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/bench/test_hoa_exporter_perf.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/bench/test_hoa_parser_perf.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/bench/test_sere_nlm_perf.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/bench/test_transforms_perf.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/ci/make-release +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/Makefile +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/_static/navigation.html +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/modules.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.acc_expr.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.acceptance.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.alphabet.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.builder.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.hoa.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.logic.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.operators.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.symbolic.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/changelog.md +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/acceptance.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/hoa.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/index.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/operators.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/symbolic.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/weights.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/conf.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/index.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/docs/quick-start.rst +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/justfile +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/mypy.ini +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/pixi.lock +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/pixi.toml +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/project.utf-8.add +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/project.utf-8.add.spl +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/pyproject.toml +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/pyrightconfig.json +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/pytest.toml +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/rules/no-derivative-multiarg.yml +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/rules/no-typing-as-ty.yml +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/sgconfig.yml +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/__init__.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/acc_expr.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/acceptance.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/alphabet.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/builder.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/hoa/__init__.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/hoa/__main__.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/hoa/hoa.lark +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/hoa/parser.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/__init__.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/__init__.pyi +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/_common.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/ltl.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/psl.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/sere.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/operators/__init__.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/operators/_backend.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/operators/matrix.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/operators/polynomial.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/py.typed +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/symbolic/__init__.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/symbolic/bdd.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/src/morphata/utils.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/conftest.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut1.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut11.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut2.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut3.2.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut3.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut4.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut5.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut6.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut7.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut8.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/finite_aut.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/gen_buchi_3.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/gen_cobuchi_2.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/rabin_2pair.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/streett_2pair.hoa +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/test_exporter.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/test_parser.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/logic/__init__.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/logic/test_ltl.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/logic/test_psl.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/logic/test_sere.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/operators/test_matrix.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/operators/test_polynomial.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_bdd_helpers.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_from_hoaf.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_ltlf2dfa_equivalence.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_to_hoa.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_transforms.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/test_acceptance_validation.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/test_alphabet.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/tests/test_builder.py +0 -0
- {morphata-2.2.1 → morphata-2.2.2}/uv.lock +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.2 — 2026-08-23
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Fixes a bug with complement and composition of nondeterministic automata
|
|
8
|
+
|
|
9
|
+
`__invert__` dualized each
|
|
10
|
+
(guard, successor)
|
|
11
|
+
pair on its own, leaving the implicit disjunction across a state's pairs undualized.
|
|
12
|
+
Read each state's relation back through `partition_by_state_residual` first,
|
|
13
|
+
so the disjunction sits inside one residual per disjoint guard class;
|
|
14
|
+
missing moves come back as a `false` residual, whose dual is the correct `true`.
|
|
15
|
+
|
|
16
|
+
`compose_automata` remapped only the reachable states,
|
|
17
|
+
so a fresh index could land on an unreachable state that kept its own index
|
|
18
|
+
and silently overwrite a transition row.
|
|
19
|
+
Remap every state and prune the unreachable rows afterwards.
|
|
20
|
+
`reindex_states` now rejects an assignment whose image collides with a state it does
|
|
21
|
+
not remap, so the same mistake raises instead of corrupting.
|
|
22
|
+
|
|
23
|
+
Together these made `is_language_equivalent` report False
|
|
24
|
+
for automata with identical languages.
|
|
25
|
+
|
|
3
26
|
## 2.2.1 — 2026-08-20
|
|
4
27
|
|
|
5
28
|
This version bump is mostly a cleanup after the 2.2.0 rewrite,
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 2,
|
|
21
|
+
__version__ = version = '2.2.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -531,16 +531,31 @@ class SymbolicAutomaton[AP: Hashable, Symbol]:
|
|
|
531
531
|
) -> SymbolicAutomaton[AP, Symbol]:
|
|
532
532
|
"""Return a new symbolic automaton with the states reindexed based on the assignments.
|
|
533
533
|
|
|
534
|
-
States absent from ``assignment`` keep their index
|
|
535
|
-
|
|
536
|
-
|
|
534
|
+
States absent from ``assignment`` keep their index, so the assignment
|
|
535
|
+
must not map any state onto the index of a state it leaves alone. This
|
|
536
|
+
reindexes state identity everywhere it is encoded: the BDD functions
|
|
537
|
+
(via ``let_state_vars``), the transition-dict keys, and the acceptance
|
|
537
538
|
condition. The state-keyed lazy caches are left to rebuild on demand;
|
|
538
539
|
the ``deterministic`` / ``alternating`` flags are preserved because a
|
|
539
540
|
reindexing is a bijective relabelling.
|
|
541
|
+
|
|
542
|
+
Raises
|
|
543
|
+
------
|
|
544
|
+
ValueError
|
|
545
|
+
If the image of ``assignment`` collides with a state that
|
|
546
|
+
``assignment`` does not remap. Such a collision silently drops the
|
|
547
|
+
colliding transition row (and merges the acceptance sets) instead
|
|
548
|
+
of relabelling.
|
|
540
549
|
"""
|
|
541
550
|
if not assignment:
|
|
542
551
|
return self
|
|
543
552
|
|
|
553
|
+
collisions = set(assignment.values()) & (set(self.states) - set(assignment))
|
|
554
|
+
if collisions:
|
|
555
|
+
raise ValueError(
|
|
556
|
+
f"reindexing assignment maps onto states it does not remap: {sorted(collisions)!r}"
|
|
557
|
+
)
|
|
558
|
+
|
|
544
559
|
manager = self.manager
|
|
545
560
|
|
|
546
561
|
def _transfer(fn: Function[AP, int]) -> Function[AP, int]:
|
|
@@ -689,9 +704,21 @@ class SymbolicAutomaton[AP: Hashable, Symbol]:
|
|
|
689
704
|
"""
|
|
690
705
|
mgr = self.manager
|
|
691
706
|
initial = mgr.dualize_state_expr(self.initial)
|
|
707
|
+
# A state's outgoing pairs are read disjunctively (see
|
|
708
|
+
# `transition_relation`), so the dual has to flip that outer disjunction
|
|
709
|
+
# too. Dualizing each `succ` in isolation would leave it in place, which
|
|
710
|
+
# is unsound as soon as two guards of a state overlap. Reading the
|
|
711
|
+
# relation back through `partition_by_state_residual` puts the whole
|
|
712
|
+
# disjunction inside one residual per (disjoint) guard class; missing
|
|
713
|
+
# moves show up as a `false` residual, whose dual is `true`.
|
|
692
714
|
transitions = {
|
|
693
|
-
q: [
|
|
694
|
-
|
|
715
|
+
q: [
|
|
716
|
+
(guard, mgr.dualize_state_expr(succ))
|
|
717
|
+
for guard, succ in mgr.partition_by_state_residual(
|
|
718
|
+
self.transition_relation(q)
|
|
719
|
+
)
|
|
720
|
+
]
|
|
721
|
+
for q in self.transitions
|
|
695
722
|
}
|
|
696
723
|
# A fresh (empty) transition-relation cache is used implicitly --
|
|
697
724
|
# inheriting self's would serve stale, pre-dual relations.
|
|
@@ -33,7 +33,7 @@ from collections.abc import (
|
|
|
33
33
|
Sequence,
|
|
34
34
|
)
|
|
35
35
|
from collections.abc import Set as AbstractSet
|
|
36
|
-
from dataclasses import dataclass, field
|
|
36
|
+
from dataclasses import dataclass, field, replace
|
|
37
37
|
from typing import override
|
|
38
38
|
|
|
39
39
|
from morphata.acceptance import Finite
|
|
@@ -684,7 +684,7 @@ def compose_automata[AP: Hashable, Symbol](
|
|
|
684
684
|
f"incompatible manager for automaton at position {idx}; use `replace_manager` to ensure they have the same manager."
|
|
685
685
|
)
|
|
686
686
|
|
|
687
|
-
# Loop through the
|
|
687
|
+
# Loop through the automaton states and find the remapping.
|
|
688
688
|
# TODO: THIS CURRENTLY DOES NOT COLLAPSE SINKS
|
|
689
689
|
# A sink is one that has outgoing == mgr.true (if the automaton is complete, which it should be)
|
|
690
690
|
# So we just need to check if the sink is accepting or not.
|
|
@@ -693,12 +693,27 @@ def compose_automata[AP: Hashable, Symbol](
|
|
|
693
693
|
# 1. if the condition is Finite, just check
|
|
694
694
|
# 2. if omega, partition by which acceptance set it is part of
|
|
695
695
|
remap = {
|
|
696
|
-
q: state_var_manager.get_or_declare((idx, QVar(q))).name
|
|
697
|
-
for q in aut.reachable_states()
|
|
696
|
+
q: state_var_manager.get_or_declare((idx, QVar(q))).name for q in aut.states
|
|
698
697
|
}
|
|
699
698
|
assert len(remap) > 0
|
|
699
|
+
# Every state is remapped, not just the reachable ones: a partial
|
|
700
|
+
# assignment can hand a fresh index to one state while another keeps
|
|
701
|
+
# that same index, and the collision silently drops a transition row.
|
|
702
|
+
# The unreachable rows are pruned after the relabelling instead.
|
|
703
|
+
live = {remap[q] for q in aut.reachable_states()}
|
|
700
704
|
# remap all the functions in the current automaton
|
|
701
705
|
remapped = aut.reindex_states(remap)
|
|
706
|
+
remapped = replace(
|
|
707
|
+
remapped,
|
|
708
|
+
transitions={
|
|
709
|
+
q: outgoing for q, outgoing in remapped.transitions.items() if q in live
|
|
710
|
+
},
|
|
711
|
+
_transition_rel_cache={
|
|
712
|
+
q: rel
|
|
713
|
+
for q, rel in remapped._transition_rel_cache.items() # pyright: ignore[reportPrivateUsage]
|
|
714
|
+
if q in live
|
|
715
|
+
},
|
|
716
|
+
)
|
|
702
717
|
if carry is None:
|
|
703
718
|
carry = remapped
|
|
704
719
|
else:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import itertools
|
|
5
6
|
from collections.abc import Set as AbstractSet
|
|
6
7
|
from typing import Literal
|
|
7
8
|
|
|
@@ -175,3 +176,97 @@ def test_min_dfa_of_conjunction_empty() -> None:
|
|
|
175
176
|
assert result.accepts([])
|
|
176
177
|
assert result.accepts([frozenset(), frozenset({"a"}), frozenset({"a", "b"})])
|
|
177
178
|
assert result.accepts([frozenset({"b"})] * 4)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# State-index collisions with unreachable states
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _words(max_len: int = 4) -> list[list[AbstractSet[str]]]:
|
|
187
|
+
letters: list[AbstractSet[str]] = [frozenset(), frozenset({"a"})]
|
|
188
|
+
return [
|
|
189
|
+
list(w)
|
|
190
|
+
for n in range(max_len + 1)
|
|
191
|
+
for w in itertools.product(letters, repeat=n)
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _ends_with_a(
|
|
196
|
+
mgr: BDDManager[str, int], *, spare_states: int = 0
|
|
197
|
+
) -> SymbolicAutomaton[str, AbstractSet[str]]:
|
|
198
|
+
"""DFA for "the word ends with ``a``", plus ``spare_states`` unreachable states.
|
|
199
|
+
|
|
200
|
+
The spare states are what makes the composition remap partial: they are not
|
|
201
|
+
reachable, so a remap keyed on the reachable states alone leaves them at
|
|
202
|
+
their old index -- which is exactly the index a reachable state is being
|
|
203
|
+
moved to.
|
|
204
|
+
"""
|
|
205
|
+
mgr.declare_ap_vars("a")
|
|
206
|
+
mgr.declare_state_vars(0, 1, *range(2, 2 + spare_states))
|
|
207
|
+
a, q = mgr.ap_var("a"), mgr.q_var
|
|
208
|
+
transitions = {
|
|
209
|
+
0: [(a, q(1)), (~a, q(0))],
|
|
210
|
+
1: [(a, q(1)), (~a, q(0))],
|
|
211
|
+
}
|
|
212
|
+
for spare in range(2, 2 + spare_states):
|
|
213
|
+
transitions[spare] = [(mgr.true, q(spare))]
|
|
214
|
+
return SymbolicAutomaton[str, AbstractSet[str]](
|
|
215
|
+
mgr,
|
|
216
|
+
q(0),
|
|
217
|
+
transitions,
|
|
218
|
+
Finite(frozenset({1})),
|
|
219
|
+
alphabet=powerset_alphabet({"a"}),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _starts_with_a(
|
|
224
|
+
mgr: BDDManager[str, int],
|
|
225
|
+
) -> SymbolicAutomaton[str, AbstractSet[str]]:
|
|
226
|
+
"""DFA for "the word starts with ``a``"; states 3, 4, 5 to avoid a clash."""
|
|
227
|
+
mgr.declare_ap_vars("a")
|
|
228
|
+
mgr.declare_state_vars(3, 4, 5)
|
|
229
|
+
a, q = mgr.ap_var("a"), mgr.q_var
|
|
230
|
+
return SymbolicAutomaton[str, AbstractSet[str]](
|
|
231
|
+
mgr,
|
|
232
|
+
q(3),
|
|
233
|
+
{
|
|
234
|
+
3: [(a, q(4)), (~a, q(5))],
|
|
235
|
+
4: [(mgr.true, q(4))],
|
|
236
|
+
5: [(mgr.true, q(5))],
|
|
237
|
+
},
|
|
238
|
+
Finite(frozenset({4})),
|
|
239
|
+
alphabet=powerset_alphabet({"a"}),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.mark.parametrize("op", ["and", "or"])
|
|
244
|
+
def test_compose_with_unreachable_states(
|
|
245
|
+
mgr: BDDManager[str, int], op: Literal["and", "or"]
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Unreachable states must not cost the composition a transition row.
|
|
248
|
+
|
|
249
|
+
``compose_automata`` hands every operand a fresh block of indices. When the
|
|
250
|
+
remap covers only the reachable states, a fresh index can land on an
|
|
251
|
+
unreachable state that keeps its old index, and the reindexed row for the
|
|
252
|
+
reachable state is overwritten by the unreachable one's.
|
|
253
|
+
"""
|
|
254
|
+
lhs = _starts_with_a(mgr)
|
|
255
|
+
rhs = _ends_with_a(mgr, spare_states=2)
|
|
256
|
+
composed = compose_automata(op, lhs, rhs)
|
|
257
|
+
|
|
258
|
+
expected = [
|
|
259
|
+
(lhs.accepts(w) and rhs.accepts(w))
|
|
260
|
+
if op == "and"
|
|
261
|
+
else (lhs.accepts(w) or rhs.accepts(w))
|
|
262
|
+
for w in _words()
|
|
263
|
+
]
|
|
264
|
+
assert [composed.accepts(w) for w in _words()] == expected
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_compose_drops_unreachable_states(mgr: BDDManager[str, int]) -> None:
|
|
268
|
+
"""The product carries the reachable states of its operands and nothing else."""
|
|
269
|
+
lhs = _starts_with_a(mgr)
|
|
270
|
+
rhs = _ends_with_a(mgr, spare_states=2)
|
|
271
|
+
|
|
272
|
+
assert compose_automata("and", lhs, rhs).num_states == 5
|
|
@@ -20,7 +20,7 @@ import logic_asts as logic
|
|
|
20
20
|
import pytest
|
|
21
21
|
from logic_asts import ltl
|
|
22
22
|
|
|
23
|
-
from morphata.acceptance import Buchi
|
|
23
|
+
from morphata.acceptance import Buchi, Finite
|
|
24
24
|
from morphata.alphabet import powerset_alphabet
|
|
25
25
|
from morphata.symbolic import SymbolicAutomaton
|
|
26
26
|
from morphata.symbolic.bdd import BDDManager
|
|
@@ -154,3 +154,53 @@ def test_requires_finite_acceptance(mgr: BDDManager[str, int]) -> None:
|
|
|
154
154
|
is_language_equivalent(non_finite, a)
|
|
155
155
|
with pytest.raises(TypeError):
|
|
156
156
|
is_language_equivalent(a, non_finite)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Nondeterministic operands
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _nfa_a_two_before_the_end(
|
|
165
|
+
mgr: BDDManager[str, int], *, spare_state: bool
|
|
166
|
+
) -> SymbolicAutomaton[str, AbstractSet[str]]:
|
|
167
|
+
"""NFA for "some ``a`` occurs at least two positions before the end".
|
|
168
|
+
|
|
169
|
+
State 0 is nondeterministic: on ``a`` both of its pairs fire, so the
|
|
170
|
+
equivalence check has to dualize the disjunction across them. With
|
|
171
|
+
``spare_state``, an unreachable state is added, which does not change the
|
|
172
|
+
language but does shift the state indexing the composition works with.
|
|
173
|
+
"""
|
|
174
|
+
mgr.declare_ap_vars("a")
|
|
175
|
+
states = [0, 1, 2] + ([3] if spare_state else [])
|
|
176
|
+
mgr.declare_state_vars(*states)
|
|
177
|
+
av, q = mgr.ap_var("a"), mgr.q_var
|
|
178
|
+
transitions = {
|
|
179
|
+
0: [(av, q(1)), (mgr.true, q(0))],
|
|
180
|
+
1: [(mgr.true, q(2))],
|
|
181
|
+
2: [(mgr.true, q(2))],
|
|
182
|
+
}
|
|
183
|
+
if spare_state:
|
|
184
|
+
transitions[3] = [(mgr.true, q(2))]
|
|
185
|
+
return SymbolicAutomaton[str, AbstractSet[str]](
|
|
186
|
+
mgr,
|
|
187
|
+
q(0),
|
|
188
|
+
transitions,
|
|
189
|
+
Finite(frozenset({2})),
|
|
190
|
+
alphabet=powerset_alphabet({"a"}),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_nondeterministic_pair_with_a_spare_state(mgr: BDDManager[str, int]) -> None:
|
|
195
|
+
"""Two spellings of one language, one of them carrying an unreachable state."""
|
|
196
|
+
a = _nfa_a_two_before_the_end(mgr, spare_state=False)
|
|
197
|
+
b = _nfa_a_two_before_the_end(mgr, spare_state=True)
|
|
198
|
+
|
|
199
|
+
assert _brute_force_equivalent(a, b)
|
|
200
|
+
assert is_language_equivalent(a, b)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_nondeterministic_against_its_min_dfa(mgr: BDDManager[str, int]) -> None:
|
|
204
|
+
a = _nfa_a_two_before_the_end(mgr, spare_state=False)
|
|
205
|
+
|
|
206
|
+
assert is_language_equivalent(a, a.to_min_dfa())
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import itertools
|
|
5
6
|
from collections.abc import Callable, Mapping
|
|
6
7
|
from collections.abc import Set as AbstractSet
|
|
7
8
|
from typing import assert_type
|
|
@@ -460,3 +461,150 @@ class TestReplaceManager:
|
|
|
460
461
|
alphabet = powerset_alphabet(_AB_PREDICATES)
|
|
461
462
|
aut = _build_a_then_b_dfa().build(labeling_fn=alphabet.labeling, manager=mgr)
|
|
462
463
|
assert aut.replace_manager(mgr) is aut
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
# Complement by dualization
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _words_over_a(max_len: int = 5) -> list[list[frozenset[str]]]:
|
|
472
|
+
"""Every word up to ``max_len`` over the powerset alphabet on ``{"a"}``."""
|
|
473
|
+
letters = [sym(), sym("a")]
|
|
474
|
+
return [
|
|
475
|
+
list(w)
|
|
476
|
+
for n in range(max_len + 1)
|
|
477
|
+
for w in itertools.product(letters, repeat=n)
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _dfa_last_letter_was_a(
|
|
482
|
+
mgr: BDDManager[str, int],
|
|
483
|
+
) -> SymbolicAutomaton[str, AbstractSet[str]]:
|
|
484
|
+
"""Deterministic: accepts iff the word ends with ``a``."""
|
|
485
|
+
mgr.declare_ap_vars("a")
|
|
486
|
+
mgr.declare_state_vars(0, 1)
|
|
487
|
+
a, q = mgr.ap_var("a"), mgr.q_var
|
|
488
|
+
return SymbolicAutomaton[str, AbstractSet[str]](
|
|
489
|
+
mgr,
|
|
490
|
+
q(0),
|
|
491
|
+
{0: [(a, q(1)), (~a, q(0))], 1: [(a, q(1)), (~a, q(0))]},
|
|
492
|
+
Finite(frozenset({1})),
|
|
493
|
+
alphabet=powerset_alphabet(frozenset({"a"})),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _nfa_overlapping_guards(
|
|
498
|
+
mgr: BDDManager[str, int],
|
|
499
|
+
) -> SymbolicAutomaton[str, AbstractSet[str]]:
|
|
500
|
+
"""Nondeterministic: some ``a`` occurs at least two positions before the end.
|
|
501
|
+
|
|
502
|
+
State 0 has two outgoing pairs whose guards both fire on ``a``. That outer
|
|
503
|
+
disjunction is the part :meth:`SymbolicAutomaton.__invert__` has to dualize.
|
|
504
|
+
"""
|
|
505
|
+
mgr.declare_ap_vars("a")
|
|
506
|
+
mgr.declare_state_vars(0, 1, 2)
|
|
507
|
+
a, q = mgr.ap_var("a"), mgr.q_var
|
|
508
|
+
return SymbolicAutomaton[str, AbstractSet[str]](
|
|
509
|
+
mgr,
|
|
510
|
+
q(0),
|
|
511
|
+
{
|
|
512
|
+
0: [(a, q(1)), (mgr.true, q(0))],
|
|
513
|
+
1: [(mgr.true, q(2))],
|
|
514
|
+
2: [(mgr.true, q(2))],
|
|
515
|
+
},
|
|
516
|
+
Finite(frozenset({2})),
|
|
517
|
+
alphabet=powerset_alphabet(frozenset({"a"})),
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _conjunctive_monitors(
|
|
522
|
+
mgr: BDDManager[str, int],
|
|
523
|
+
) -> SymbolicAutomaton[str, AbstractSet[str]]:
|
|
524
|
+
"""Alternating: the initial state forks into two monitors that must both hold."""
|
|
525
|
+
mgr.declare_ap_vars("a")
|
|
526
|
+
mgr.declare_state_vars(0, 1, 2)
|
|
527
|
+
a, q = mgr.ap_var("a"), mgr.q_var
|
|
528
|
+
return SymbolicAutomaton[str, AbstractSet[str]](
|
|
529
|
+
mgr,
|
|
530
|
+
q(0),
|
|
531
|
+
{0: [(mgr.true, q(1) & q(2))], 1: [(a, q(1))], 2: [(mgr.true, q(2))]},
|
|
532
|
+
Finite(frozenset({1, 2})),
|
|
533
|
+
alphabet=powerset_alphabet(frozenset({"a"})),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@pytest.mark.parametrize(
|
|
538
|
+
("make", "deterministic", "alternating"),
|
|
539
|
+
[
|
|
540
|
+
(_dfa_last_letter_was_a, True, False),
|
|
541
|
+
(_nfa_overlapping_guards, False, False),
|
|
542
|
+
(_conjunctive_monitors, False, True),
|
|
543
|
+
],
|
|
544
|
+
ids=["deterministic", "nondeterministic", "alternating"],
|
|
545
|
+
)
|
|
546
|
+
def test_invert_complements_the_language(
|
|
547
|
+
mgr: BDDManager[str, int],
|
|
548
|
+
make: Callable[[BDDManager[str, int]], SymbolicAutomaton[str, AbstractSet[str]]],
|
|
549
|
+
deterministic: bool,
|
|
550
|
+
alternating: bool,
|
|
551
|
+
) -> None:
|
|
552
|
+
"""``~x`` accepts exactly the words ``x`` rejects, whatever the branching mode.
|
|
553
|
+
|
|
554
|
+
The nondeterministic case is the regression guard: dualizing each
|
|
555
|
+
``(guard, successor)`` pair on its own leaves the implicit disjunction
|
|
556
|
+
across a state's pairs undualized, which is only invisible when at most one
|
|
557
|
+
pair can fire per input.
|
|
558
|
+
"""
|
|
559
|
+
aut = make(mgr)
|
|
560
|
+
assert aut.is_deterministic() is deterministic
|
|
561
|
+
assert aut.is_alternating() is alternating
|
|
562
|
+
|
|
563
|
+
complement = ~aut
|
|
564
|
+
disagreements = [
|
|
565
|
+
w for w in _words_over_a() if complement.accepts(w) == aut.accepts(w)
|
|
566
|
+
]
|
|
567
|
+
assert disagreements == []
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def test_double_invert_preserves_the_language(mgr: BDDManager[str, int]) -> None:
|
|
571
|
+
from morphata.symbolic.utils import is_language_equivalent
|
|
572
|
+
|
|
573
|
+
aut = _nfa_overlapping_guards(mgr)
|
|
574
|
+
assert is_language_equivalent(~~aut, aut)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
# reindex_states
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def test_reindex_states_rejects_a_colliding_assignment(
|
|
583
|
+
mgr: BDDManager[str, int],
|
|
584
|
+
) -> None:
|
|
585
|
+
"""A fresh index that names a state left alone would drop that state's row."""
|
|
586
|
+
aut = _nfa_overlapping_guards(mgr)
|
|
587
|
+
|
|
588
|
+
with pytest.raises(ValueError, match="does not remap"):
|
|
589
|
+
_ = aut.reindex_states({0: 2})
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def test_reindex_states_allows_a_permutation(mgr: BDDManager[str, int]) -> None:
|
|
593
|
+
aut = _nfa_overlapping_guards(mgr)
|
|
594
|
+
swapped = aut.reindex_states({1: 2, 2: 1})
|
|
595
|
+
|
|
596
|
+
assert set(swapped.states) == {0, 1, 2}
|
|
597
|
+
assert swapped.acceptance == Finite(frozenset({1}))
|
|
598
|
+
assert [swapped.accepts(w) for w in _words_over_a(3)] == [
|
|
599
|
+
aut.accepts(w) for w in _words_over_a(3)
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def test_reindex_states_allows_a_partial_move_to_fresh_indices(
|
|
604
|
+
mgr: BDDManager[str, int],
|
|
605
|
+
) -> None:
|
|
606
|
+
aut = _nfa_overlapping_guards(mgr)
|
|
607
|
+
mgr.declare_state_vars(7)
|
|
608
|
+
moved = aut.reindex_states({0: 7})
|
|
609
|
+
|
|
610
|
+
assert set(moved.states) == {7, 1, 2}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|