morphata 2.0.9__tar.gz → 2.0.10__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 (149) hide show
  1. {morphata-2.0.9 → morphata-2.0.10}/.claude/settings.local.json +4 -1
  2. morphata-2.0.10/.codegraph/.gitignore +16 -0
  3. morphata-2.0.10/.codegraph/codegraph.db +0 -0
  4. morphata-2.0.10/.codegraph/codegraph.db-shm +0 -0
  5. morphata-2.0.10/.codegraph/codegraph.db-wal +0 -0
  6. morphata-2.0.10/.codegraph/daemon.pid +6 -0
  7. {morphata-2.0.9 → morphata-2.0.10}/CHANGELOG.md +14 -0
  8. {morphata-2.0.9 → morphata-2.0.10}/PKG-INFO +1 -1
  9. {morphata-2.0.9 → morphata-2.0.10}/bench/README.md +5 -0
  10. morphata-2.0.10/bench/test_hoa_parser_perf.py +114 -0
  11. {morphata-2.0.9 → morphata-2.0.10}/mypy.ini +10 -0
  12. {morphata-2.0.9 → morphata-2.0.10}/pyproject.toml +1 -1
  13. {morphata-2.0.9 → morphata-2.0.10}/pyrightconfig.json +1 -1
  14. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/hoa/hoa.lark +13 -7
  15. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/hoa/parser.py +2 -1
  16. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/logic/ltl.py +2 -138
  17. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/logic/psl.py +2 -2
  18. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/logic/sere.py +3 -2
  19. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/symbolic/__init__.py +1 -0
  20. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/symbolic/automaton.py +8 -3
  21. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/symbolic/bdd.py +104 -0
  22. {morphata-2.0.9 → morphata-2.0.10}/tests/conftest.py +1 -1
  23. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_persistence.py +76 -0
  24. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_symbolic_automaton.py +36 -0
  25. {morphata-2.0.9 → morphata-2.0.10}/uv.lock +540 -471
  26. {morphata-2.0.9 → morphata-2.0.10}/.basedpyright/baseline.json +0 -0
  27. {morphata-2.0.9 → morphata-2.0.10}/.dockerignore +0 -0
  28. {morphata-2.0.9 → morphata-2.0.10}/.editorconfig +0 -0
  29. {morphata-2.0.9 → morphata-2.0.10}/.envrc +0 -0
  30. {morphata-2.0.9 → morphata-2.0.10}/.envrc.recommended +0 -0
  31. {morphata-2.0.9 → morphata-2.0.10}/.fdignore +0 -0
  32. {morphata-2.0.9 → morphata-2.0.10}/.gitattributes +0 -0
  33. {morphata-2.0.9 → morphata-2.0.10}/.gitignore +0 -0
  34. {morphata-2.0.9 → morphata-2.0.10}/.mailmap +0 -0
  35. {morphata-2.0.9 → morphata-2.0.10}/.python-version +0 -0
  36. {morphata-2.0.9 → morphata-2.0.10}/.rstcheck.cfg +0 -0
  37. {morphata-2.0.9 → morphata-2.0.10}/AGENTS.md +0 -0
  38. {morphata-2.0.9 → morphata-2.0.10}/CLAUDE.md +0 -0
  39. {morphata-2.0.9 → morphata-2.0.10}/CONTRIBUTING.md +0 -0
  40. {morphata-2.0.9 → morphata-2.0.10}/LICENSE +0 -0
  41. {morphata-2.0.9 → morphata-2.0.10}/README.md +0 -0
  42. {morphata-2.0.9 → morphata-2.0.10}/bench/__init__.py +0 -0
  43. {morphata-2.0.9 → morphata-2.0.10}/bench/conftest.py +0 -0
  44. {morphata-2.0.9 → morphata-2.0.10}/bench/test_equivalence_perf.py +0 -0
  45. {morphata-2.0.9 → morphata-2.0.10}/bench/test_hoa_exporter_perf.py +0 -0
  46. {morphata-2.0.9 → morphata-2.0.10}/bench/test_sere_nlm_perf.py +0 -0
  47. {morphata-2.0.9 → morphata-2.0.10}/bench/test_successors_for_alphabet_perf.py +0 -0
  48. {morphata-2.0.9 → morphata-2.0.10}/ci/make-release +0 -0
  49. {morphata-2.0.9 → morphata-2.0.10}/cliff.toml +0 -0
  50. {morphata-2.0.9 → morphata-2.0.10}/docs/_static/navigation.html +0 -0
  51. {morphata-2.0.9 → morphata-2.0.10}/docs/api/modules.rst +0 -0
  52. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.acceptance.rst +0 -0
  53. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.alphabet.rst +0 -0
  54. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.automaton.rst +0 -0
  55. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.builder.rst +0 -0
  56. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.hoa.rst +0 -0
  57. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.logic.ltl.rst +0 -0
  58. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.logic.psl.rst +0 -0
  59. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.logic.sere.rst +0 -0
  60. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.logic.strel.rst +0 -0
  61. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.operators.matrix.rst +0 -0
  62. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.operators.polynomial.rst +0 -0
  63. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.operators.rst +0 -0
  64. {morphata-2.0.9 → morphata-2.0.10}/docs/api/morphata.symbolic.rst +0 -0
  65. {morphata-2.0.9 → morphata-2.0.10}/docs/concepts/acceptance.rst +0 -0
  66. {morphata-2.0.9 → morphata-2.0.10}/docs/concepts/hoa.rst +0 -0
  67. {morphata-2.0.9 → morphata-2.0.10}/docs/concepts/index.rst +0 -0
  68. {morphata-2.0.9 → morphata-2.0.10}/docs/concepts/operators.rst +0 -0
  69. {morphata-2.0.9 → morphata-2.0.10}/docs/concepts/spec.rst +0 -0
  70. {morphata-2.0.9 → morphata-2.0.10}/docs/concepts/weights.rst +0 -0
  71. {morphata-2.0.9 → morphata-2.0.10}/docs/conf.py +0 -0
  72. {morphata-2.0.9 → morphata-2.0.10}/docs/index.rst +0 -0
  73. {morphata-2.0.9 → morphata-2.0.10}/docs/quick-start.rst +0 -0
  74. {morphata-2.0.9 → morphata-2.0.10}/docs/symbolic-transforms.rst +0 -0
  75. {morphata-2.0.9 → morphata-2.0.10}/justfile +0 -0
  76. {morphata-2.0.9 → morphata-2.0.10}/pixi.lock +0 -0
  77. {morphata-2.0.9 → morphata-2.0.10}/pixi.toml +0 -0
  78. {morphata-2.0.9 → morphata-2.0.10}/project.utf-8.add +0 -0
  79. {morphata-2.0.9 → morphata-2.0.10}/project.utf-8.add.spl +0 -0
  80. {morphata-2.0.9 → morphata-2.0.10}/pyrefly.toml +0 -0
  81. {morphata-2.0.9 → morphata-2.0.10}/pytest.toml +0 -0
  82. {morphata-2.0.9 → morphata-2.0.10}/ruff.toml +0 -0
  83. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/__init__.py +0 -0
  84. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/acceptance.py +0 -0
  85. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/alphabet.py +0 -0
  86. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/automaton.py +0 -0
  87. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/builder.py +0 -0
  88. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/hoa/__init__.py +0 -0
  89. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/hoa/__main__.py +0 -0
  90. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/hoa/acc_expr.py +0 -0
  91. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/hoa/exporter.py +0 -0
  92. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/logic/__init__.py +0 -0
  93. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/logic/_common.py +0 -0
  94. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/logic/strel.py +0 -0
  95. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/operators/__init__.py +0 -0
  96. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/operators/_backend.py +0 -0
  97. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/operators/matrix.py +0 -0
  98. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/operators/polynomial.py +0 -0
  99. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/py.typed +0 -0
  100. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/symbolic/compose.py +0 -0
  101. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/symbolic/equivalence.py +0 -0
  102. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/symbolic/persistence.py +0 -0
  103. {morphata-2.0.9 → morphata-2.0.10}/src/morphata/symbolic/transforms.py +0 -0
  104. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut1.hoa +0 -0
  105. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut11.hoa +0 -0
  106. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut2.hoa +0 -0
  107. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut3.2.hoa +0 -0
  108. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut3.hoa +0 -0
  109. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut4.hoa +0 -0
  110. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut5.hoa +0 -0
  111. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut6.hoa +0 -0
  112. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut7.hoa +0 -0
  113. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/aut8.hoa +0 -0
  114. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/finite_aut.hoa +0 -0
  115. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/gen_buchi_3.hoa +0 -0
  116. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/gen_cobuchi_2.hoa +0 -0
  117. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/rabin_2pair.hoa +0 -0
  118. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/streett_2pair.hoa +0 -0
  119. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/test_exporter_basic.py +0 -0
  120. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/test_exporter_roundtrip.py +0 -0
  121. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/test_exporter_streaming.py +0 -0
  122. {morphata-2.0.9 → morphata-2.0.10}/tests/hoa/test_parser.py +0 -0
  123. {morphata-2.0.9 → morphata-2.0.10}/tests/logic/__init__.py +0 -0
  124. {morphata-2.0.9 → morphata-2.0.10}/tests/logic/test_ltl.py +0 -0
  125. {morphata-2.0.9 → morphata-2.0.10}/tests/logic/test_psl.py +0 -0
  126. {morphata-2.0.9 → morphata-2.0.10}/tests/logic/test_sere.py +0 -0
  127. {morphata-2.0.9 → morphata-2.0.10}/tests/logic/test_sere_properties.py +0 -0
  128. {morphata-2.0.9 → morphata-2.0.10}/tests/logic/test_strel.py +0 -0
  129. {morphata-2.0.9 → morphata-2.0.10}/tests/operators/test_matrix_from_ir.py +0 -0
  130. {morphata-2.0.9 → morphata-2.0.10}/tests/operators/test_matrix_from_ltl.py +0 -0
  131. {morphata-2.0.9 → morphata-2.0.10}/tests/operators/test_polynomial_from_ir.py +0 -0
  132. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_alphabet_aware_transitions.py +0 -0
  133. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_bdd_helpers.py +0 -0
  134. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_compose.py +0 -0
  135. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_composition_map.py +0 -0
  136. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_equivalence_naive_property.py +0 -0
  137. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_from_hoaf.py +0 -0
  138. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_ltlf2dfa_equivalence.py +0 -0
  139. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_step.py +0 -0
  140. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_symbolic_from_ir.py +0 -0
  141. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_to_hoa.py +0 -0
  142. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_transforms.py +0 -0
  143. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_transforms_ir.py +0 -0
  144. {morphata-2.0.9 → morphata-2.0.10}/tests/symbolic/test_transforms_properties.py +0 -0
  145. {morphata-2.0.9 → morphata-2.0.10}/tests/test_alphabet.py +0 -0
  146. {morphata-2.0.9 → morphata-2.0.10}/tests/test_automaton.py +0 -0
  147. {morphata-2.0.9 → morphata-2.0.10}/tests/test_builder.py +0 -0
  148. {morphata-2.0.9 → morphata-2.0.10}/tests/test_from_parsed.py +0 -0
  149. {morphata-2.0.9 → morphata-2.0.10}/ty.toml +0 -0
@@ -26,7 +26,10 @@
26
26
  "Bash(echo \"exit: $?\")",
27
27
  "Skill(superpowers:executing-plans)",
28
28
  "Bash(ruff check *)",
29
- "Bash(ruff format *)"
29
+ "Bash(ruff format *)",
30
+ "Bash(python -c \"import algebraic.polynomials as p; print\\(p.__file__\\)\")",
31
+ "Bash(python -c \"import algebraic; print\\(algebraic.__file__\\)\")",
32
+ "Bash(python -c \"import algebraic; print\\(algebraic.__path__\\)\")"
30
33
  ]
31
34
  }
32
35
  }
@@ -0,0 +1,16 @@
1
+ # CodeGraph data files
2
+ # These are local to each machine and should not be committed
3
+
4
+ # Database
5
+ *.db
6
+ *.db-wal
7
+ *.db-shm
8
+
9
+ # Cache
10
+ cache/
11
+
12
+ # Logs
13
+ *.log
14
+
15
+ # Hook markers
16
+ .dirty
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "pid": 92,
3
+ "version": "0.9.9",
4
+ "socketPath": "/home/anand/workspace/automata-stuff/morphata/.codegraph/daemon.sock",
5
+ "startedAt": 1780894603056
6
+ }
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.10 — 2026-06-08
4
+
5
+ ### Fixed
6
+
7
+ - Using `SymbolicAutomaton.to_bdd_json` and `SymbolicAutomaton.from_bdd_json` did not
8
+ preserve the positive normal form of the BDDs because we were using incorrect Shannon
9
+ cofactor expansion.
10
+
11
+ ### Performance
12
+
13
+ - The HOA grammar is now LALR compatible! This means we get a huge performance boost
14
+ even for pretty large automata.
15
+ - Similar thing was done in `logic-asts` and thus, the version will be bumped.
16
+
3
17
  ## 2.0.9 — 2026-06-04
4
18
 
5
19
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphata
3
- Version: 2.0.9
3
+ Version: 2.0.10
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
@@ -20,6 +20,11 @@ To get pytest-benchmark's tabular wall-time output:
20
20
  wall time across regimes that stress the per-row guard/successor
21
21
  caching (small DFA, sparse-big-alphabet DFA, conjunction min-DFA, raw
22
22
  AFA without minimization).
23
+ - `test_hoa_parser_perf.py` -- ``hoa.parser.parse`` (parse + transform)
24
+ across the sample corpus, parametrized over ``earley`` vs ``lalr`` from
25
+ the same grammar to quantify the LALR switch. Run with
26
+ ``--benchmark-group-by=param:case`` to put the two algorithms side by
27
+ side per input.
23
28
 
24
29
  ## Recording results
25
30
 
@@ -0,0 +1,114 @@
1
+ """Micro-benchmarks for ``morphata.hoa.parser.parse``.
2
+
3
+ Quantifies the Earley -> LALR switch in the HOA parser. Both variants are
4
+ built from the *same* committed grammar (`hoa.lark`); the only difference is
5
+ ``parser="earley"`` (the old default) versus ``parser="lalr"`` (current). The
6
+ grammar's ``acceptance_cond`` is stratified, so it is unambiguous and parses
7
+ under both algorithms -- the comparison is purely the parsing algorithm plus
8
+ the shared ``_AstTransformer`` pass, i.e. the full ``parse()`` pipeline.
9
+
10
+ Cases span the sample corpus from small (``aut1``) to the largest hand-written
11
+ files (``rabin_2pair``, ``streett_2pair``), plus a synthetic ``craftax``
12
+ stress input: a ~36-state min-DFA over 25 APs (the
13
+ ``test_min_dfa_of_conjunction_craftax_shaped_stress`` conjunction), exported to
14
+ HOA. At ~18 KB with dense per-edge guards it is ~35x the largest sample file
15
+ and pushes Earley into its superlinear regime, where the LALR win is starkest.
16
+
17
+ Run with::
18
+
19
+ pytest bench/test_hoa_parser_perf.py -v \\
20
+ --benchmark-columns=mean,median,min,max,rounds \\
21
+ --benchmark-group-by=param:case
22
+
23
+ Grouping by ``case`` puts the earley/lalr rows for the same input side by side
24
+ so the speedup is read directly off the table.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import functools
30
+ from pathlib import Path
31
+
32
+ import logic_asts as logic
33
+ import logic_asts.ltl as ltl
34
+ import pytest
35
+ from lark import Lark
36
+ from pytest_benchmark.fixture import BenchmarkFixture as Benchmark
37
+
38
+ from morphata.alphabet import powerset_alphabet
39
+ from morphata.hoa.exporter import symbolic_automaton_to_hoa
40
+ from morphata.hoa.parser import HOA_GRAMMAR_FILE, ParsedAutomaton, _AstTransformer
41
+ from morphata.symbolic.bdd import make_manager
42
+ from morphata.symbolic.compose import min_dfa_of_conjunction
43
+
44
+ _GRAMMAR = HOA_GRAMMAR_FILE.read_text()
45
+ _HOA_DIR = Path(__file__).resolve().parent.parent / "tests" / "hoa"
46
+
47
+ # One Lark instance per algorithm, built once and reused across rounds.
48
+ _PARSERS: dict[str, Lark] = {
49
+ "earley": Lark(_GRAMMAR, start="automaton", parser="earley", maybe_placeholders=True),
50
+ "lalr": Lark(_GRAMMAR, start="automaton", parser="lalr", maybe_placeholders=True),
51
+ }
52
+
53
+ # Representative slice of the sample corpus: smallest, a couple of mid-size
54
+ # omega conditions, the two largest acceptance formulas, and a synthetic
55
+ # stress input (built lazily, see ``_craftax_hoa``).
56
+ _CASES = ["aut1", "gen_buchi_3", "aut8", "rabin_2pair", "streett_2pair", "craftax"]
57
+
58
+
59
+ @functools.cache
60
+ def _craftax_hoa() -> str:
61
+ """Export the Craftax ENCHANT_SWORD-shaped min-DFA (~7 conjuncts, |AP|=25).
62
+
63
+ Mirrors ``test_min_dfa_of_conjunction_craftax_shaped_stress``. Cached so the
64
+ min-DFA is built once regardless of how many benchmark rounds run.
65
+ """
66
+ aps = {f"p{i}" for i in range(25)}
67
+ alphabet = powerset_alphabet(aps)
68
+ p = sorted(aps)
69
+
70
+ def F(name: str) -> logic.LTLExpr[str]: # noqa: N802
71
+ return ltl.Eventually(logic.Variable(name))
72
+
73
+ def U(lhs: logic.LTLExpr[str], rhs: logic.LTLExpr[str]) -> logic.LTLExpr[str]: # noqa: N802
74
+ return ltl.Until(lhs, rhs)
75
+
76
+ formulas: list[logic.LTLExpr[str]] = [
77
+ F(p[0]),
78
+ U(logic.Not(logic.Variable(p[0])), logic.Or((logic.Variable(p[1]), logic.Variable(p[2])))),
79
+ U(
80
+ logic.Not(logic.Variable(p[0])),
81
+ logic.Or((logic.Variable(p[3]), logic.Variable(p[4]), logic.Variable(p[5]))),
82
+ ),
83
+ U(logic.Not(logic.Variable(p[0])), logic.Or((logic.Variable(p[6]), logic.Variable(p[7])))),
84
+ F(p[1]),
85
+ F(p[4]),
86
+ F(p[7]),
87
+ ]
88
+ sym, _ = min_dfa_of_conjunction(formulas, alphabet=alphabet, manager=make_manager())
89
+ return symbolic_automaton_to_hoa(sym)
90
+
91
+
92
+ def _hoa_text(case: str) -> str:
93
+ if case == "craftax":
94
+ return _craftax_hoa()
95
+ return (_HOA_DIR / f"{case}.hoa").read_text()
96
+
97
+
98
+ def _parse_with(lark: Lark, text: str) -> ParsedAutomaton:
99
+ tree = lark.parse(text)
100
+ ret = _AstTransformer().transform(tree)
101
+ assert isinstance(ret, ParsedAutomaton)
102
+ return ret
103
+
104
+
105
+ @pytest.mark.parametrize("kind", ["earley", "lalr"])
106
+ @pytest.mark.parametrize("case", _CASES)
107
+ def test_hoa_parse(benchmark: Benchmark, case: str, kind: str) -> None:
108
+ text = _hoa_text(case)
109
+ lark = _PARSERS[kind]
110
+
111
+ def _run() -> int:
112
+ return len(_parse_with(lark, text).body)
113
+
114
+ assert benchmark(_run) >= 0
@@ -8,6 +8,8 @@ files = $MYPY_CONFIG_FILE_DIR/src/**/*.py,
8
8
 
9
9
  cache_dir = $MYPY_CONFIG_FILE_DIR/.mypy_cache
10
10
 
11
+ mypy_path = $MYPY_CONFIG_FILE_DIR/typings
12
+
11
13
  exclude_gitignore = True
12
14
 
13
15
 
@@ -43,3 +45,11 @@ ignore_missing_imports = True
43
45
 
44
46
  [mypy-wadler_lindig]
45
47
  ignore_missing_imports = True
48
+
49
+ [mypy-spot]
50
+ ignore_missing_imports = True
51
+ disallow_untyped_defs = False
52
+
53
+ [mypy-spot.*]
54
+ ignore_missing_imports = True
55
+ disallow_untyped_defs = False
@@ -2,7 +2,7 @@
2
2
  name = "morphata"
3
3
  authors = [{ name = "Anand Balakrishnan", email = "anandbala1597@gmail.com" }]
4
4
  description = "Representation theory for automata, made practical."
5
- version = "2.0.9"
5
+ version = "2.0.10"
6
6
  requires-python = ">=3.12"
7
7
  classifiers = [
8
8
  "Intended Audience :: Science/Research",
@@ -2,7 +2,7 @@
2
2
  "include": [
3
3
  "src",
4
4
  "tests",
5
- "packages"
5
+ "bench"
6
6
  ],
7
7
  "pythonVersion": "3.12",
8
8
  "pythonPlatform": "All",
@@ -38,13 +38,19 @@ state_conj: INT ("&" INT)*
38
38
  | "(" label_expr ")"
39
39
 
40
40
 
41
- ?acceptance_cond: BOOLEAN -> acc_bool
42
- | "Fin" "(" acc_set ")" -> acc_fin
43
- | "Inf" "(" acc_set ")" -> acc_inf
44
- | "Final" "(" acc_set ")" -> acc_final
45
- | "(" acceptance_cond ")"
46
- | acceptance_cond "&" acceptance_cond -> acc_and
47
- | acceptance_cond "|" acceptance_cond -> acc_or
41
+ ?acceptance_cond: acc_or
42
+
43
+ ?acc_or : acc_and
44
+ | acc_or "|" acc_and -> acc_or
45
+
46
+ ?acc_and : acc_atom
47
+ | acc_and "&" acc_atom -> acc_and
48
+
49
+ ?acc_atom: BOOLEAN -> acc_bool
50
+ | "Fin" "(" acc_set ")" -> acc_fin
51
+ | "Inf" "(" acc_set ")" -> acc_inf
52
+ | "Final" "(" acc_set ")" -> acc_final
53
+ | "(" acceptance_cond ")"
48
54
 
49
55
  acc_set: [INVERT] INT
50
56
  INVERT: "!"
@@ -365,7 +365,8 @@ with open(HOA_GRAMMAR_FILE, "r") as grammar:
365
365
  HOA_GRAMMAR = Lark(
366
366
  grammar,
367
367
  start="automaton",
368
- strict=True,
368
+ parser="lalr",
369
+ cache=True,
369
370
  maybe_placeholders=True,
370
371
  )
371
372
 
@@ -29,6 +29,7 @@ from morphata.symbolic.bdd import (
29
29
  Function,
30
30
  Manager,
31
31
  bdd_to_boolexpr,
32
+ bdd_to_positive_boolexpr,
32
33
  declare_state_vars,
33
34
  mgr_partition_ap_by_state_residual,
34
35
  state_var_name,
@@ -117,7 +118,7 @@ def ltl_to_automaton(
117
118
  if state_residual == mgr.false:
118
119
  # No transition fires under this AP-cube; omit the row.
119
120
  continue
120
- succ_expr = _state_bdd_to_positive_boolexpr(state_residual, mgr)
121
+ succ_expr = ty.cast("BoolExpr[int]", bdd_to_positive_boolexpr(state_residual))
121
122
  guard_expr = ty.cast("BoolExpr[AP]", bdd_to_boolexpr(ap_cube, var_decoder=ap_guard_decoder))
122
123
  row_list.append((guard_expr, succ_expr))
123
124
  rows[sid] = row_list
@@ -148,143 +149,6 @@ def ltl_to_automaton(
148
149
  )
149
150
 
150
151
 
151
- def _state_bdd_to_positive_boolexpr(node: Function, mgr: Manager) -> BoolExpr[int]:
152
- """Convert a monotone-positive BDD over state vars ``q{i}`` to a positive
153
- :class:`BoolExpr[int]` (no ``Not`` over state variables).
154
-
155
- Why a dedicated converter
156
- -------------------------
157
- The downstream :func:`morphata.symbolic.bdd.boolexpr_to_bdd` rejects
158
- ``Not`` over state variables on purpose: the AFA-to-NFA dealternation
159
- pipeline (``mgr_minimal_pos_satisfying_subsets``,
160
- ``mgr_partition_ap_by_state_residual``, etc.) relies on every
161
- successor BDD being monotone-positive in the state vars. The generic
162
- :func:`bdd_to_boolexpr` emits the symmetric Shannon form
163
- ``(v & high) | (~v & low)`` at the "neither short-circuits" arm,
164
- which would slip a ``~q_i`` into the row's successor expression and
165
- fail at re-BDD time even though the underlying function is positive.
166
-
167
- Algorithm
168
- ---------
169
- Memoised post-order walk over the BDD nodes. At each non-terminal
170
- node with variable ``v`` and children ``low = walk(n.low)``,
171
- ``high = walk(n.high)``::
172
-
173
- if low == high: built = low
174
- elif low_is_false and high_is_true: built = var
175
- elif low_is_false: built = var & high
176
- elif high_is_true: built = var | low
177
- else: built = (var & high) | low
178
-
179
- Terminals decode to ``Literal(True)`` / ``Literal(False)``; the
180
- equality test ``n == mgr.true`` already accounts for the complement
181
- bit on terminal edges.
182
-
183
- Correctness of ``(v & high) | low`` (the general arm)
184
- -----------------------------------------------------
185
- The standard Shannon expansion is::
186
-
187
- f = (v & high) | (~v & low)
188
-
189
- For monotone-positive ``f`` the cofactors satisfy ``low <= high``
190
- (i.e. ``low ==> high`` -- the set of satisfying assignments of
191
- ``low`` is contained in that of ``high``). Therefore
192
- ``low == low & high`` and::
193
-
194
- (v & high) | low
195
- = (v & high) | (low & high)
196
- = high & (v | low)
197
- = ((v | low) & high)
198
-
199
- Checking against the standard form:
200
-
201
- * ``v = True``: standard gives ``high``; simplified gives
202
- ``True & high | low = high | low = high`` (since ``low <= high``).
203
- * ``v = False``: standard gives ``low``; simplified gives
204
- ``False & high | low = low``.
205
-
206
- Both match, so the simplified form is equivalent to the standard
207
- Shannon expansion at every node, and it never mentions ``~v``.
208
-
209
- Where the monotone-positive invariant comes from
210
- ------------------------------------------------
211
- The polynomial compiled into ``node`` by
212
- :func:`_polynomial_to_bdd` is built from
213
- :func:`ltl_symbolic_expansion`, which only ever emits state atoms
214
- via ``logic.Variable(("state", obj))`` -- never with a wrapping
215
- ``Not``. So the polynomial is positive in state vars by
216
- construction, and cofactoring on AP variables (which is what
217
- :func:`mgr_partition_ap_by_state_residual` does) cannot introduce
218
- a negation on a state var. Every state-residual BDD reaching this
219
- function therefore satisfies the contract.
220
-
221
- Guard rails
222
- -----------
223
- Two ``elif`` arms cover BDD shapes that cannot occur for a
224
- monotone-positive function (``high == false`` with ``low != false``,
225
- or ``low == true`` with ``high != true``) and raise instead of
226
- silently producing an incorrect Shannon form. A non-terminal node
227
- whose top edge is complemented (``Function.negated``) would flip the
228
- function's value to its negation -- also incompatible with
229
- monotone-positivity -- and likewise raises.
230
-
231
- Raises
232
- ------
233
- ValueError
234
- If ``node`` references a non-state variable, has a complement
235
- edge on a non-terminal, or otherwise fails the
236
- monotone-positive contract.
237
- """
238
- cache: dict[Function, BoolExpr[int]] = {}
239
-
240
- def walk(n: Function) -> BoolExpr[int]:
241
- if n in cache:
242
- return cache[n]
243
- if n.var is None:
244
- result: BoolExpr[int] = logic.Literal(n == mgr.true)
245
- cache[n] = result
246
- return result
247
- if not n.var.startswith("q"):
248
- raise ValueError(f"expected state variable, got {n.var!r}")
249
- if getattr(n, "negated", False):
250
- # A complemented top-edge on a non-terminal would make this
251
- # subtree non-monotone in state vars, which contradicts the
252
- # caller's contract.
253
- raise ValueError("state residual BDD has complement edge; not monotone-positive")
254
- idx = int(n.var[1:])
255
- var: BoolExpr[int] = logic.Variable(idx)
256
- assert n.low is not None and n.high is not None
257
- low = walk(n.low)
258
- high = walk(n.high)
259
- low_is_false = isinstance(low, logic.Literal) and low.value is False
260
- low_is_true = isinstance(low, logic.Literal) and low.value is True
261
- high_is_false = isinstance(high, logic.Literal) and high.value is False
262
- high_is_true = isinstance(high, logic.Literal) and high.value is True
263
-
264
- if low == high:
265
- built: BoolExpr[int] = low
266
- elif high_is_false:
267
- # Monotone-positive plus high=false forces low=false (low<=high),
268
- # already handled by low==high above.
269
- raise ValueError("non-monotone BDD: high=false but low!=false")
270
- elif low_is_true:
271
- # Same: low=true forces high=true; handled by low==high.
272
- raise ValueError("non-monotone BDD: low=true but high!=true")
273
- elif low_is_false and high_is_true:
274
- built = var
275
- elif low_is_false:
276
- built = logic.And((var, high))
277
- elif high_is_true:
278
- built = logic.Or((var, low))
279
- else:
280
- # General positive form: (var & high) | low, valid because low <= high.
281
- built = logic.Or((logic.And((var, high)), low))
282
- cache[n] = built
283
- return built
284
-
285
- return walk(node)
286
-
287
-
288
152
  def _polynomial_to_bdd(
289
153
  mgr: Manager,
290
154
  poly: BoolExpr[tuple[str, Hashable]],
@@ -25,7 +25,6 @@ from morphata.logic._common import dualize_polynomial as common_dualize_polynomi
25
25
  from morphata.logic._common import make_logic_context
26
26
  from morphata.logic.ltl import (
27
27
  _polynomial_to_bdd,
28
- _state_bdd_to_positive_boolexpr,
29
28
  ltl_empty_trace_value,
30
29
  ltl_symbolic_expansion,
31
30
  )
@@ -41,6 +40,7 @@ from morphata.logic.sere import (
41
40
  from morphata.symbolic.bdd import (
42
41
  Manager,
43
42
  bdd_to_boolexpr,
43
+ bdd_to_positive_boolexpr,
44
44
  declare_state_vars,
45
45
  mgr_partition_ap_by_state_residual,
46
46
  )
@@ -339,7 +339,7 @@ def psl_to_automaton(
339
339
  for ap_cube, state_residual in mgr_partition_ap_by_state_residual(mgr, bdd_poly, ap_var_names):
340
340
  if state_residual == mgr.false:
341
341
  continue
342
- succ_expr = _state_bdd_to_positive_boolexpr(state_residual, mgr)
342
+ succ_expr = ty.cast("BoolExpr[int]", bdd_to_positive_boolexpr(state_residual))
343
343
  guard_expr = ty.cast(
344
344
  "BoolExpr[AP]",
345
345
  bdd_to_boolexpr(ap_cube, var_decoder=ap_guard_decoder),
@@ -32,10 +32,11 @@ from morphata.acceptance import Finite
32
32
  from morphata.automaton import Automaton, Outgoing
33
33
  from morphata.logic._common import dualize_polynomial as _common_dualize_polynomial
34
34
  from morphata.logic._common import make_logic_context
35
- from morphata.logic.ltl import _polynomial_to_bdd, _state_bdd_to_positive_boolexpr
35
+ from morphata.logic.ltl import _polynomial_to_bdd
36
36
  from morphata.symbolic.bdd import (
37
37
  Manager,
38
38
  bdd_to_boolexpr,
39
+ bdd_to_positive_boolexpr,
39
40
  declare_state_vars,
40
41
  mgr_partition_ap_by_state_residual,
41
42
  )
@@ -485,7 +486,7 @@ def sere_to_automaton(
485
486
  for ap_cube, state_residual in mgr_partition_ap_by_state_residual(mgr, bdd_poly, ap_var_names):
486
487
  if state_residual == mgr.false:
487
488
  continue
488
- succ_expr = _state_bdd_to_positive_boolexpr(state_residual, mgr)
489
+ succ_expr = ty.cast("BoolExpr[int]", bdd_to_positive_boolexpr(state_residual))
489
490
  guard_expr = ty.cast(
490
491
  "BoolExpr[AP]",
491
492
  bdd_to_boolexpr(ap_cube, var_decoder=ap_guard_decoder),
@@ -31,6 +31,7 @@ from morphata.symbolic.bdd import APRegistry as APRegistry
31
31
  from morphata.symbolic.bdd import Function as Function
32
32
  from morphata.symbolic.bdd import Manager as Manager
33
33
  from morphata.symbolic.bdd import bdd_to_boolexpr as bdd_to_boolexpr
34
+ from morphata.symbolic.bdd import bdd_to_positive_boolexpr as bdd_to_positive_boolexpr
34
35
  from morphata.symbolic.bdd import boolexpr_to_bdd as boolexpr_to_bdd
35
36
  from morphata.symbolic.bdd import declare_ap_vars as declare_ap_vars
36
37
  from morphata.symbolic.bdd import declare_state_vars as declare_state_vars
@@ -196,7 +196,7 @@ class SymbolicAutomaton(Generic[AP]):
196
196
  and there are no rows to serialize.
197
197
  """
198
198
  from morphata.automaton import Outgoing
199
- from morphata.symbolic.bdd import bdd_to_boolexpr
199
+ from morphata.symbolic.bdd import bdd_to_boolexpr, bdd_to_positive_boolexpr
200
200
 
201
201
  if self.num_states > 0 and not self.transitions:
202
202
  raise ValueError(
@@ -220,7 +220,10 @@ class SymbolicAutomaton(Generic[AP]):
220
220
  "BoolExpr[AP]",
221
221
  bdd_to_boolexpr(guard_bdd, var_decoder=_ap_decode),
222
222
  )
223
- succ_expr = typing.cast("BoolExpr[int]", bdd_to_boolexpr(succ_bdd))
223
+ # Successor BDDs are monotone-positive over the state vars;
224
+ # decode them so the result stays in positive normal form
225
+ # (PolynomialOperator rejects Not over state variables).
226
+ succ_expr = typing.cast("BoolExpr[int]", bdd_to_positive_boolexpr(succ_bdd))
224
227
  decoded_rows.append((guard_expr, succ_expr))
225
228
  rows_by_state[s] = decoded_rows
226
229
 
@@ -230,7 +233,9 @@ class SymbolicAutomaton(Generic[AP]):
230
233
  ) -> Iterable[tuple[BoolExpr[AP], BoolExpr[int]]]:
231
234
  return iter(_rows.get(s, []))
232
235
 
233
- initial_expr = typing.cast("BoolExpr[int]", bdd_to_boolexpr(self.initial))
236
+ # The initial-state set is monotone-positive over the state vars; keep
237
+ # it in positive normal form for the same reason as successors above.
238
+ initial_expr = typing.cast("BoolExpr[int]", bdd_to_positive_boolexpr(self.initial))
234
239
 
235
240
  return Automaton(
236
241
  states=frozenset(range(self.num_states)),
@@ -53,6 +53,7 @@ __all__ = [
53
53
  "Manager",
54
54
  "STATE_VAR_FORMAT",
55
55
  "bdd_to_boolexpr",
56
+ "bdd_to_positive_boolexpr",
56
57
  "boolexpr_to_bdd",
57
58
  "declare_ap_vars",
58
59
  "declare_state_vars",
@@ -301,6 +302,109 @@ def bdd_to_boolexpr(
301
302
  return walk(node)
302
303
 
303
304
 
305
+ def bdd_to_positive_boolexpr(
306
+ node: Function,
307
+ *,
308
+ var_decoder: Callable[[str], Hashable] | None = None,
309
+ ) -> BoolExpr[Any]:
310
+ """Decode a *monotone-positive* BDD to a :class:`BoolExpr` without ``Not``.
311
+
312
+ The dual of :func:`boolexpr_to_bdd`, which rejects ``Not`` on
313
+ construction. Use this -- not :func:`bdd_to_boolexpr` -- whenever the
314
+ BDD encodes a function that is monotone positive in its variables and
315
+ the decoded expression must stay in positive normal form: AFA
316
+ successor and initial-state expressions over the ``q{i}`` state
317
+ namespace are the canonical case (``PolynomialOperator`` rejects
318
+ ``Not`` over state variables).
319
+
320
+ The generic :func:`bdd_to_boolexpr` emits the symmetric Shannon form
321
+ ``(v & high) | (~v & low)`` at the arm where neither cofactor is
322
+ constant, slipping a ``~v`` into the output even when the underlying
323
+ function is positive. For monotone-positive ``f`` the cofactors satisfy
324
+ ``low <= high``, so ``(v & high) | (~v & low) == (v & high) | low`` --
325
+ the same function with no negation. This decoder emits the latter.
326
+
327
+ Parameters
328
+ ----------
329
+ node :
330
+ Live ``dd.Function`` node encoding a monotone-positive function.
331
+ var_decoder :
332
+ Optional mapping from BDD variable name to the user-level variable
333
+ identifier embedded in the returned ``BoolExpr``. When ``None``
334
+ (the default), variable names must follow :data:`STATE_VAR_FORMAT`
335
+ (``q{i}``) and decode to ``int`` indices.
336
+
337
+ Returns
338
+ -------
339
+ BoolExpr
340
+ Positive (``Not``-free) expression equal to ``node``. With the
341
+ default decoder the result is ``BoolExpr[int]`` over state indices.
342
+
343
+ Raises
344
+ ------
345
+ ValueError
346
+ If ``node`` has a complement edge on a non-terminal, or otherwise
347
+ fails the monotone-positive contract (``high == false`` with
348
+ ``low != false``, or ``low == true`` with ``high != true``). These
349
+ shapes cannot occur for a monotone-positive function and indicate
350
+ the input did not satisfy the precondition.
351
+ """
352
+
353
+ def _default_decoder(name: str) -> int:
354
+ if not name.startswith("q"):
355
+ raise ValueError(f"Cannot convert BDD with non-state variable {name!r} to BoolExpr[int]")
356
+ return int(name[1:])
357
+
358
+ decode: Callable[[str], Hashable] = var_decoder if var_decoder is not None else _default_decoder
359
+
360
+ cache: dict[Function, BoolExpr[Any]] = {}
361
+
362
+ def walk(n: Function) -> BoolExpr[Any]:
363
+ if n in cache:
364
+ return cache[n]
365
+ if n.var is None:
366
+ mgr = n.bdd
367
+ result: BoolExpr[Any] = logic.Literal(n == mgr.true)
368
+ cache[n] = result
369
+ return result
370
+ if getattr(n, "negated", False):
371
+ # A complemented top-edge on a non-terminal flips the function to
372
+ # its negation, which cannot be monotone-positive.
373
+ raise ValueError("BDD has a complement edge on a non-terminal; not monotone-positive")
374
+ var: BoolExpr[Any] = logic.Variable(decode(n.var))
375
+ assert n.low is not None and n.high is not None
376
+ low = walk(n.low)
377
+ high = walk(n.high)
378
+ low_is_false = isinstance(low, logic.Literal) and low.value is False
379
+ low_is_true = isinstance(low, logic.Literal) and low.value is True
380
+ high_is_true = isinstance(high, logic.Literal) and high.value is True
381
+ high_is_false = isinstance(high, logic.Literal) and high.value is False
382
+
383
+ built: BoolExpr[Any]
384
+ if low == high:
385
+ built = low
386
+ elif high_is_false:
387
+ # Monotone-positive plus high=false forces low=false (low<=high),
388
+ # already handled by low==high above.
389
+ raise ValueError("non-monotone BDD: high=false but low!=false")
390
+ elif low_is_true:
391
+ # Same: low=true forces high=true; handled by low==high.
392
+ raise ValueError("non-monotone BDD: low=true but high!=true")
393
+ elif low_is_false and high_is_true:
394
+ built = var
395
+ elif low_is_false:
396
+ built = logic.And((var, high))
397
+ elif high_is_true:
398
+ built = logic.Or((var, low))
399
+ else:
400
+ # General positive form: (var & high) | low, valid because low <= high.
401
+ built = logic.Or((logic.And((var, high)), low))
402
+ cache[n] = built
403
+ return built
404
+
405
+ return walk(node)
406
+
407
+
304
408
  def mgr_eval_subset(
305
409
  mgr: Manager,
306
410
  bdd: Function,
@@ -101,7 +101,7 @@ def spot_ltlf_hoa(formula: str) -> str:
101
101
  """
102
102
 
103
103
  if HAS_SPOT == "lib":
104
- import spot # type: ignore[import-untyped]
104
+ import spot
105
105
 
106
106
  f = spot.from_ltlf(spot.formula(formula))
107
107
  aut = f.translate(*SPOT_AUT_ARGS)