morphata 2.0.5__tar.gz → 2.0.7__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.5 → morphata-2.0.7}/CHANGELOG.md +20 -0
  2. {morphata-2.0.5 → morphata-2.0.7}/PKG-INFO +1 -1
  3. {morphata-2.0.5 → morphata-2.0.7}/pyproject.toml +1 -1
  4. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/hoa/exporter.py +25 -12
  5. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/symbolic/__init__.py +5 -1
  6. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/symbolic/automaton.py +105 -1
  7. morphata-2.0.7/src/morphata/symbolic/persistence.py +346 -0
  8. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/test_exporter_streaming.py +48 -0
  9. morphata-2.0.7/tests/symbolic/test_alphabet_aware_transitions.py +140 -0
  10. morphata-2.0.7/tests/symbolic/test_persistence.py +175 -0
  11. {morphata-2.0.5 → morphata-2.0.7}/uv.lock +1 -1
  12. {morphata-2.0.5 → morphata-2.0.7}/.basedpyright/baseline.json +0 -0
  13. {morphata-2.0.5 → morphata-2.0.7}/.claude/settings.local.json +0 -0
  14. {morphata-2.0.5 → morphata-2.0.7}/.dockerignore +0 -0
  15. {morphata-2.0.5 → morphata-2.0.7}/.editorconfig +0 -0
  16. {morphata-2.0.5 → morphata-2.0.7}/.envrc +0 -0
  17. {morphata-2.0.5 → morphata-2.0.7}/.envrc.recommended +0 -0
  18. {morphata-2.0.5 → morphata-2.0.7}/.fdignore +0 -0
  19. {morphata-2.0.5 → morphata-2.0.7}/.gitattributes +0 -0
  20. {morphata-2.0.5 → morphata-2.0.7}/.gitignore +0 -0
  21. {morphata-2.0.5 → morphata-2.0.7}/.mailmap +0 -0
  22. {morphata-2.0.5 → morphata-2.0.7}/.python-version +0 -0
  23. {morphata-2.0.5 → morphata-2.0.7}/.rstcheck.cfg +0 -0
  24. {morphata-2.0.5 → morphata-2.0.7}/AGENTS.md +0 -0
  25. {morphata-2.0.5 → morphata-2.0.7}/CLAUDE.md +0 -0
  26. {morphata-2.0.5 → morphata-2.0.7}/CONTRIBUTING.md +0 -0
  27. {morphata-2.0.5 → morphata-2.0.7}/LICENSE +0 -0
  28. {morphata-2.0.5 → morphata-2.0.7}/README.md +0 -0
  29. {morphata-2.0.5 → morphata-2.0.7}/bench/README.md +0 -0
  30. {morphata-2.0.5 → morphata-2.0.7}/bench/__init__.py +0 -0
  31. {morphata-2.0.5 → morphata-2.0.7}/bench/conftest.py +0 -0
  32. {morphata-2.0.5 → morphata-2.0.7}/bench/test_sere_nlm_perf.py +0 -0
  33. {morphata-2.0.5 → morphata-2.0.7}/ci/make-release +0 -0
  34. {morphata-2.0.5 → morphata-2.0.7}/cliff.toml +0 -0
  35. {morphata-2.0.5 → morphata-2.0.7}/docs/_static/navigation.html +0 -0
  36. {morphata-2.0.5 → morphata-2.0.7}/docs/api/modules.rst +0 -0
  37. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.acceptance.rst +0 -0
  38. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.alphabet.rst +0 -0
  39. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.automaton.rst +0 -0
  40. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.builder.rst +0 -0
  41. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.hoa.rst +0 -0
  42. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.logic.ltl.rst +0 -0
  43. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.logic.psl.rst +0 -0
  44. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.logic.sere.rst +0 -0
  45. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.logic.strel.rst +0 -0
  46. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.operators.matrix.rst +0 -0
  47. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.operators.polynomial.rst +0 -0
  48. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.operators.rst +0 -0
  49. {morphata-2.0.5 → morphata-2.0.7}/docs/api/morphata.symbolic.rst +0 -0
  50. {morphata-2.0.5 → morphata-2.0.7}/docs/concepts/acceptance.rst +0 -0
  51. {morphata-2.0.5 → morphata-2.0.7}/docs/concepts/hoa.rst +0 -0
  52. {morphata-2.0.5 → morphata-2.0.7}/docs/concepts/index.rst +0 -0
  53. {morphata-2.0.5 → morphata-2.0.7}/docs/concepts/operators.rst +0 -0
  54. {morphata-2.0.5 → morphata-2.0.7}/docs/concepts/spec.rst +0 -0
  55. {morphata-2.0.5 → morphata-2.0.7}/docs/concepts/weights.rst +0 -0
  56. {morphata-2.0.5 → morphata-2.0.7}/docs/conf.py +0 -0
  57. {morphata-2.0.5 → morphata-2.0.7}/docs/index.rst +0 -0
  58. {morphata-2.0.5 → morphata-2.0.7}/docs/quick-start.rst +0 -0
  59. {morphata-2.0.5 → morphata-2.0.7}/docs/symbolic-transforms.rst +0 -0
  60. {morphata-2.0.5 → morphata-2.0.7}/justfile +0 -0
  61. {morphata-2.0.5 → morphata-2.0.7}/mypy.ini +0 -0
  62. {morphata-2.0.5 → morphata-2.0.7}/pixi.lock +0 -0
  63. {morphata-2.0.5 → morphata-2.0.7}/pixi.toml +0 -0
  64. {morphata-2.0.5 → morphata-2.0.7}/project.utf-8.add +0 -0
  65. {morphata-2.0.5 → morphata-2.0.7}/project.utf-8.add.spl +0 -0
  66. {morphata-2.0.5 → morphata-2.0.7}/pyrefly.toml +0 -0
  67. {morphata-2.0.5 → morphata-2.0.7}/pyrightconfig.json +0 -0
  68. {morphata-2.0.5 → morphata-2.0.7}/pytest.toml +0 -0
  69. {morphata-2.0.5 → morphata-2.0.7}/ruff.toml +0 -0
  70. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/__init__.py +0 -0
  71. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/acceptance.py +0 -0
  72. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/alphabet.py +0 -0
  73. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/automaton.py +0 -0
  74. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/builder.py +0 -0
  75. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/hoa/__init__.py +0 -0
  76. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/hoa/__main__.py +0 -0
  77. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/hoa/acc_expr.py +0 -0
  78. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/hoa/hoa.lark +0 -0
  79. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/hoa/parser.py +0 -0
  80. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/logic/__init__.py +0 -0
  81. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/logic/_common.py +0 -0
  82. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/logic/ltl.py +0 -0
  83. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/logic/psl.py +0 -0
  84. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/logic/sere.py +0 -0
  85. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/logic/strel.py +0 -0
  86. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/operators/__init__.py +0 -0
  87. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/operators/_backend.py +0 -0
  88. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/operators/matrix.py +0 -0
  89. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/operators/polynomial.py +0 -0
  90. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/py.typed +0 -0
  91. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/symbolic/bdd.py +0 -0
  92. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/symbolic/compose.py +0 -0
  93. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/symbolic/equivalence.py +0 -0
  94. {morphata-2.0.5 → morphata-2.0.7}/src/morphata/symbolic/transforms.py +0 -0
  95. {morphata-2.0.5 → morphata-2.0.7}/tests/conftest.py +0 -0
  96. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut1.hoa +0 -0
  97. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut11.hoa +0 -0
  98. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut2.hoa +0 -0
  99. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut3.2.hoa +0 -0
  100. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut3.hoa +0 -0
  101. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut4.hoa +0 -0
  102. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut5.hoa +0 -0
  103. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut6.hoa +0 -0
  104. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut7.hoa +0 -0
  105. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/aut8.hoa +0 -0
  106. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/finite_aut.hoa +0 -0
  107. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/gen_buchi_3.hoa +0 -0
  108. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/gen_cobuchi_2.hoa +0 -0
  109. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/rabin_2pair.hoa +0 -0
  110. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/streett_2pair.hoa +0 -0
  111. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/test_exporter_basic.py +0 -0
  112. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/test_exporter_roundtrip.py +0 -0
  113. {morphata-2.0.5 → morphata-2.0.7}/tests/hoa/test_parser.py +0 -0
  114. {morphata-2.0.5 → morphata-2.0.7}/tests/logic/__init__.py +0 -0
  115. {morphata-2.0.5 → morphata-2.0.7}/tests/logic/test_ltl.py +0 -0
  116. {morphata-2.0.5 → morphata-2.0.7}/tests/logic/test_psl.py +0 -0
  117. {morphata-2.0.5 → morphata-2.0.7}/tests/logic/test_sere.py +0 -0
  118. {morphata-2.0.5 → morphata-2.0.7}/tests/logic/test_sere_properties.py +0 -0
  119. {morphata-2.0.5 → morphata-2.0.7}/tests/logic/test_strel.py +0 -0
  120. {morphata-2.0.5 → morphata-2.0.7}/tests/operators/test_matrix_from_ir.py +0 -0
  121. {morphata-2.0.5 → morphata-2.0.7}/tests/operators/test_matrix_from_ltl.py +0 -0
  122. {morphata-2.0.5 → morphata-2.0.7}/tests/operators/test_polynomial_from_ir.py +0 -0
  123. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_bdd_helpers.py +0 -0
  124. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_compose.py +0 -0
  125. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_from_hoaf.py +0 -0
  126. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_ltlf2dfa_equivalence.py +0 -0
  127. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_step.py +0 -0
  128. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_symbolic_automaton.py +0 -0
  129. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_symbolic_from_ir.py +0 -0
  130. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_to_hoa.py +0 -0
  131. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_transforms.py +0 -0
  132. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_transforms_ir.py +0 -0
  133. {morphata-2.0.5 → morphata-2.0.7}/tests/symbolic/test_transforms_properties.py +0 -0
  134. {morphata-2.0.5 → morphata-2.0.7}/tests/test_alphabet.py +0 -0
  135. {morphata-2.0.5 → morphata-2.0.7}/tests/test_automaton.py +0 -0
  136. {morphata-2.0.5 → morphata-2.0.7}/tests/test_builder.py +0 -0
  137. {morphata-2.0.5 → morphata-2.0.7}/tests/test_from_parsed.py +0 -0
  138. {morphata-2.0.5 → morphata-2.0.7}/ty.toml +0 -0
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.7 — 2026-06-02
4
+
5
+ ### Fixed
6
+
7
+ - The BDD JSON exporter in `dd.cudd` needed to be worked around because of a bug where
8
+ `mgr.true` and `mrg.false` do not survive a round trip serialization.
9
+
10
+ ## 2.0.6 — 2026-06-02
11
+
12
+ ### Fixed
13
+
14
+ - A major regression where the streaming `to_hoa` stuff changed the label enumeration
15
+ semantics to be _minterms_ instead of _cubes_.
16
+ This is now fixed.
17
+
18
+ ### Added
19
+
20
+ - Added a free-floating function that helps enumerate the successor BDDs from a symbolic
21
+ automaton state, given a specific alphabet and set of symbols.
22
+
3
23
  ## 2.0.5 — 2026-06-01
4
24
 
5
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphata
3
- Version: 2.0.5
3
+ Version: 2.0.7
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
@@ -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.5"
5
+ version = "2.0.7"
6
6
  requires-python = ">=3.12"
7
7
  classifiers = [
8
8
  "Intended Audience :: Science/Research",
@@ -136,11 +136,13 @@ def symbolic_automaton_to_hoa(
136
136
  """Serialize a :class:`SymbolicAutomaton` to HOA v1 directly from its BDDs.
137
137
 
138
138
  Walks ``sym.transitions`` row-by-row. For each cached
139
- ``(guard_bdd, succ_bdd)`` pair, enumerates AP-cubes of ``guard_bdd``
140
- via ``BDD.pick_iter`` (over the BDD's support, no ``care_vars``) and
141
- successor-state cubes of ``succ_bdd`` the same way, and emits one
142
- HOA ``[label] dst`` edge per (guard cube x successor cube). No
143
- ``logic_asts.BoolExpr`` tree is constructed.
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|``.
144
146
 
145
147
  ``out`` semantics match :func:`automaton_to_hoa`: when ``None``, an
146
148
  internal ``StringIO`` accumulates the output and is returned;
@@ -164,6 +166,7 @@ def symbolic_automaton_to_hoa(
164
166
  acceptance condition is not :class:`~morphata.acceptance.Finite`.
165
167
  """
166
168
  from morphata.acceptance import Finite
169
+ from morphata.symbolic.bdd import bdd_to_boolexpr
167
170
 
168
171
  if sym.num_states > 0 and not sym.transitions:
169
172
  raise ValueError(
@@ -201,7 +204,15 @@ def symbolic_automaton_to_hoa(
201
204
  total_states = next_idx
202
205
 
203
206
  ap_order = sorted(sym.ap_registry, key=repr)
204
- bdd_var_to_ap_index: dict[str, int] = {sym.ap_registry[ap]: i for i, ap in enumerate(ap_order)}
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
+
205
216
  acc_name, acc_expr_text, num_sets, acc_state_sets = _acceptance_to_hoa(sym.acceptance)
206
217
  if true_sink is not None:
207
218
  # Finite acceptance: extend set 0 (the accepting set) with the
@@ -226,7 +237,8 @@ def symbolic_automaton_to_hoa(
226
237
  assert false_sink is not None
227
238
  buf.write(f"Start: {false_sink}\n")
228
239
  else:
229
- for start_line in _bdd_state_cubes(sym.initial, mgr=mgr):
240
+ initial_expr = ty.cast("logic.BoolExpr[int]", bdd_to_boolexpr(sym.initial))
241
+ for start_line in _initial_start_lines(initial_expr):
230
242
  buf.write(f"Start: {start_line}\n")
231
243
  ap_words = " ".join(f'"{ap}"' for ap in ap_order)
232
244
  buf.write(f"AP: {len(ap_order)}{(' ' + ap_words) if ap_order else ''}\n")
@@ -249,11 +261,12 @@ def symbolic_automaton_to_hoa(
249
261
  assert false_sink is not None
250
262
  dsts = [str(false_sink)]
251
263
  else:
252
- dsts = _bdd_state_cubes(succ_bdd, mgr=mgr)
253
- for cube in mgr.pick_iter(guard_bdd):
254
- label = _cube_to_hoa_label(cube, bdd_var_to_ap_index)
255
- for dst in dsts:
256
- buf.write(f"[{label}] {dst}\n")
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")
257
270
  for sink in (true_sink, false_sink):
258
271
  if sink is None:
259
272
  continue
@@ -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,8 @@ 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
+
47
49
  from typing_extensions import Writer
48
50
 
49
51
  from morphata.alphabet import BooleanAlphabet
@@ -245,6 +247,31 @@ class SymbolicAutomaton(Generic[AP]):
245
247
 
246
248
  return symbolic_automaton_to_hoa(self, name=name, properties=properties, out=out)
247
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
272
+
273
+ return from_bdd_json(path, alphabet=alphabet, manager=manager)
274
+
248
275
  def to_min_dfa(self) -> "SymbolicAutomaton[AP]":
249
276
  """Determinize and minimize ``self`` into a minimal DFA.
250
277
 
@@ -358,6 +385,83 @@ class SymbolicAutomaton(Generic[AP]):
358
385
  )
359
386
 
360
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
+
361
465
  # ---------------------------------------------------------------------------
362
466
  # Private helpers
363
467
  # ---------------------------------------------------------------------------
@@ -0,0 +1,346 @@
1
+ """Binary-ish BDD-level persistence for :class:`SymbolicAutomaton`.
2
+
3
+ Persists a :class:`SymbolicAutomaton` to two files via :mod:`dd`'s native
4
+ JSON dump:
5
+
6
+ - ``<path>``: the BDD nodes themselves, written by ``Manager.dump(...,
7
+ filetype='json')`` as a multi-root JSON file. Cross-root structural
8
+ sharing in the manager is preserved at the file-format level (each
9
+ unique BDD node appears once; roots reference node ids).
10
+ - ``<path>.meta.json``: a sidecar JSON file holding the surrounding
11
+ ``SymbolicAutomaton`` metadata that is not carried by the BDDs
12
+ themselves (state count, accepting states, acceptance condition, AP
13
+ registry, transition root-name layout).
14
+
15
+ Motivation
16
+ ----------
17
+ HOA is the right on-disk format for sharing automata with downstream
18
+ tooling, but for an internal cache it has two costs:
19
+
20
+ 1. The HOA body writes each row's label as fresh text, so the file size
21
+ grows with (rows x label bytes) rather than with the manager's
22
+ unique node count.
23
+ 2. Parsing HOA back into a :class:`SymbolicAutomaton` goes through a
24
+ lark parser that allocates per-token Python AST nodes; on
25
+ million-line HOA files this dominates load time and memory.
26
+
27
+ The BDD JSON format here avoids both: dd's loader is C-level (no lark),
28
+ and node deduplication keeps the unique-node table proportional to the
29
+ manager's reachable node count rather than the per-row text footprint.
30
+
31
+ The format is intended as a cache, not a portable artifact: it bakes in
32
+ the AP and state variable naming conventions used by morphata, so the
33
+ loader can reconstruct the manager into the same variable namespace.
34
+
35
+ Atomic write
36
+ ------------
37
+ Each file is written via a ``.partial`` sibling and then ``Path.replace``-d
38
+ into place. The two replaces are sequential, so a crash between them can
39
+ leave the BDD file in place without the sidecar; both files always land
40
+ *together* only modulo that window. Callers that need both-or-neither
41
+ semantics should write to a scratch directory and rename the directory.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import json
47
+ from pathlib import Path
48
+ from typing import TYPE_CHECKING, Any
49
+
50
+ from morphata.acceptance import (
51
+ AcceptanceCondition,
52
+ AccPair,
53
+ Buchi,
54
+ CoBuchi,
55
+ Finite,
56
+ GeneralizedBuchi,
57
+ GeneralizedCoBuchi,
58
+ Rabin,
59
+ Streett,
60
+ )
61
+ from morphata.symbolic.bdd import (
62
+ AP_VAR_FORMAT,
63
+ STATE_VAR_FORMAT,
64
+ Function,
65
+ Manager,
66
+ declare_state_vars,
67
+ make_manager,
68
+ )
69
+
70
+ if TYPE_CHECKING:
71
+ from morphata.alphabet import BooleanAlphabet
72
+ from morphata.symbolic.automaton import SymbolicAutomaton
73
+
74
+
75
+ SCHEMA_VERSION = "1"
76
+ """Sidecar schema version. Bump on any breaking change to the JSON layout."""
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Acceptance condition <-> JSON
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ def _acceptance_to_json(cond: AcceptanceCondition[int]) -> dict[str, Any]:
85
+ if isinstance(cond, Finite):
86
+ return {"kind": "Finite", "accepting": sorted(cond.accepting)}
87
+ if isinstance(cond, Buchi):
88
+ return {"kind": "Buchi", "accepting": sorted(cond.accepting)}
89
+ if isinstance(cond, CoBuchi):
90
+ return {"kind": "CoBuchi", "rejecting": sorted(cond.rejecting)}
91
+ if isinstance(cond, GeneralizedBuchi):
92
+ return {
93
+ "kind": "GeneralizedBuchi",
94
+ "accepting": [sorted(s) for s in cond.accepting],
95
+ }
96
+ if isinstance(cond, GeneralizedCoBuchi):
97
+ return {
98
+ "kind": "GeneralizedCoBuchi",
99
+ "rejecting": [sorted(s) for s in cond.rejecting],
100
+ }
101
+ if isinstance(cond, Rabin):
102
+ return {
103
+ "kind": "Rabin",
104
+ "pairs": [[sorted(p.rejecting), sorted(p.accepting)] for p in cond.pairs],
105
+ }
106
+ if isinstance(cond, Streett):
107
+ return {
108
+ "kind": "Streett",
109
+ "pairs": [[sorted(p.rejecting), sorted(p.accepting)] for p in cond.pairs],
110
+ }
111
+ raise NotImplementedError(
112
+ f"BDD JSON cache: cannot serialize acceptance condition of type "
113
+ f"{type(cond).__name__}; supported: Finite, Buchi, CoBuchi, "
114
+ "GeneralizedBuchi, GeneralizedCoBuchi, Rabin, Streett."
115
+ )
116
+
117
+
118
+ def _acceptance_from_json(data: dict[str, Any]) -> AcceptanceCondition[int]:
119
+ kind = data["kind"]
120
+ if kind == "Finite":
121
+ return Finite(frozenset(data["accepting"]))
122
+ if kind == "Buchi":
123
+ return Buchi(frozenset(data["accepting"]))
124
+ if kind == "CoBuchi":
125
+ return CoBuchi(frozenset(data["rejecting"]))
126
+ if kind == "GeneralizedBuchi":
127
+ return GeneralizedBuchi(tuple(frozenset(s) for s in data["accepting"]))
128
+ if kind == "GeneralizedCoBuchi":
129
+ return GeneralizedCoBuchi(tuple(frozenset(s) for s in data["rejecting"]))
130
+ if kind == "Rabin":
131
+ return Rabin(tuple(AccPair(frozenset(r), frozenset(a)) for r, a in data["pairs"]))
132
+ if kind == "Streett":
133
+ return Streett(tuple(AccPair(frozenset(r), frozenset(a)) for r, a in data["pairs"]))
134
+ raise ValueError(f"BDD JSON cache: unknown acceptance kind {kind!r}")
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Top-level dump / load
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def _meta_path_for(bdd_path: Path) -> Path:
143
+ return Path(str(bdd_path) + ".meta.json")
144
+
145
+
146
+ def to_bdd_json(sym: "SymbolicAutomaton[Any]", path: str | Path) -> None:
147
+ """Persist *sym* to ``path`` (BDD JSON) plus a ``<path>.meta.json`` sidecar.
148
+
149
+ Roots written to the BDD file:
150
+
151
+ - ``initial``: the initial-state BDD.
152
+ - ``g_{s}_{k}``, ``s_{s}_{k}``: the guard and successor BDDs of the
153
+ k-th cached transition out of state ``s``.
154
+
155
+ The sidecar JSON holds everything else needed to rehydrate the
156
+ automaton (state count, accepting states, acceptance condition, AP
157
+ registry mapping AP value -> manager var name, and the per-state
158
+ list of ``(guard_root_name, succ_root_name)`` pairs).
159
+
160
+ Raises
161
+ ------
162
+ ValueError
163
+ If ``sym.num_states > 0`` and the transition cache is empty.
164
+ NotImplementedError
165
+ If the AP registry contains a non-string AP value, or the
166
+ acceptance condition is not one of the named conditions handled
167
+ by :func:`_acceptance_to_json`.
168
+ """
169
+ if sym.num_states > 0 and not sym.transitions:
170
+ raise ValueError(
171
+ "to_bdd_json requires a populated transition cache; reconstruct SymbolicAutomaton with cache_transitions=True."
172
+ )
173
+
174
+ bdd_path = Path(path)
175
+ meta_path = _meta_path_for(bdd_path)
176
+ bdd_partial = Path(str(bdd_path) + ".partial")
177
+ meta_partial = Path(str(meta_path) + ".partial")
178
+
179
+ mgr = sym.manager
180
+ roots: dict[str, Function] = {"initial": sym.initial}
181
+ transitions_layout: dict[str, list[list[str]]] = {}
182
+ for s in range(sym.num_states):
183
+ rows_for_s: list[list[str]] = []
184
+ for k, (g, n) in enumerate(sym.transitions.get(s, ())):
185
+ g_name = f"g_{s}_{k}"
186
+ s_name = f"s_{s}_{k}"
187
+ roots[g_name] = g
188
+ roots[s_name] = n
189
+ rows_for_s.append([g_name, s_name])
190
+ transitions_layout[str(s)] = rows_for_s
191
+
192
+ # dd's JSON dump/load round-trip is buggy for terminal roots on the
193
+ # CUDD backend: ``_dump_bdd_info`` emits a numeric id for True/False
194
+ # roots while ``_dump_bdd`` short-circuits terminals as ``"T"`` /
195
+ # ``"F"`` and never writes a body for those ids, so ``manager.load``
196
+ # raises KeyError. Sidestep by filtering terminal roots out of the
197
+ # dict handed to dd.dump and recording their names in the sidecar.
198
+ true_root_names: list[str] = []
199
+ false_root_names: list[str] = []
200
+ bdd_roots: dict[str, Function] = {}
201
+ for name, node in roots.items():
202
+ if node == mgr.true:
203
+ true_root_names.append(name)
204
+ elif node == mgr.false:
205
+ false_root_names.append(name)
206
+ else:
207
+ bdd_roots[name] = node
208
+
209
+ ap_registry_json: dict[str, str] = {}
210
+ for ap, var in sym.ap_registry.items():
211
+ if not isinstance(ap, str):
212
+ raise NotImplementedError(
213
+ f"BDD JSON cache currently supports only str-valued APs; got AP value {ap!r} of type {type(ap).__name__}."
214
+ )
215
+ ap_registry_json[ap] = var
216
+
217
+ meta: dict[str, Any] = {
218
+ "schema_version": SCHEMA_VERSION,
219
+ "num_states": sym.num_states,
220
+ "accepting_states": sorted(sym.accepting_states),
221
+ "acceptance": _acceptance_to_json(sym.acceptance),
222
+ "ap_registry": ap_registry_json,
223
+ "state_var_format": STATE_VAR_FORMAT,
224
+ "ap_var_format": AP_VAR_FORMAT,
225
+ "initial_root_name": "initial",
226
+ "transitions": transitions_layout,
227
+ "true_root_names": sorted(true_root_names),
228
+ "false_root_names": sorted(false_root_names),
229
+ # dd's dump raises if roots={}, so flag the all-terminal case and
230
+ # skip the BDD file altogether; the sidecar carries everything.
231
+ "bdd_file_empty": not bdd_roots,
232
+ }
233
+
234
+ if bdd_roots:
235
+ mgr.dump(str(bdd_partial), roots=bdd_roots, filetype="json")
236
+ with open(meta_partial, "w") as f:
237
+ json.dump(meta, f, indent=2, sort_keys=True)
238
+
239
+ if bdd_roots:
240
+ bdd_partial.replace(bdd_path)
241
+ elif bdd_path.exists():
242
+ bdd_path.unlink()
243
+ meta_partial.replace(meta_path)
244
+
245
+
246
+ def from_bdd_json(
247
+ path: str | Path,
248
+ *,
249
+ alphabet: "BooleanAlphabet[Any, str]",
250
+ manager: Manager | None = None,
251
+ ) -> "SymbolicAutomaton[str]":
252
+ """Reload a :class:`SymbolicAutomaton` from :func:`to_bdd_json`'s output.
253
+
254
+ Declares state and AP variables in *manager* (a fresh one if not
255
+ given) matching the writer's namespace, loads the BDD JSON into the
256
+ manager via ``Manager.load``, and reassembles
257
+ :attr:`SymbolicAutomaton.transitions` from the sidecar layout.
258
+
259
+ AP values are returned as ``str`` (the only AP value type supported
260
+ by :func:`to_bdd_json`).
261
+
262
+ Raises
263
+ ------
264
+ ValueError
265
+ If the sidecar schema version is unknown, if its
266
+ ``state_var_format`` does not match morphata's, or if a root
267
+ named by the sidecar is missing from the BDD file.
268
+ """
269
+ from morphata.symbolic.automaton import SymbolicAutomaton
270
+
271
+ bdd_path = Path(path)
272
+ meta_path = _meta_path_for(bdd_path)
273
+
274
+ with open(meta_path) as f:
275
+ meta = json.load(f)
276
+
277
+ schema = meta.get("schema_version")
278
+ if schema != SCHEMA_VERSION:
279
+ raise ValueError(f"BDD JSON cache schema mismatch: file has {schema!r}, loader expected {SCHEMA_VERSION!r}.")
280
+ if meta.get("state_var_format") != STATE_VAR_FORMAT:
281
+ raise ValueError(
282
+ f"state_var_format mismatch: file uses {meta.get('state_var_format')!r}, loader expects {STATE_VAR_FORMAT!r}."
283
+ )
284
+
285
+ num_states = int(meta["num_states"])
286
+ ap_registry_meta: dict[str, str] = dict(meta["ap_registry"])
287
+
288
+ if manager is None:
289
+ manager = make_manager(reordering=True)
290
+ declare_state_vars(manager, num_states)
291
+ existing = set(manager.vars)
292
+ to_add = [name for name in ap_registry_meta.values() if name not in existing]
293
+ if to_add:
294
+ manager.declare(*to_add)
295
+
296
+ if meta.get("bdd_file_empty", False):
297
+ roots: dict[str, Function] = {}
298
+ else:
299
+ loaded = manager.load(str(bdd_path))
300
+ if not isinstance(loaded, dict):
301
+ raise ValueError(
302
+ f"BDD JSON load returned {type(loaded).__name__}; expected a "
303
+ "dict of named roots. The file may have been written by a "
304
+ "different tool."
305
+ )
306
+ roots = loaded
307
+
308
+ # Restore terminal roots that were filtered out at dump time.
309
+ for name in meta.get("true_root_names", []):
310
+ roots[name] = manager.true
311
+ for name in meta.get("false_root_names", []):
312
+ roots[name] = manager.false
313
+
314
+ initial_name = meta["initial_root_name"]
315
+ try:
316
+ initial = roots[initial_name]
317
+ except KeyError as exc:
318
+ raise ValueError(f"BDD JSON file is missing initial root {initial_name!r}") from exc
319
+
320
+ transitions: dict[int, list[tuple[Function, Function]]] = {}
321
+ for s_str, rows in meta["transitions"].items():
322
+ s = int(s_str)
323
+ pairs: list[tuple[Function, Function]] = []
324
+ for entry in rows:
325
+ g_name, n_name = entry
326
+ try:
327
+ pairs.append((roots[g_name], roots[n_name]))
328
+ except KeyError as exc:
329
+ raise ValueError(
330
+ f"BDD JSON file is missing root {exc.args[0]!r} referenced by sidecar state {s} row layout."
331
+ ) from exc
332
+ transitions[s] = pairs
333
+
334
+ accepting_states = frozenset(int(s) for s in meta["accepting_states"])
335
+ acceptance = _acceptance_from_json(meta["acceptance"])
336
+
337
+ return SymbolicAutomaton(
338
+ manager=manager,
339
+ initial=initial,
340
+ transitions=transitions,
341
+ ap_registry=ap_registry_meta,
342
+ accepting_states=accepting_states,
343
+ num_states=num_states,
344
+ acceptance=acceptance,
345
+ alphabet=alphabet,
346
+ )
@@ -220,6 +220,54 @@ class TestSymbolicSinkMaterialisation:
220
220
  assert "State: 2 {0}\n[t] 2\n" in hoa_text
221
221
 
222
222
 
223
+ class TestSymbolicCubeNotMinterm:
224
+ """Regression for the 2.0.5 bug where the streaming exporter emitted one
225
+ HOA edge per BDD minterm instead of per cube.
226
+
227
+ ``dd.cudd.BDD.pick_iter(u)`` defaults ``care_vars`` to ``support(u)`` and
228
+ then enumerates *minterms* (fully-assigned models) over that support, so
229
+ a guard with non-trivial support like ``a | b`` would produce three
230
+ edges (``a&b``, ``a&!b``, ``!a&b``) instead of the single cube ``(a | b)``.
231
+ """
232
+
233
+ def _build_disjunctive_guard_dfa(self) -> SymbolicAutomaton[str]:
234
+ # Two-state DFA over {a, b}. State 0 transitions to state 1 under
235
+ # the guard ``a | b`` (BDD support = {a, b}; pre-fix expands to 3
236
+ # minterms). Otherwise loops in state 0.
237
+ b = AutomatonBuilder[int, str]()
238
+ b.add_state(0, initial=True)
239
+ b.add_state(1, accepting=True)
240
+ b.add_transition(0, logic.Or((logic.Variable("a"), logic.Variable("b"))), 1)
241
+ b.add_transition(
242
+ 0,
243
+ logic.And((logic.Not(logic.Variable("a")), logic.Not(logic.Variable("b")))),
244
+ 0,
245
+ )
246
+ b.add_transition(1, logic.Variable("a"), 1)
247
+ b.add_transition(1, logic.Not(logic.Variable("a")), 1)
248
+ aut = b.build()
249
+ return SymbolicAutomaton.from_automaton(aut, alphabet=powerset_alphabet(aut.predicates))
250
+
251
+ def test_disjunctive_guard_emits_single_cube_edge(self) -> None:
252
+ sym = self._build_disjunctive_guard_dfa()
253
+ hoa_text = symbolic_automaton_to_hoa(sym)
254
+ assert hoa_text is not None
255
+ # Find the body block for state 0.
256
+ body = hoa_text.split("--BODY--\n", 1)[1]
257
+ state0_block = body.split("State: 0", 1)[1].split("State:", 1)[0]
258
+ edges_to_1 = [line for line in state0_block.splitlines() if line.endswith(" 1")]
259
+ # Post-fix: a single edge labelled with the disjunction goes to 1.
260
+ # Pre-fix (the bug): three minterm edges go to 1.
261
+ assert len(edges_to_1) == 1, f"expected one cube edge to state 1, got {len(edges_to_1)}: {edges_to_1!r}"
262
+
263
+ def test_writer_vs_string_byte_identical_on_disjunctive_guard(self) -> None:
264
+ sym = self._build_disjunctive_guard_dfa()
265
+ string_form = symbolic_automaton_to_hoa(sym)
266
+ buf = io.StringIO()
267
+ symbolic_automaton_to_hoa(sym, out=buf)
268
+ assert buf.getvalue() == string_form
269
+
270
+
223
271
  class TestSymbolicErrorPaths:
224
272
  def test_literal_successor_with_non_finite_acceptance_raises(self) -> None:
225
273
  from morphata.acceptance import Buchi