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.
Files changed (122) hide show
  1. {morphata-2.2.1 → morphata-2.2.2}/CHANGELOG.md +23 -0
  2. {morphata-2.2.1 → morphata-2.2.2}/PKG-INFO +1 -1
  3. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/_version.py +2 -2
  4. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/symbolic/automaton.py +32 -5
  5. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/symbolic/utils.py +19 -4
  6. {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_compose.py +95 -0
  7. {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_language_equivalence.py +51 -1
  8. {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_symbolic_automaton.py +148 -0
  9. {morphata-2.2.1 → morphata-2.2.2}/.basedpyright/baseline.json +0 -0
  10. {morphata-2.2.1 → morphata-2.2.2}/.dockerignore +0 -0
  11. {morphata-2.2.1 → morphata-2.2.2}/.editorconfig +0 -0
  12. {morphata-2.2.1 → morphata-2.2.2}/.envrc +0 -0
  13. {morphata-2.2.1 → morphata-2.2.2}/.envrc.recommended +0 -0
  14. {morphata-2.2.1 → morphata-2.2.2}/.fdignore +0 -0
  15. {morphata-2.2.1 → morphata-2.2.2}/.gitattributes +0 -0
  16. {morphata-2.2.1 → morphata-2.2.2}/.gitignore +0 -0
  17. {morphata-2.2.1 → morphata-2.2.2}/.mailmap +0 -0
  18. {morphata-2.2.1 → morphata-2.2.2}/.python-version +0 -0
  19. {morphata-2.2.1 → morphata-2.2.2}/.rstcheck.cfg +0 -0
  20. {morphata-2.2.1 → morphata-2.2.2}/AGENTS.md +0 -0
  21. {morphata-2.2.1 → morphata-2.2.2}/CLAUDE.md +0 -0
  22. {morphata-2.2.1 → morphata-2.2.2}/CONTRIBUTING.md +0 -0
  23. {morphata-2.2.1 → morphata-2.2.2}/LICENSE +0 -0
  24. {morphata-2.2.1 → morphata-2.2.2}/README.rst +0 -0
  25. {morphata-2.2.1 → morphata-2.2.2}/bench/README.md +0 -0
  26. {morphata-2.2.1 → morphata-2.2.2}/bench/__init__.py +0 -0
  27. {morphata-2.2.1 → morphata-2.2.2}/bench/conftest.py +0 -0
  28. {morphata-2.2.1 → morphata-2.2.2}/bench/test_equivalence_perf.py +0 -0
  29. {morphata-2.2.1 → morphata-2.2.2}/bench/test_hoa_exporter_perf.py +0 -0
  30. {morphata-2.2.1 → morphata-2.2.2}/bench/test_hoa_parser_perf.py +0 -0
  31. {morphata-2.2.1 → morphata-2.2.2}/bench/test_sere_nlm_perf.py +0 -0
  32. {morphata-2.2.1 → morphata-2.2.2}/bench/test_transforms_perf.py +0 -0
  33. {morphata-2.2.1 → morphata-2.2.2}/ci/make-release +0 -0
  34. {morphata-2.2.1 → morphata-2.2.2}/docs/Makefile +0 -0
  35. {morphata-2.2.1 → morphata-2.2.2}/docs/_static/navigation.html +0 -0
  36. {morphata-2.2.1 → morphata-2.2.2}/docs/api/modules.rst +0 -0
  37. {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.acc_expr.rst +0 -0
  38. {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.acceptance.rst +0 -0
  39. {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.alphabet.rst +0 -0
  40. {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.builder.rst +0 -0
  41. {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.hoa.rst +0 -0
  42. {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.logic.rst +0 -0
  43. {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.operators.rst +0 -0
  44. {morphata-2.2.1 → morphata-2.2.2}/docs/api/morphata.symbolic.rst +0 -0
  45. {morphata-2.2.1 → morphata-2.2.2}/docs/changelog.md +0 -0
  46. {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/acceptance.rst +0 -0
  47. {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/hoa.rst +0 -0
  48. {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/index.rst +0 -0
  49. {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/operators.rst +0 -0
  50. {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/symbolic.rst +0 -0
  51. {morphata-2.2.1 → morphata-2.2.2}/docs/concepts/weights.rst +0 -0
  52. {morphata-2.2.1 → morphata-2.2.2}/docs/conf.py +0 -0
  53. {morphata-2.2.1 → morphata-2.2.2}/docs/index.rst +0 -0
  54. {morphata-2.2.1 → morphata-2.2.2}/docs/quick-start.rst +0 -0
  55. {morphata-2.2.1 → morphata-2.2.2}/justfile +0 -0
  56. {morphata-2.2.1 → morphata-2.2.2}/mypy.ini +0 -0
  57. {morphata-2.2.1 → morphata-2.2.2}/pixi.lock +0 -0
  58. {morphata-2.2.1 → morphata-2.2.2}/pixi.toml +0 -0
  59. {morphata-2.2.1 → morphata-2.2.2}/project.utf-8.add +0 -0
  60. {morphata-2.2.1 → morphata-2.2.2}/project.utf-8.add.spl +0 -0
  61. {morphata-2.2.1 → morphata-2.2.2}/pyproject.toml +0 -0
  62. {morphata-2.2.1 → morphata-2.2.2}/pyrightconfig.json +0 -0
  63. {morphata-2.2.1 → morphata-2.2.2}/pytest.toml +0 -0
  64. {morphata-2.2.1 → morphata-2.2.2}/rules/no-derivative-multiarg.yml +0 -0
  65. {morphata-2.2.1 → morphata-2.2.2}/rules/no-typing-as-ty.yml +0 -0
  66. {morphata-2.2.1 → morphata-2.2.2}/sgconfig.yml +0 -0
  67. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/__init__.py +0 -0
  68. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/acc_expr.py +0 -0
  69. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/acceptance.py +0 -0
  70. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/alphabet.py +0 -0
  71. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/builder.py +0 -0
  72. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/hoa/__init__.py +0 -0
  73. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/hoa/__main__.py +0 -0
  74. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/hoa/hoa.lark +0 -0
  75. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/hoa/parser.py +0 -0
  76. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/__init__.py +0 -0
  77. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/__init__.pyi +0 -0
  78. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/_common.py +0 -0
  79. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/ltl.py +0 -0
  80. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/psl.py +0 -0
  81. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/logic/sere.py +0 -0
  82. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/operators/__init__.py +0 -0
  83. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/operators/_backend.py +0 -0
  84. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/operators/matrix.py +0 -0
  85. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/operators/polynomial.py +0 -0
  86. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/py.typed +0 -0
  87. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/symbolic/__init__.py +0 -0
  88. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/symbolic/bdd.py +0 -0
  89. {morphata-2.2.1 → morphata-2.2.2}/src/morphata/utils.py +0 -0
  90. {morphata-2.2.1 → morphata-2.2.2}/tests/conftest.py +0 -0
  91. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut1.hoa +0 -0
  92. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut11.hoa +0 -0
  93. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut2.hoa +0 -0
  94. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut3.2.hoa +0 -0
  95. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut3.hoa +0 -0
  96. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut4.hoa +0 -0
  97. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut5.hoa +0 -0
  98. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut6.hoa +0 -0
  99. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut7.hoa +0 -0
  100. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/aut8.hoa +0 -0
  101. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/finite_aut.hoa +0 -0
  102. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/gen_buchi_3.hoa +0 -0
  103. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/gen_cobuchi_2.hoa +0 -0
  104. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/rabin_2pair.hoa +0 -0
  105. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/streett_2pair.hoa +0 -0
  106. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/test_exporter.py +0 -0
  107. {morphata-2.2.1 → morphata-2.2.2}/tests/hoa/test_parser.py +0 -0
  108. {morphata-2.2.1 → morphata-2.2.2}/tests/logic/__init__.py +0 -0
  109. {morphata-2.2.1 → morphata-2.2.2}/tests/logic/test_ltl.py +0 -0
  110. {morphata-2.2.1 → morphata-2.2.2}/tests/logic/test_psl.py +0 -0
  111. {morphata-2.2.1 → morphata-2.2.2}/tests/logic/test_sere.py +0 -0
  112. {morphata-2.2.1 → morphata-2.2.2}/tests/operators/test_matrix.py +0 -0
  113. {morphata-2.2.1 → morphata-2.2.2}/tests/operators/test_polynomial.py +0 -0
  114. {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_bdd_helpers.py +0 -0
  115. {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_from_hoaf.py +0 -0
  116. {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_ltlf2dfa_equivalence.py +0 -0
  117. {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_to_hoa.py +0 -0
  118. {morphata-2.2.1 → morphata-2.2.2}/tests/symbolic/test_transforms.py +0 -0
  119. {morphata-2.2.1 → morphata-2.2.2}/tests/test_acceptance_validation.py +0 -0
  120. {morphata-2.2.1 → morphata-2.2.2}/tests/test_alphabet.py +0 -0
  121. {morphata-2.2.1 → morphata-2.2.2}/tests/test_builder.py +0 -0
  122. {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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphata
3
- Version: 2.2.1
3
+ Version: 2.2.2
4
4
  Summary: Representation theory for automata, made practical.
5
5
  Project-URL: Documentation, https://docs.anandb.dev/morphata
6
6
  Project-URL: Repository, https://git.anandb.dev/morphata.git
@@ -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.1'
22
- __version_tuple__ = version_tuple = (2, 2, 1)
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. This reindexes state
535
- identity everywhere it is encoded: the BDD functions (via
536
- ``let_state_vars``), the transition-dict keys, and the acceptance
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: [(guard, mgr.dualize_state_expr(succ)) for guard, succ in outgoing]
694
- for q, outgoing in self.transitions.items()
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 reachable automaton states and find the remapping.
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