morphata 2.0.4__tar.gz → 2.0.6__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 (138) hide show
  1. {morphata-2.0.4 → morphata-2.0.6}/CHANGELOG.md +26 -0
  2. {morphata-2.0.4 → morphata-2.0.6}/PKG-INFO +2 -2
  3. {morphata-2.0.4 → morphata-2.0.6}/ci/make-release +3 -3
  4. {morphata-2.0.4 → morphata-2.0.6}/pixi.lock +1 -1
  5. {morphata-2.0.4 → morphata-2.0.6}/pixi.toml +1 -1
  6. {morphata-2.0.4 → morphata-2.0.6}/pyproject.toml +2 -2
  7. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/hoa/__init__.py +2 -1
  8. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/hoa/exporter.py +282 -9
  9. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/symbolic/__init__.py +5 -1
  10. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/symbolic/automaton.py +118 -5
  11. morphata-2.0.6/src/morphata/symbolic/persistence.py +311 -0
  12. morphata-2.0.6/tests/hoa/test_exporter_streaming.py +298 -0
  13. morphata-2.0.6/tests/symbolic/test_alphabet_aware_transitions.py +140 -0
  14. morphata-2.0.6/tests/symbolic/test_persistence.py +125 -0
  15. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_to_hoa.py +21 -0
  16. {morphata-2.0.4 → morphata-2.0.6}/uv.lock +2 -2
  17. {morphata-2.0.4 → morphata-2.0.6}/.basedpyright/baseline.json +0 -0
  18. {morphata-2.0.4 → morphata-2.0.6}/.claude/settings.local.json +0 -0
  19. {morphata-2.0.4 → morphata-2.0.6}/.dockerignore +0 -0
  20. {morphata-2.0.4 → morphata-2.0.6}/.editorconfig +0 -0
  21. {morphata-2.0.4 → morphata-2.0.6}/.envrc +0 -0
  22. {morphata-2.0.4 → morphata-2.0.6}/.envrc.recommended +0 -0
  23. {morphata-2.0.4 → morphata-2.0.6}/.fdignore +0 -0
  24. {morphata-2.0.4 → morphata-2.0.6}/.gitattributes +0 -0
  25. {morphata-2.0.4 → morphata-2.0.6}/.gitignore +0 -0
  26. {morphata-2.0.4 → morphata-2.0.6}/.mailmap +0 -0
  27. {morphata-2.0.4 → morphata-2.0.6}/.python-version +0 -0
  28. {morphata-2.0.4 → morphata-2.0.6}/.rstcheck.cfg +0 -0
  29. {morphata-2.0.4 → morphata-2.0.6}/AGENTS.md +0 -0
  30. {morphata-2.0.4 → morphata-2.0.6}/CLAUDE.md +0 -0
  31. {morphata-2.0.4 → morphata-2.0.6}/CONTRIBUTING.md +0 -0
  32. {morphata-2.0.4 → morphata-2.0.6}/LICENSE +0 -0
  33. {morphata-2.0.4 → morphata-2.0.6}/README.md +0 -0
  34. {morphata-2.0.4 → morphata-2.0.6}/bench/README.md +0 -0
  35. {morphata-2.0.4 → morphata-2.0.6}/bench/__init__.py +0 -0
  36. {morphata-2.0.4 → morphata-2.0.6}/bench/conftest.py +0 -0
  37. {morphata-2.0.4 → morphata-2.0.6}/bench/test_sere_nlm_perf.py +0 -0
  38. {morphata-2.0.4 → morphata-2.0.6}/cliff.toml +0 -0
  39. {morphata-2.0.4 → morphata-2.0.6}/docs/_static/navigation.html +0 -0
  40. {morphata-2.0.4 → morphata-2.0.6}/docs/api/modules.rst +0 -0
  41. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.acceptance.rst +0 -0
  42. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.alphabet.rst +0 -0
  43. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.automaton.rst +0 -0
  44. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.builder.rst +0 -0
  45. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.hoa.rst +0 -0
  46. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.logic.ltl.rst +0 -0
  47. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.logic.psl.rst +0 -0
  48. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.logic.sere.rst +0 -0
  49. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.logic.strel.rst +0 -0
  50. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.operators.matrix.rst +0 -0
  51. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.operators.polynomial.rst +0 -0
  52. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.operators.rst +0 -0
  53. {morphata-2.0.4 → morphata-2.0.6}/docs/api/morphata.symbolic.rst +0 -0
  54. {morphata-2.0.4 → morphata-2.0.6}/docs/concepts/acceptance.rst +0 -0
  55. {morphata-2.0.4 → morphata-2.0.6}/docs/concepts/hoa.rst +0 -0
  56. {morphata-2.0.4 → morphata-2.0.6}/docs/concepts/index.rst +0 -0
  57. {morphata-2.0.4 → morphata-2.0.6}/docs/concepts/operators.rst +0 -0
  58. {morphata-2.0.4 → morphata-2.0.6}/docs/concepts/spec.rst +0 -0
  59. {morphata-2.0.4 → morphata-2.0.6}/docs/concepts/weights.rst +0 -0
  60. {morphata-2.0.4 → morphata-2.0.6}/docs/conf.py +0 -0
  61. {morphata-2.0.4 → morphata-2.0.6}/docs/index.rst +0 -0
  62. {morphata-2.0.4 → morphata-2.0.6}/docs/quick-start.rst +0 -0
  63. {morphata-2.0.4 → morphata-2.0.6}/docs/symbolic-transforms.rst +0 -0
  64. {morphata-2.0.4 → morphata-2.0.6}/justfile +0 -0
  65. {morphata-2.0.4 → morphata-2.0.6}/mypy.ini +0 -0
  66. {morphata-2.0.4 → morphata-2.0.6}/project.utf-8.add +0 -0
  67. {morphata-2.0.4 → morphata-2.0.6}/project.utf-8.add.spl +0 -0
  68. {morphata-2.0.4 → morphata-2.0.6}/pyrefly.toml +0 -0
  69. {morphata-2.0.4 → morphata-2.0.6}/pyrightconfig.json +0 -0
  70. {morphata-2.0.4 → morphata-2.0.6}/pytest.toml +0 -0
  71. {morphata-2.0.4 → morphata-2.0.6}/ruff.toml +0 -0
  72. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/__init__.py +0 -0
  73. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/acceptance.py +0 -0
  74. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/alphabet.py +0 -0
  75. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/automaton.py +0 -0
  76. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/builder.py +0 -0
  77. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/hoa/__main__.py +0 -0
  78. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/hoa/acc_expr.py +0 -0
  79. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/hoa/hoa.lark +0 -0
  80. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/hoa/parser.py +0 -0
  81. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/logic/__init__.py +0 -0
  82. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/logic/_common.py +0 -0
  83. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/logic/ltl.py +0 -0
  84. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/logic/psl.py +0 -0
  85. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/logic/sere.py +0 -0
  86. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/logic/strel.py +0 -0
  87. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/operators/__init__.py +0 -0
  88. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/operators/_backend.py +0 -0
  89. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/operators/matrix.py +0 -0
  90. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/operators/polynomial.py +0 -0
  91. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/py.typed +0 -0
  92. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/symbolic/bdd.py +0 -0
  93. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/symbolic/compose.py +0 -0
  94. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/symbolic/equivalence.py +0 -0
  95. {morphata-2.0.4 → morphata-2.0.6}/src/morphata/symbolic/transforms.py +0 -0
  96. {morphata-2.0.4 → morphata-2.0.6}/tests/conftest.py +0 -0
  97. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut1.hoa +0 -0
  98. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut11.hoa +0 -0
  99. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut2.hoa +0 -0
  100. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut3.2.hoa +0 -0
  101. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut3.hoa +0 -0
  102. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut4.hoa +0 -0
  103. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut5.hoa +0 -0
  104. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut6.hoa +0 -0
  105. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut7.hoa +0 -0
  106. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/aut8.hoa +0 -0
  107. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/finite_aut.hoa +0 -0
  108. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/gen_buchi_3.hoa +0 -0
  109. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/gen_cobuchi_2.hoa +0 -0
  110. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/rabin_2pair.hoa +0 -0
  111. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/streett_2pair.hoa +0 -0
  112. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/test_exporter_basic.py +0 -0
  113. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/test_exporter_roundtrip.py +0 -0
  114. {morphata-2.0.4 → morphata-2.0.6}/tests/hoa/test_parser.py +0 -0
  115. {morphata-2.0.4 → morphata-2.0.6}/tests/logic/__init__.py +0 -0
  116. {morphata-2.0.4 → morphata-2.0.6}/tests/logic/test_ltl.py +0 -0
  117. {morphata-2.0.4 → morphata-2.0.6}/tests/logic/test_psl.py +0 -0
  118. {morphata-2.0.4 → morphata-2.0.6}/tests/logic/test_sere.py +0 -0
  119. {morphata-2.0.4 → morphata-2.0.6}/tests/logic/test_sere_properties.py +0 -0
  120. {morphata-2.0.4 → morphata-2.0.6}/tests/logic/test_strel.py +0 -0
  121. {morphata-2.0.4 → morphata-2.0.6}/tests/operators/test_matrix_from_ir.py +0 -0
  122. {morphata-2.0.4 → morphata-2.0.6}/tests/operators/test_matrix_from_ltl.py +0 -0
  123. {morphata-2.0.4 → morphata-2.0.6}/tests/operators/test_polynomial_from_ir.py +0 -0
  124. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_bdd_helpers.py +0 -0
  125. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_compose.py +0 -0
  126. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_from_hoaf.py +0 -0
  127. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_ltlf2dfa_equivalence.py +0 -0
  128. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_step.py +0 -0
  129. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_symbolic_automaton.py +0 -0
  130. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_symbolic_from_ir.py +0 -0
  131. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_transforms.py +0 -0
  132. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_transforms_ir.py +0 -0
  133. {morphata-2.0.4 → morphata-2.0.6}/tests/symbolic/test_transforms_properties.py +0 -0
  134. {morphata-2.0.4 → morphata-2.0.6}/tests/test_alphabet.py +0 -0
  135. {morphata-2.0.4 → morphata-2.0.6}/tests/test_automaton.py +0 -0
  136. {morphata-2.0.4 → morphata-2.0.6}/tests/test_builder.py +0 -0
  137. {morphata-2.0.4 → morphata-2.0.6}/tests/test_from_parsed.py +0 -0
  138. {morphata-2.0.4 → morphata-2.0.6}/ty.toml +0 -0
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.6 — 2026-06-02
4
+
5
+ ### Fixed
6
+
7
+ - A major regression where the streaming `to_hoa` stuff changed the label enumeration
8
+ semantics to be _minterms_ instead of _cubes_.
9
+ This is now fixed.
10
+
11
+ ### Added
12
+
13
+ - Added a free-floating function that helps enumerate the successor BDDs from a symbolic
14
+ automaton state, given a specific alphabet and set of symbols.
15
+
16
+ ## 2.0.5 — 2026-06-01
17
+
18
+ ### Added
19
+
20
+ `SymbolicAutomaton` and `Automaton` now support a streaming writer
21
+ for HOA export via `morphata.hoa.symbolic_automaton_to_hoa`
22
+ and `morphata.hoa.automaton_to_hoa`, and is also present in `SymbolicAutomaton.to_hoa`.
23
+
24
+ This is mostly a performance thing:
25
+ providing a buffered writer to the exporter allows one to stream the HOA format into a
26
+ file, without having to manifest the entire string representation of the automaton,
27
+ which can be cumbersome.
28
+
3
29
  ## 2.0.4 — 2026-05-28
4
30
 
5
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphata
3
- Version: 2.0.4
3
+ Version: 2.0.6
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,7 +20,7 @@ Requires-Dist: logic-asts>=1.6.2
20
20
  Requires-Dist: networkx
21
21
  Requires-Dist: numpy
22
22
  Requires-Dist: types-networkx
23
- Requires-Dist: typing-extensions
23
+ Requires-Dist: typing-extensions>=4.14
24
24
  Provides-Extra: dot
25
25
  Requires-Dist: pydot>=4.0.1; extra == 'dot'
26
26
  Provides-Extra: jax
@@ -88,7 +88,7 @@ echo "==> Promoting CHANGELOG Unreleased -> ${NEW_VERSION} (${RELEASE_DATE})"
88
88
  sed -i "s/^## Unreleased$/## ${NEW_VERSION} \xe2\x80\x94 ${RELEASE_DATE}/" CHANGELOG.md
89
89
 
90
90
  # Commit
91
- COMMIT_MSG="chore(release): ${NEW_VERSION}"
91
+ COMMIT_MSG="chore(release): $(uv version)"
92
92
  echo "==> Committing: $COMMIT_MSG"
93
93
 
94
94
  if is_jj; then
@@ -120,8 +120,8 @@ echo "==> Tagging $TAG"
120
120
  if is_jj; then
121
121
  jj git import 2>/dev/null || true
122
122
  fi
123
- printf '%s\n\n%s\n' "$TAG_TITLE" "$TAG_BODY" \
124
- | git tag -a "$TAG" --cleanup=verbatim -F -
123
+ printf '%s\n\n%s\n' "$TAG_TITLE" "$TAG_BODY" |
124
+ git tag -a "$TAG" --cleanup=verbatim -F -
125
125
  if is_jj; then
126
126
  jj git import 2>/dev/null || true
127
127
  fi
@@ -5474,7 +5474,7 @@ packages:
5474
5474
  - networkx
5475
5475
  - numpy
5476
5476
  - types-networkx
5477
- - typing-extensions
5477
+ - typing-extensions>=4.14
5478
5478
  - jax>=0.8 ; extra == 'jax'
5479
5479
  - torch>=2.0 ; extra == 'torch'
5480
5480
  - pydot>=4.0.1 ; extra == 'dot'
@@ -17,7 +17,7 @@ lark = ">=1.3.1"
17
17
  networkx = "*"
18
18
  numpy = "*"
19
19
  types-networkx = "*"
20
- typing-extensions = "*"
20
+ typing-extensions = ">=4.14"
21
21
 
22
22
  [pypi-dependencies]
23
23
  morphata = { path = ".", editable = true }
@@ -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.4"
5
+ version = "2.0.6"
6
6
  requires-python = ">=3.12"
7
7
  classifiers = [
8
8
  "Intended Audience :: Science/Research",
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "networkx",
22
22
  "numpy",
23
23
  "types-networkx",
24
- "typing-extensions",
24
+ "typing-extensions>=4.14",
25
25
  ]
26
26
 
27
27
  [project.optional-dependencies]
@@ -37,7 +37,8 @@ Example extended HOA with finite acceptance::
37
37
 
38
38
  import morphata.hoa.parser as parser
39
39
  from morphata.hoa.exporter import automaton_to_hoa as automaton_to_hoa
40
+ from morphata.hoa.exporter import symbolic_automaton_to_hoa as symbolic_automaton_to_hoa
40
41
  from morphata.hoa.parser import ParsedAutomaton as ParsedAutomaton
41
42
  from morphata.hoa.parser import parse as parse
42
43
 
43
- __all__ = ["parse", "parser", "ParsedAutomaton", "automaton_to_hoa"]
44
+ __all__ = ["parse", "parser", "ParsedAutomaton", "automaton_to_hoa", "symbolic_automaton_to_hoa"]
@@ -12,23 +12,53 @@ import io
12
12
  import typing as ty
13
13
  from collections.abc import Iterable, Mapping, Sequence
14
14
  from collections.abc import Set as AbstractSet
15
+ from typing import TYPE_CHECKING
15
16
 
16
17
  import logic_asts.base as logic
18
+ from typing_extensions import Writer, overload
17
19
 
18
20
  import morphata.hoa.acc_expr as acc_expr
19
21
 
20
- if ty.TYPE_CHECKING:
22
+ if TYPE_CHECKING:
21
23
  from morphata.acceptance import AcceptanceCondition
22
24
  from morphata.automaton import Automaton
25
+ from morphata.symbolic.automaton import SymbolicAutomaton
26
+ from morphata.symbolic.bdd import Function, Manager
23
27
 
24
28
 
29
+ @overload
25
30
  def automaton_to_hoa(
26
- aut: Automaton[int, ty.Any, ty.Any],
31
+ aut: "Automaton[int, ty.Any, ty.Any]",
32
+ *,
33
+ name: str | None = ...,
34
+ properties: "Sequence[str] | None" = ...,
35
+ out: None = ...,
36
+ ) -> str: ...
37
+
38
+
39
+ @overload
40
+ def automaton_to_hoa(
41
+ aut: "Automaton[int, ty.Any, ty.Any]",
42
+ *,
43
+ name: str | None = ...,
44
+ properties: "Sequence[str] | None" = ...,
45
+ out: "Writer[str]",
46
+ ) -> None: ...
47
+
48
+
49
+ def automaton_to_hoa(
50
+ aut: "Automaton[int, ty.Any, ty.Any]",
27
51
  *,
28
52
  name: str | None = None,
29
- properties: Sequence[str] | None = None,
30
- ) -> str:
31
- """Serialize *aut* to an HOA v1 string.
53
+ properties: "Sequence[str] | None" = None,
54
+ out: "Writer[str] | None" = None,
55
+ ) -> "str | None":
56
+ """Serialize *aut* to HOA v1.
57
+
58
+ If ``out`` is ``None`` (the default), build the HOA in an internal
59
+ ``io.StringIO`` and return the resulting string. If ``out`` is
60
+ provided, stream the HOA directly into it and return ``None``; the
61
+ caller owns flushing / closing.
32
62
 
33
63
  Requirements on *aut*:
34
64
 
@@ -50,7 +80,8 @@ def automaton_to_hoa(
50
80
  ap_to_index = {ap: i for i, ap in enumerate(ap_order)}
51
81
  acc_name, acc_expr_text, num_sets, acc_state_sets = _acceptance_to_hoa(aut.acceptance)
52
82
 
53
- buf = io.StringIO()
83
+ owns_buffer = out is None
84
+ buf: Writer[str] = io.StringIO() if out is None else out
54
85
  _write_header(
55
86
  buf,
56
87
  aut=aut,
@@ -69,7 +100,186 @@ def automaton_to_hoa(
69
100
  ap_to_index=ap_to_index,
70
101
  acc_state_sets=acc_state_sets,
71
102
  )
72
- return buf.getvalue()
103
+ if owns_buffer:
104
+ assert isinstance(buf, io.StringIO)
105
+ return buf.getvalue()
106
+ return None
107
+
108
+
109
+ @overload
110
+ def symbolic_automaton_to_hoa(
111
+ sym: "SymbolicAutomaton[ty.Any]",
112
+ *,
113
+ name: str | None = ...,
114
+ properties: Sequence[str] | None = ...,
115
+ out: None = ...,
116
+ ) -> str: ...
117
+
118
+
119
+ @overload
120
+ def symbolic_automaton_to_hoa(
121
+ sym: "SymbolicAutomaton[ty.Any]",
122
+ *,
123
+ name: str | None = ...,
124
+ properties: Sequence[str] | None = ...,
125
+ out: Writer[str],
126
+ ) -> None: ...
127
+
128
+
129
+ def symbolic_automaton_to_hoa(
130
+ sym: "SymbolicAutomaton[ty.Any]",
131
+ *,
132
+ name: str | None = None,
133
+ properties: Sequence[str] | None = None,
134
+ out: Writer[str] | None = None,
135
+ ) -> str | None:
136
+ """Serialize a :class:`SymbolicAutomaton` to HOA v1 directly from its BDDs.
137
+
138
+ Walks ``sym.transitions`` row-by-row. For each cached
139
+ ``(guard_bdd, succ_bdd)`` pair, decodes both BDDs into compact
140
+ ``logic_asts.BoolExpr`` trees via Shannon expansion
141
+ (:func:`~morphata.symbolic.bdd.bdd_to_boolexpr`) and emits one HOA
142
+ edge per disjunct of the successor expression, with a single label
143
+ expression per row. Cube structure of the guard is preserved -- variables
144
+ absent from a given path through the BDD are projected out -- so the
145
+ output size is proportional to the BDD shape, not to ``2^|support|``.
146
+
147
+ ``out`` semantics match :func:`automaton_to_hoa`: when ``None``, an
148
+ internal ``StringIO`` accumulates the output and is returned;
149
+ otherwise the output streams into ``out`` and the function returns
150
+ ``None``.
151
+
152
+ Literal-true / literal-false successor BDDs (``mgr.true`` /
153
+ ``mgr.false``) are handled by emitting synthetic sink states with
154
+ self-loops, mirroring :func:`_materialize_sinks` on the IR side. The
155
+ BDD manager is not mutated. As on the IR side, sink materialisation
156
+ is only implemented for :class:`~morphata.acceptance.Finite`
157
+ acceptance; omega-regular conditions with literal successors raise
158
+ ``NotImplementedError``.
159
+
160
+ Raises
161
+ ------
162
+ ValueError
163
+ If the cached transition table is empty but ``sym.num_states > 0``.
164
+ NotImplementedError
165
+ If literal-true / literal-false successors are present and the
166
+ acceptance condition is not :class:`~morphata.acceptance.Finite`.
167
+ """
168
+ from morphata.acceptance import Finite
169
+ from morphata.symbolic.bdd import bdd_to_boolexpr
170
+
171
+ if sym.num_states > 0 and not sym.transitions:
172
+ raise ValueError(
173
+ "symbolic_automaton_to_hoa requires a populated transition cache; "
174
+ "reconstruct SymbolicAutomaton with cache_transitions=True."
175
+ )
176
+
177
+ mgr = sym.manager
178
+
179
+ needs_true = sym.initial == mgr.true
180
+ needs_false = sym.initial == mgr.false
181
+ for s in range(sym.num_states):
182
+ for _, succ_bdd in sym.transitions.get(s, ()):
183
+ if succ_bdd == mgr.true:
184
+ needs_true = True
185
+ elif succ_bdd == mgr.false:
186
+ needs_false = True
187
+
188
+ if (needs_true or needs_false) and not isinstance(sym.acceptance, Finite):
189
+ raise NotImplementedError(
190
+ "HOA export: literal-true / literal-false successor BDDs require "
191
+ "sink-state materialisation, which is only implemented for "
192
+ f"Finite acceptance; got {type(sym.acceptance).__name__}."
193
+ )
194
+
195
+ next_idx = sym.num_states
196
+ true_sink: int | None = None
197
+ false_sink: int | None = None
198
+ if needs_true:
199
+ true_sink = next_idx
200
+ next_idx += 1
201
+ if needs_false:
202
+ false_sink = next_idx
203
+ next_idx += 1
204
+ total_states = next_idx
205
+
206
+ ap_order = sorted(sym.ap_registry, key=repr)
207
+ ap_to_index: dict[ty.Any, int] = {ap: i for i, ap in enumerate(ap_order)}
208
+ ap_decoder_map: dict[str, ty.Any] = {var: ap for ap, var in sym.ap_registry.items()}
209
+
210
+ def _ap_decode(name: str) -> ty.Any: # noqa: ANN401
211
+ try:
212
+ return ap_decoder_map[name]
213
+ except KeyError as exc:
214
+ raise ValueError(f"Guard BDD references variable {name!r} not in the AP registry.") from exc
215
+
216
+ acc_name, acc_expr_text, num_sets, acc_state_sets = _acceptance_to_hoa(sym.acceptance)
217
+ if true_sink is not None:
218
+ # Finite acceptance: extend set 0 (the accepting set) with the
219
+ # true-sink. ``_acceptance_to_hoa(Finite)`` always returns
220
+ # ``{0: ...}``.
221
+ augmented_sets: dict[int, AbstractSet[int]] = dict(acc_state_sets)
222
+ augmented_sets[0] = frozenset(augmented_sets.get(0, frozenset())) | {true_sink}
223
+ acc_state_sets = augmented_sets
224
+ states = list(range(sym.num_states))
225
+
226
+ owns_buffer = out is None
227
+ buf: Writer[str] = io.StringIO() if owns_buffer else out # type: ignore[assignment]
228
+
229
+ buf.write("HOA: v1\n")
230
+ if name is not None:
231
+ buf.write(f'name: "{name}"\n')
232
+ buf.write(f"States: {total_states}\n")
233
+ if sym.initial == mgr.true:
234
+ assert true_sink is not None
235
+ buf.write(f"Start: {true_sink}\n")
236
+ elif sym.initial == mgr.false:
237
+ assert false_sink is not None
238
+ buf.write(f"Start: {false_sink}\n")
239
+ else:
240
+ initial_expr = ty.cast("logic.BoolExpr[int]", bdd_to_boolexpr(sym.initial))
241
+ for start_line in _initial_start_lines(initial_expr):
242
+ buf.write(f"Start: {start_line}\n")
243
+ ap_words = " ".join(f'"{ap}"' for ap in ap_order)
244
+ buf.write(f"AP: {len(ap_order)}{(' ' + ap_words) if ap_order else ''}\n")
245
+ if acc_name is not None:
246
+ buf.write(f"acc-name: {acc_name}\n")
247
+ buf.write(f"Acceptance: {num_sets} {acc_expr_text}\n")
248
+ if properties:
249
+ buf.write("properties: " + " ".join(properties) + "\n")
250
+
251
+ buf.write("--BODY--\n")
252
+ for s in states:
253
+ sets_for_s = sorted(idx for idx, members in acc_state_sets.items() if s in members)
254
+ marker = (" {" + " ".join(str(i) for i in sets_for_s) + "}") if sets_for_s else ""
255
+ buf.write(f"State: {s}{marker}\n")
256
+ for guard_bdd, succ_bdd in sym.transitions.get(s, ()):
257
+ if succ_bdd == mgr.true:
258
+ assert true_sink is not None
259
+ dsts = [str(true_sink)]
260
+ elif succ_bdd == mgr.false:
261
+ assert false_sink is not None
262
+ dsts = [str(false_sink)]
263
+ else:
264
+ succ_expr = ty.cast("logic.BoolExpr[int]", bdd_to_boolexpr(succ_bdd))
265
+ dsts = _state_expr_to_edges(succ_expr)
266
+ guard_expr = bdd_to_boolexpr(guard_bdd, var_decoder=_ap_decode)
267
+ label = _label_to_hoa(guard_expr, ap_to_index)
268
+ for dst in dsts:
269
+ buf.write(f"[{label}] {dst}\n")
270
+ for sink in (true_sink, false_sink):
271
+ if sink is None:
272
+ continue
273
+ sets_for_sink = sorted(idx for idx, members in acc_state_sets.items() if sink in members)
274
+ marker = (" {" + " ".join(str(i) for i in sets_for_sink) + "}") if sets_for_sink else ""
275
+ buf.write(f"State: {sink}{marker}\n")
276
+ buf.write(f"[t] {sink}\n")
277
+ buf.write("--END--\n")
278
+
279
+ if owns_buffer:
280
+ assert isinstance(buf, io.StringIO)
281
+ return buf.getvalue()
282
+ return None
73
283
 
74
284
 
75
285
  # ---------------------------------------------------------------------------
@@ -181,7 +391,7 @@ def _materialize_sinks(
181
391
 
182
392
 
183
393
  def _write_header(
184
- buf: io.StringIO,
394
+ buf: Writer[str],
185
395
  *,
186
396
  aut: Automaton[int, ty.Any, ty.Any],
187
397
  states: list[int],
@@ -255,7 +465,7 @@ def _flatten_or(expr: logic.BoolExpr[ty.Any]) -> list[logic.BoolExpr[ty.Any]]:
255
465
 
256
466
 
257
467
  def _write_body(
258
- buf: io.StringIO,
468
+ buf: Writer[str],
259
469
  *,
260
470
  aut: Automaton[int, ty.Any, ty.Any],
261
471
  states: list[int],
@@ -351,6 +561,69 @@ def _conj_to_dst(expr: logic.BoolExpr[int], *, context: str) -> str:
351
561
  raise ValueError(f"HOA {context} expression must be a disjunction of conjunctions of state variables; got {expr!r}.")
352
562
 
353
563
 
564
+ # ---------------------------------------------------------------------------
565
+ # BDD-direct helpers (symbolic_automaton_to_hoa)
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ def _cube_to_hoa_label(
570
+ cube: Mapping[str, bool],
571
+ bdd_var_to_ap_index: Mapping[str, int],
572
+ ) -> str:
573
+ """Render a single BDD cube over AP variables as an HOA label.
574
+
575
+ *cube* is a ``{bdd_var_name: bool}`` dict from ``dd``'s
576
+ ``BDD.pick_iter``; variables absent from the BDD's support are
577
+ don't-cares and do not appear in the output.
578
+ *bdd_var_to_ap_index* maps the BDD variable name back to the AP's
579
+ HOA index. Literals in the output are sorted by AP index for
580
+ deterministic output.
581
+ """
582
+ if not cube:
583
+ return "t"
584
+ literals: list[tuple[int, bool]] = []
585
+ for var_name, polarity in cube.items():
586
+ try:
587
+ idx = bdd_var_to_ap_index[var_name]
588
+ except KeyError as exc:
589
+ raise ValueError(f"BDD cube references variable {var_name!r} not in the AP registry.") from exc
590
+ literals.append((idx, polarity))
591
+ literals.sort(key=lambda iv: iv[0])
592
+ parts = [(str(idx) if polarity else f"!{idx}") for idx, polarity in literals]
593
+ if len(parts) == 1:
594
+ return parts[0]
595
+ return "(" + " & ".join(parts) + ")"
596
+
597
+
598
+ def _bdd_state_cubes(node: "Function", *, mgr: "Manager") -> list[str]:
599
+ """Enumerate satisfying cubes of *node* over state variables as HOA dst strings.
600
+
601
+ *node* is a ``dd.Function`` over the state variables ``q0..q{n-1}``;
602
+ *mgr* is the owning ``dd.BDD`` manager (``pick_iter`` lives on it,
603
+ not on Function objects). We enumerate minterms over the BDD's
604
+ *support* (i.e. no ``care_vars`` argument), so a point-successor
605
+ function naturally yields one cube.
606
+
607
+ For each cube:
608
+
609
+ - Positive state variables -> the cube's HOA destination
610
+ ``"d1&d2&..."`` (a single integer when there is one positive).
611
+ - Negative state variables in the cube are dropped from the output
612
+ (HOA `dst` expressions list only the chosen states).
613
+
614
+ A cube with zero positive state variables is treated as a bug and
615
+ raises ``ValueError``; the caller is responsible for rejecting
616
+ literal-false successor BDDs before reaching here.
617
+ """
618
+ cubes: list[str] = []
619
+ for cube in mgr.pick_iter(node):
620
+ positives = sorted(int(name[1:]) for name, val in cube.items() if val)
621
+ if not positives:
622
+ raise ValueError("Successor BDD cube has no positive state variable; cannot render an HOA destination.")
623
+ cubes.append("&".join(str(i) for i in positives))
624
+ return cubes
625
+
626
+
354
627
  # ---------------------------------------------------------------------------
355
628
  # Acceptance
356
629
  # ---------------------------------------------------------------------------
@@ -10,7 +10,10 @@ This subpackage provides:
10
10
  - :mod:`morphata.symbolic.automaton`: :class:`SymbolicAutomaton`, a
11
11
  representation that wraps an :class:`~morphata.automaton.Automaton`
12
12
  so the initial state set and every cached transition are live BDD
13
- nodes in a single ``dd.BDD`` manager.
13
+ nodes in a single ``dd.BDD`` manager. Also exposes
14
+ :func:`successors_for_alphabet`, a freestanding enumerator that
15
+ yields ``(symbol, succ_bdd)`` per declared symbol without
16
+ materialising a per-state transition relation.
14
17
  - :mod:`morphata.symbolic.transforms`: BDD-level structural transforms
15
18
  -- :func:`~morphata.symbolic.transforms.nfa_from_afa` (dealternation),
16
19
  :func:`~morphata.symbolic.transforms.determinize` (subset construction
@@ -21,6 +24,7 @@ This subpackage provides:
21
24
  """
22
25
 
23
26
  from morphata.symbolic.automaton import SymbolicAutomaton as SymbolicAutomaton
27
+ from morphata.symbolic.automaton import successors_for_alphabet as successors_for_alphabet
24
28
  from morphata.symbolic.bdd import AP_VAR_FORMAT as AP_VAR_FORMAT
25
29
  from morphata.symbolic.bdd import STATE_VAR_FORMAT as STATE_VAR_FORMAT
26
30
  from morphata.symbolic.bdd import APRegistry as APRegistry
@@ -32,7 +32,7 @@ see :mod:`morphata.symbolic.transforms`.
32
32
  from __future__ import annotations
33
33
 
34
34
  import typing
35
- from collections.abc import Callable, Hashable, Iterable, Mapping, Sequence
35
+ from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping, Sequence
36
36
  from collections.abc import Set as AbstractSet
37
37
  from dataclasses import dataclass, field
38
38
  from typing import TYPE_CHECKING, Any, Generic
@@ -44,6 +44,10 @@ from morphata.automaton import Automaton, BoolExpr
44
44
  from morphata.hoa import ParsedAutomaton
45
45
 
46
46
  if TYPE_CHECKING:
47
+ from pathlib import Path
48
+
49
+ from typing_extensions import Writer
50
+
47
51
  from morphata.alphabet import BooleanAlphabet
48
52
  from morphata.symbolic.bdd import (
49
53
  APRegistry,
@@ -230,11 +234,43 @@ class SymbolicAutomaton(Generic[AP]):
230
234
  *,
231
235
  name: str | None = None,
232
236
  properties: Sequence[str] | None = None,
233
- ) -> str:
234
- """Serialize ``self`` to an HOA v1 string via :func:`automaton_to_hoa`."""
235
- from morphata.hoa import automaton_to_hoa
237
+ out: "Writer[str] | None" = None,
238
+ ) -> str | None:
239
+ """Serialize ``self`` to HOA v1 via :func:`symbolic_automaton_to_hoa`.
240
+
241
+ Walks the cached transition BDDs directly -- no
242
+ ``logic_asts.BoolExpr`` intermediates. When ``out`` is given,
243
+ streams output into it and returns ``None``; otherwise returns
244
+ the full HOA string.
245
+ """
246
+ from morphata.hoa import symbolic_automaton_to_hoa
247
+
248
+ return symbolic_automaton_to_hoa(self, name=name, properties=properties, out=out)
249
+
250
+ def to_bdd_json(self, path: "str | Path") -> None:
251
+ """Persist ``self`` to a BDD JSON cache via :func:`to_bdd_json`.
252
+
253
+ Writes ``path`` (the BDD nodes, via :mod:`dd`'s multi-root JSON
254
+ dump) and a sibling ``path + ".meta.json"`` (acceptance, AP
255
+ registry, transition root layout). Intended as an internal
256
+ fast-path cache; HOA remains the right format for sharing
257
+ automata with other tools.
258
+ """
259
+ from morphata.symbolic.persistence import to_bdd_json
260
+
261
+ to_bdd_json(self, path)
262
+
263
+ @staticmethod
264
+ def from_bdd_json(
265
+ path: "str | Path",
266
+ *,
267
+ alphabet: "BooleanAlphabet[Any, str]",
268
+ manager: Manager | None = None,
269
+ ) -> "SymbolicAutomaton[str]":
270
+ """Reload a :class:`SymbolicAutomaton` written by :meth:`to_bdd_json`."""
271
+ from morphata.symbolic.persistence import from_bdd_json
236
272
 
237
- return automaton_to_hoa(self.to_automaton(), name=name, properties=properties)
273
+ return from_bdd_json(path, alphabet=alphabet, manager=manager)
238
274
 
239
275
  def to_min_dfa(self) -> "SymbolicAutomaton[AP]":
240
276
  """Determinize and minimize ``self`` into a minimal DFA.
@@ -349,6 +385,83 @@ class SymbolicAutomaton(Generic[AP]):
349
385
  )
350
386
 
351
387
 
388
+ # ---------------------------------------------------------------------------
389
+ # Alphabet-aware enumeration
390
+ # ---------------------------------------------------------------------------
391
+
392
+
393
+ def successors_for_alphabet(
394
+ sym_aut: "SymbolicAutomaton[AP]",
395
+ source: int,
396
+ alphabet: "BooleanAlphabet[Any, AP]",
397
+ symbols: Iterable[Any],
398
+ ) -> Iterator[tuple[Any, Function]]:
399
+ """Yield ``(symbol, succ_bdd)`` for each ``symbol in symbols`` whose
400
+ cofactor of the source's outgoing transitions is not ``mgr.false``.
401
+
402
+ Each yielded ``succ_bdd`` is a BDD over state variables encoding the
403
+ set of successors of ``source`` on ``symbol``. For DFA-shaped
404
+ automata it is a single state variable; for non-deterministic
405
+ shapes it is the OR of active successors.
406
+
407
+ Cost is bounded by ``|symbols| * len(sym_aut.transitions[source])``
408
+ ``mgr.let`` calls on per-row guards (each typically very small).
409
+ No per-state transition relation is materialised.
410
+
411
+ Yield order is a stable contract: pairs are yielded in the same
412
+ order ``symbols`` is iterated, with ``mgr.false`` symbols omitted.
413
+ Callers may walk the input alongside the yielded pairs with a two-
414
+ pointer pattern instead of materialising an intermediate dict.
415
+
416
+ Equivalence to ``step``: for any ``symbol``, the yielded
417
+ ``succ_bdd`` equals ``sym_aut.step(mgr.var(state_var_name(source)),
418
+ symbol)``; symbols whose ``step`` result is ``mgr.false`` are not
419
+ yielded.
420
+
421
+ Parameters
422
+ ----------
423
+ sym_aut
424
+ SymbolicAutomaton constructed with ``cache_transitions=True``.
425
+ source
426
+ Source state index in ``range(sym_aut.num_states)``.
427
+ alphabet
428
+ Boolean labeling alphabet over the same AP space as
429
+ ``sym_aut.ap_registry``.
430
+ symbols
431
+ Symbols to enumerate. Iterated exactly once.
432
+
433
+ Raises
434
+ ------
435
+ ValueError
436
+ If ``sym_aut`` was constructed with ``cache_transitions=False``
437
+ (no per-row pairs to cofactor), or if ``alphabet.predicates``
438
+ contains an AP not present in ``sym_aut.ap_registry``.
439
+ """
440
+ if sym_aut.num_states > 0 and not sym_aut.transitions:
441
+ raise ValueError(
442
+ "successors_for_alphabet requires a populated transition cache; "
443
+ "reconstruct SymbolicAutomaton with cache_transitions=True."
444
+ )
445
+ unknown = set(alphabet.predicates) - set(sym_aut.ap_registry)
446
+ if unknown:
447
+ raise ValueError(f"alphabet has unknown predicates not in ap_registry: {sorted(map(repr, unknown))}")
448
+
449
+ mgr = sym_aut.manager
450
+ rows = sym_aut.transitions[source]
451
+ ap_registry = sym_aut.ap_registry
452
+
453
+ for symbol in symbols:
454
+ truths = alphabet.labeling(symbol)
455
+ ap_point: dict[str, Function] = {ap_registry[ap]: (mgr.true if ap in truths else mgr.false) for ap in ap_registry}
456
+ succ_acc: Function = mgr.false
457
+ for guard_bdd, succ_bdd in rows:
458
+ restricted = mgr.let(ap_point, guard_bdd)
459
+ if restricted == mgr.true:
460
+ succ_acc = mgr.apply("or", succ_acc, succ_bdd)
461
+ if succ_acc != mgr.false:
462
+ yield symbol, succ_acc
463
+
464
+
352
465
  # ---------------------------------------------------------------------------
353
466
  # Private helpers
354
467
  # ---------------------------------------------------------------------------