morphata 2.0.4__tar.gz → 2.0.5__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 (135) hide show
  1. {morphata-2.0.4 → morphata-2.0.5}/CHANGELOG.md +13 -0
  2. {morphata-2.0.4 → morphata-2.0.5}/PKG-INFO +2 -2
  3. {morphata-2.0.4 → morphata-2.0.5}/ci/make-release +3 -3
  4. {morphata-2.0.4 → morphata-2.0.5}/pixi.lock +1 -1
  5. {morphata-2.0.4 → morphata-2.0.5}/pixi.toml +1 -1
  6. {morphata-2.0.4 → morphata-2.0.5}/pyproject.toml +2 -2
  7. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/hoa/__init__.py +2 -1
  8. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/hoa/exporter.py +269 -9
  9. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/symbolic/automaton.py +13 -4
  10. morphata-2.0.5/tests/hoa/test_exporter_streaming.py +250 -0
  11. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_to_hoa.py +21 -0
  12. {morphata-2.0.4 → morphata-2.0.5}/uv.lock +2 -2
  13. {morphata-2.0.4 → morphata-2.0.5}/.basedpyright/baseline.json +0 -0
  14. {morphata-2.0.4 → morphata-2.0.5}/.claude/settings.local.json +0 -0
  15. {morphata-2.0.4 → morphata-2.0.5}/.dockerignore +0 -0
  16. {morphata-2.0.4 → morphata-2.0.5}/.editorconfig +0 -0
  17. {morphata-2.0.4 → morphata-2.0.5}/.envrc +0 -0
  18. {morphata-2.0.4 → morphata-2.0.5}/.envrc.recommended +0 -0
  19. {morphata-2.0.4 → morphata-2.0.5}/.fdignore +0 -0
  20. {morphata-2.0.4 → morphata-2.0.5}/.gitattributes +0 -0
  21. {morphata-2.0.4 → morphata-2.0.5}/.gitignore +0 -0
  22. {morphata-2.0.4 → morphata-2.0.5}/.mailmap +0 -0
  23. {morphata-2.0.4 → morphata-2.0.5}/.python-version +0 -0
  24. {morphata-2.0.4 → morphata-2.0.5}/.rstcheck.cfg +0 -0
  25. {morphata-2.0.4 → morphata-2.0.5}/AGENTS.md +0 -0
  26. {morphata-2.0.4 → morphata-2.0.5}/CLAUDE.md +0 -0
  27. {morphata-2.0.4 → morphata-2.0.5}/CONTRIBUTING.md +0 -0
  28. {morphata-2.0.4 → morphata-2.0.5}/LICENSE +0 -0
  29. {morphata-2.0.4 → morphata-2.0.5}/README.md +0 -0
  30. {morphata-2.0.4 → morphata-2.0.5}/bench/README.md +0 -0
  31. {morphata-2.0.4 → morphata-2.0.5}/bench/__init__.py +0 -0
  32. {morphata-2.0.4 → morphata-2.0.5}/bench/conftest.py +0 -0
  33. {morphata-2.0.4 → morphata-2.0.5}/bench/test_sere_nlm_perf.py +0 -0
  34. {morphata-2.0.4 → morphata-2.0.5}/cliff.toml +0 -0
  35. {morphata-2.0.4 → morphata-2.0.5}/docs/_static/navigation.html +0 -0
  36. {morphata-2.0.4 → morphata-2.0.5}/docs/api/modules.rst +0 -0
  37. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.acceptance.rst +0 -0
  38. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.alphabet.rst +0 -0
  39. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.automaton.rst +0 -0
  40. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.builder.rst +0 -0
  41. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.hoa.rst +0 -0
  42. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.logic.ltl.rst +0 -0
  43. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.logic.psl.rst +0 -0
  44. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.logic.sere.rst +0 -0
  45. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.logic.strel.rst +0 -0
  46. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.operators.matrix.rst +0 -0
  47. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.operators.polynomial.rst +0 -0
  48. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.operators.rst +0 -0
  49. {morphata-2.0.4 → morphata-2.0.5}/docs/api/morphata.symbolic.rst +0 -0
  50. {morphata-2.0.4 → morphata-2.0.5}/docs/concepts/acceptance.rst +0 -0
  51. {morphata-2.0.4 → morphata-2.0.5}/docs/concepts/hoa.rst +0 -0
  52. {morphata-2.0.4 → morphata-2.0.5}/docs/concepts/index.rst +0 -0
  53. {morphata-2.0.4 → morphata-2.0.5}/docs/concepts/operators.rst +0 -0
  54. {morphata-2.0.4 → morphata-2.0.5}/docs/concepts/spec.rst +0 -0
  55. {morphata-2.0.4 → morphata-2.0.5}/docs/concepts/weights.rst +0 -0
  56. {morphata-2.0.4 → morphata-2.0.5}/docs/conf.py +0 -0
  57. {morphata-2.0.4 → morphata-2.0.5}/docs/index.rst +0 -0
  58. {morphata-2.0.4 → morphata-2.0.5}/docs/quick-start.rst +0 -0
  59. {morphata-2.0.4 → morphata-2.0.5}/docs/symbolic-transforms.rst +0 -0
  60. {morphata-2.0.4 → morphata-2.0.5}/justfile +0 -0
  61. {morphata-2.0.4 → morphata-2.0.5}/mypy.ini +0 -0
  62. {morphata-2.0.4 → morphata-2.0.5}/project.utf-8.add +0 -0
  63. {morphata-2.0.4 → morphata-2.0.5}/project.utf-8.add.spl +0 -0
  64. {morphata-2.0.4 → morphata-2.0.5}/pyrefly.toml +0 -0
  65. {morphata-2.0.4 → morphata-2.0.5}/pyrightconfig.json +0 -0
  66. {morphata-2.0.4 → morphata-2.0.5}/pytest.toml +0 -0
  67. {morphata-2.0.4 → morphata-2.0.5}/ruff.toml +0 -0
  68. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/__init__.py +0 -0
  69. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/acceptance.py +0 -0
  70. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/alphabet.py +0 -0
  71. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/automaton.py +0 -0
  72. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/builder.py +0 -0
  73. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/hoa/__main__.py +0 -0
  74. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/hoa/acc_expr.py +0 -0
  75. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/hoa/hoa.lark +0 -0
  76. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/hoa/parser.py +0 -0
  77. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/logic/__init__.py +0 -0
  78. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/logic/_common.py +0 -0
  79. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/logic/ltl.py +0 -0
  80. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/logic/psl.py +0 -0
  81. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/logic/sere.py +0 -0
  82. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/logic/strel.py +0 -0
  83. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/operators/__init__.py +0 -0
  84. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/operators/_backend.py +0 -0
  85. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/operators/matrix.py +0 -0
  86. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/operators/polynomial.py +0 -0
  87. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/py.typed +0 -0
  88. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/symbolic/__init__.py +0 -0
  89. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/symbolic/bdd.py +0 -0
  90. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/symbolic/compose.py +0 -0
  91. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/symbolic/equivalence.py +0 -0
  92. {morphata-2.0.4 → morphata-2.0.5}/src/morphata/symbolic/transforms.py +0 -0
  93. {morphata-2.0.4 → morphata-2.0.5}/tests/conftest.py +0 -0
  94. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut1.hoa +0 -0
  95. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut11.hoa +0 -0
  96. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut2.hoa +0 -0
  97. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut3.2.hoa +0 -0
  98. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut3.hoa +0 -0
  99. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut4.hoa +0 -0
  100. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut5.hoa +0 -0
  101. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut6.hoa +0 -0
  102. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut7.hoa +0 -0
  103. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/aut8.hoa +0 -0
  104. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/finite_aut.hoa +0 -0
  105. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/gen_buchi_3.hoa +0 -0
  106. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/gen_cobuchi_2.hoa +0 -0
  107. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/rabin_2pair.hoa +0 -0
  108. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/streett_2pair.hoa +0 -0
  109. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/test_exporter_basic.py +0 -0
  110. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/test_exporter_roundtrip.py +0 -0
  111. {morphata-2.0.4 → morphata-2.0.5}/tests/hoa/test_parser.py +0 -0
  112. {morphata-2.0.4 → morphata-2.0.5}/tests/logic/__init__.py +0 -0
  113. {morphata-2.0.4 → morphata-2.0.5}/tests/logic/test_ltl.py +0 -0
  114. {morphata-2.0.4 → morphata-2.0.5}/tests/logic/test_psl.py +0 -0
  115. {morphata-2.0.4 → morphata-2.0.5}/tests/logic/test_sere.py +0 -0
  116. {morphata-2.0.4 → morphata-2.0.5}/tests/logic/test_sere_properties.py +0 -0
  117. {morphata-2.0.4 → morphata-2.0.5}/tests/logic/test_strel.py +0 -0
  118. {morphata-2.0.4 → morphata-2.0.5}/tests/operators/test_matrix_from_ir.py +0 -0
  119. {morphata-2.0.4 → morphata-2.0.5}/tests/operators/test_matrix_from_ltl.py +0 -0
  120. {morphata-2.0.4 → morphata-2.0.5}/tests/operators/test_polynomial_from_ir.py +0 -0
  121. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_bdd_helpers.py +0 -0
  122. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_compose.py +0 -0
  123. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_from_hoaf.py +0 -0
  124. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_ltlf2dfa_equivalence.py +0 -0
  125. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_step.py +0 -0
  126. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_symbolic_automaton.py +0 -0
  127. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_symbolic_from_ir.py +0 -0
  128. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_transforms.py +0 -0
  129. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_transforms_ir.py +0 -0
  130. {morphata-2.0.4 → morphata-2.0.5}/tests/symbolic/test_transforms_properties.py +0 -0
  131. {morphata-2.0.4 → morphata-2.0.5}/tests/test_alphabet.py +0 -0
  132. {morphata-2.0.4 → morphata-2.0.5}/tests/test_automaton.py +0 -0
  133. {morphata-2.0.4 → morphata-2.0.5}/tests/test_builder.py +0 -0
  134. {morphata-2.0.4 → morphata-2.0.5}/tests/test_from_parsed.py +0 -0
  135. {morphata-2.0.4 → morphata-2.0.5}/ty.toml +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.5 — 2026-06-01
4
+
5
+ ### Added
6
+
7
+ `SymbolicAutomaton` and `Automaton` now support a streaming writer
8
+ for HOA export via `morphata.hoa.symbolic_automaton_to_hoa`
9
+ and `morphata.hoa.automaton_to_hoa`, and is also present in `SymbolicAutomaton.to_hoa`.
10
+
11
+ This is mostly a performance thing:
12
+ providing a buffered writer to the exporter allows one to stream the HOA format into a
13
+ file, without having to manifest the entire string representation of the automaton,
14
+ which can be cumbersome.
15
+
3
16
  ## 2.0.4 — 2026-05-28
4
17
 
5
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphata
3
- Version: 2.0.4
3
+ Version: 2.0.5
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.5"
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,173 @@ 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, 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.
144
+
145
+ ``out`` semantics match :func:`automaton_to_hoa`: when ``None``, an
146
+ internal ``StringIO`` accumulates the output and is returned;
147
+ otherwise the output streams into ``out`` and the function returns
148
+ ``None``.
149
+
150
+ Literal-true / literal-false successor BDDs (``mgr.true`` /
151
+ ``mgr.false``) are handled by emitting synthetic sink states with
152
+ self-loops, mirroring :func:`_materialize_sinks` on the IR side. The
153
+ BDD manager is not mutated. As on the IR side, sink materialisation
154
+ is only implemented for :class:`~morphata.acceptance.Finite`
155
+ acceptance; omega-regular conditions with literal successors raise
156
+ ``NotImplementedError``.
157
+
158
+ Raises
159
+ ------
160
+ ValueError
161
+ If the cached transition table is empty but ``sym.num_states > 0``.
162
+ NotImplementedError
163
+ If literal-true / literal-false successors are present and the
164
+ acceptance condition is not :class:`~morphata.acceptance.Finite`.
165
+ """
166
+ from morphata.acceptance import Finite
167
+
168
+ if sym.num_states > 0 and not sym.transitions:
169
+ raise ValueError(
170
+ "symbolic_automaton_to_hoa requires a populated transition cache; "
171
+ "reconstruct SymbolicAutomaton with cache_transitions=True."
172
+ )
173
+
174
+ mgr = sym.manager
175
+
176
+ needs_true = sym.initial == mgr.true
177
+ needs_false = sym.initial == mgr.false
178
+ for s in range(sym.num_states):
179
+ for _, succ_bdd in sym.transitions.get(s, ()):
180
+ if succ_bdd == mgr.true:
181
+ needs_true = True
182
+ elif succ_bdd == mgr.false:
183
+ needs_false = True
184
+
185
+ if (needs_true or needs_false) and not isinstance(sym.acceptance, Finite):
186
+ raise NotImplementedError(
187
+ "HOA export: literal-true / literal-false successor BDDs require "
188
+ "sink-state materialisation, which is only implemented for "
189
+ f"Finite acceptance; got {type(sym.acceptance).__name__}."
190
+ )
191
+
192
+ next_idx = sym.num_states
193
+ true_sink: int | None = None
194
+ false_sink: int | None = None
195
+ if needs_true:
196
+ true_sink = next_idx
197
+ next_idx += 1
198
+ if needs_false:
199
+ false_sink = next_idx
200
+ next_idx += 1
201
+ total_states = next_idx
202
+
203
+ 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)}
205
+ acc_name, acc_expr_text, num_sets, acc_state_sets = _acceptance_to_hoa(sym.acceptance)
206
+ if true_sink is not None:
207
+ # Finite acceptance: extend set 0 (the accepting set) with the
208
+ # true-sink. ``_acceptance_to_hoa(Finite)`` always returns
209
+ # ``{0: ...}``.
210
+ augmented_sets: dict[int, AbstractSet[int]] = dict(acc_state_sets)
211
+ augmented_sets[0] = frozenset(augmented_sets.get(0, frozenset())) | {true_sink}
212
+ acc_state_sets = augmented_sets
213
+ states = list(range(sym.num_states))
214
+
215
+ owns_buffer = out is None
216
+ buf: Writer[str] = io.StringIO() if owns_buffer else out # type: ignore[assignment]
217
+
218
+ buf.write("HOA: v1\n")
219
+ if name is not None:
220
+ buf.write(f'name: "{name}"\n')
221
+ buf.write(f"States: {total_states}\n")
222
+ if sym.initial == mgr.true:
223
+ assert true_sink is not None
224
+ buf.write(f"Start: {true_sink}\n")
225
+ elif sym.initial == mgr.false:
226
+ assert false_sink is not None
227
+ buf.write(f"Start: {false_sink}\n")
228
+ else:
229
+ for start_line in _bdd_state_cubes(sym.initial, mgr=mgr):
230
+ buf.write(f"Start: {start_line}\n")
231
+ ap_words = " ".join(f'"{ap}"' for ap in ap_order)
232
+ buf.write(f"AP: {len(ap_order)}{(' ' + ap_words) if ap_order else ''}\n")
233
+ if acc_name is not None:
234
+ buf.write(f"acc-name: {acc_name}\n")
235
+ buf.write(f"Acceptance: {num_sets} {acc_expr_text}\n")
236
+ if properties:
237
+ buf.write("properties: " + " ".join(properties) + "\n")
238
+
239
+ buf.write("--BODY--\n")
240
+ for s in states:
241
+ sets_for_s = sorted(idx for idx, members in acc_state_sets.items() if s in members)
242
+ marker = (" {" + " ".join(str(i) for i in sets_for_s) + "}") if sets_for_s else ""
243
+ buf.write(f"State: {s}{marker}\n")
244
+ for guard_bdd, succ_bdd in sym.transitions.get(s, ()):
245
+ if succ_bdd == mgr.true:
246
+ assert true_sink is not None
247
+ dsts = [str(true_sink)]
248
+ elif succ_bdd == mgr.false:
249
+ assert false_sink is not None
250
+ dsts = [str(false_sink)]
251
+ 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")
257
+ for sink in (true_sink, false_sink):
258
+ if sink is None:
259
+ continue
260
+ sets_for_sink = sorted(idx for idx, members in acc_state_sets.items() if sink in members)
261
+ marker = (" {" + " ".join(str(i) for i in sets_for_sink) + "}") if sets_for_sink else ""
262
+ buf.write(f"State: {sink}{marker}\n")
263
+ buf.write(f"[t] {sink}\n")
264
+ buf.write("--END--\n")
265
+
266
+ if owns_buffer:
267
+ assert isinstance(buf, io.StringIO)
268
+ return buf.getvalue()
269
+ return None
73
270
 
74
271
 
75
272
  # ---------------------------------------------------------------------------
@@ -181,7 +378,7 @@ def _materialize_sinks(
181
378
 
182
379
 
183
380
  def _write_header(
184
- buf: io.StringIO,
381
+ buf: Writer[str],
185
382
  *,
186
383
  aut: Automaton[int, ty.Any, ty.Any],
187
384
  states: list[int],
@@ -255,7 +452,7 @@ def _flatten_or(expr: logic.BoolExpr[ty.Any]) -> list[logic.BoolExpr[ty.Any]]:
255
452
 
256
453
 
257
454
  def _write_body(
258
- buf: io.StringIO,
455
+ buf: Writer[str],
259
456
  *,
260
457
  aut: Automaton[int, ty.Any, ty.Any],
261
458
  states: list[int],
@@ -351,6 +548,69 @@ def _conj_to_dst(expr: logic.BoolExpr[int], *, context: str) -> str:
351
548
  raise ValueError(f"HOA {context} expression must be a disjunction of conjunctions of state variables; got {expr!r}.")
352
549
 
353
550
 
551
+ # ---------------------------------------------------------------------------
552
+ # BDD-direct helpers (symbolic_automaton_to_hoa)
553
+ # ---------------------------------------------------------------------------
554
+
555
+
556
+ def _cube_to_hoa_label(
557
+ cube: Mapping[str, bool],
558
+ bdd_var_to_ap_index: Mapping[str, int],
559
+ ) -> str:
560
+ """Render a single BDD cube over AP variables as an HOA label.
561
+
562
+ *cube* is a ``{bdd_var_name: bool}`` dict from ``dd``'s
563
+ ``BDD.pick_iter``; variables absent from the BDD's support are
564
+ don't-cares and do not appear in the output.
565
+ *bdd_var_to_ap_index* maps the BDD variable name back to the AP's
566
+ HOA index. Literals in the output are sorted by AP index for
567
+ deterministic output.
568
+ """
569
+ if not cube:
570
+ return "t"
571
+ literals: list[tuple[int, bool]] = []
572
+ for var_name, polarity in cube.items():
573
+ try:
574
+ idx = bdd_var_to_ap_index[var_name]
575
+ except KeyError as exc:
576
+ raise ValueError(f"BDD cube references variable {var_name!r} not in the AP registry.") from exc
577
+ literals.append((idx, polarity))
578
+ literals.sort(key=lambda iv: iv[0])
579
+ parts = [(str(idx) if polarity else f"!{idx}") for idx, polarity in literals]
580
+ if len(parts) == 1:
581
+ return parts[0]
582
+ return "(" + " & ".join(parts) + ")"
583
+
584
+
585
+ def _bdd_state_cubes(node: "Function", *, mgr: "Manager") -> list[str]:
586
+ """Enumerate satisfying cubes of *node* over state variables as HOA dst strings.
587
+
588
+ *node* is a ``dd.Function`` over the state variables ``q0..q{n-1}``;
589
+ *mgr* is the owning ``dd.BDD`` manager (``pick_iter`` lives on it,
590
+ not on Function objects). We enumerate minterms over the BDD's
591
+ *support* (i.e. no ``care_vars`` argument), so a point-successor
592
+ function naturally yields one cube.
593
+
594
+ For each cube:
595
+
596
+ - Positive state variables -> the cube's HOA destination
597
+ ``"d1&d2&..."`` (a single integer when there is one positive).
598
+ - Negative state variables in the cube are dropped from the output
599
+ (HOA `dst` expressions list only the chosen states).
600
+
601
+ A cube with zero positive state variables is treated as a bug and
602
+ raises ``ValueError``; the caller is responsible for rejecting
603
+ literal-false successor BDDs before reaching here.
604
+ """
605
+ cubes: list[str] = []
606
+ for cube in mgr.pick_iter(node):
607
+ positives = sorted(int(name[1:]) for name, val in cube.items() if val)
608
+ if not positives:
609
+ raise ValueError("Successor BDD cube has no positive state variable; cannot render an HOA destination.")
610
+ cubes.append("&".join(str(i) for i in positives))
611
+ return cubes
612
+
613
+
354
614
  # ---------------------------------------------------------------------------
355
615
  # Acceptance
356
616
  # ---------------------------------------------------------------------------
@@ -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 typing_extensions import Writer
48
+
47
49
  from morphata.alphabet import BooleanAlphabet
48
50
  from morphata.symbolic.bdd import (
49
51
  APRegistry,
@@ -230,11 +232,18 @@ class SymbolicAutomaton(Generic[AP]):
230
232
  *,
231
233
  name: str | None = None,
232
234
  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
235
+ out: "Writer[str] | None" = None,
236
+ ) -> str | None:
237
+ """Serialize ``self`` to HOA v1 via :func:`symbolic_automaton_to_hoa`.
238
+
239
+ Walks the cached transition BDDs directly -- no
240
+ ``logic_asts.BoolExpr`` intermediates. When ``out`` is given,
241
+ streams output into it and returns ``None``; otherwise returns
242
+ the full HOA string.
243
+ """
244
+ from morphata.hoa import symbolic_automaton_to_hoa
236
245
 
237
- return automaton_to_hoa(self.to_automaton(), name=name, properties=properties)
246
+ return symbolic_automaton_to_hoa(self, name=name, properties=properties, out=out)
238
247
 
239
248
  def to_min_dfa(self) -> "SymbolicAutomaton[AP]":
240
249
  """Determinize and minimize ``self`` into a minimal DFA.