morphata 2.0.7__tar.gz → 2.0.8__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 (143) hide show
  1. {morphata-2.0.7 → morphata-2.0.8}/CHANGELOG.md +39 -0
  2. {morphata-2.0.7 → morphata-2.0.8}/PKG-INFO +1 -1
  3. morphata-2.0.8/bench/test_equivalence_perf.py +57 -0
  4. morphata-2.0.8/bench/test_successors_for_alphabet_perf.py +124 -0
  5. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.symbolic.rst +7 -0
  6. {morphata-2.0.7 → morphata-2.0.8}/pyproject.toml +1 -1
  7. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/symbolic/__init__.py +2 -0
  8. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/symbolic/automaton.py +68 -12
  9. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/symbolic/compose.py +205 -106
  10. morphata-2.0.8/src/morphata/symbolic/equivalence.py +187 -0
  11. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/symbolic/transforms.py +22 -2
  12. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_alphabet_aware_transitions.py +28 -0
  13. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_compose.py +20 -18
  14. morphata-2.0.8/tests/symbolic/test_composition_map.py +264 -0
  15. morphata-2.0.8/tests/symbolic/test_equivalence_naive_property.py +130 -0
  16. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_ltlf2dfa_equivalence.py +39 -0
  17. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_symbolic_automaton.py +21 -0
  18. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_transforms.py +38 -0
  19. {morphata-2.0.7 → morphata-2.0.8}/uv.lock +1 -1
  20. morphata-2.0.7/src/morphata/symbolic/equivalence.py +0 -114
  21. {morphata-2.0.7 → morphata-2.0.8}/.basedpyright/baseline.json +0 -0
  22. {morphata-2.0.7 → morphata-2.0.8}/.claude/settings.local.json +0 -0
  23. {morphata-2.0.7 → morphata-2.0.8}/.dockerignore +0 -0
  24. {morphata-2.0.7 → morphata-2.0.8}/.editorconfig +0 -0
  25. {morphata-2.0.7 → morphata-2.0.8}/.envrc +0 -0
  26. {morphata-2.0.7 → morphata-2.0.8}/.envrc.recommended +0 -0
  27. {morphata-2.0.7 → morphata-2.0.8}/.fdignore +0 -0
  28. {morphata-2.0.7 → morphata-2.0.8}/.gitattributes +0 -0
  29. {morphata-2.0.7 → morphata-2.0.8}/.gitignore +0 -0
  30. {morphata-2.0.7 → morphata-2.0.8}/.mailmap +0 -0
  31. {morphata-2.0.7 → morphata-2.0.8}/.python-version +0 -0
  32. {morphata-2.0.7 → morphata-2.0.8}/.rstcheck.cfg +0 -0
  33. {morphata-2.0.7 → morphata-2.0.8}/AGENTS.md +0 -0
  34. {morphata-2.0.7 → morphata-2.0.8}/CLAUDE.md +0 -0
  35. {morphata-2.0.7 → morphata-2.0.8}/CONTRIBUTING.md +0 -0
  36. {morphata-2.0.7 → morphata-2.0.8}/LICENSE +0 -0
  37. {morphata-2.0.7 → morphata-2.0.8}/README.md +0 -0
  38. {morphata-2.0.7 → morphata-2.0.8}/bench/README.md +0 -0
  39. {morphata-2.0.7 → morphata-2.0.8}/bench/__init__.py +0 -0
  40. {morphata-2.0.7 → morphata-2.0.8}/bench/conftest.py +0 -0
  41. {morphata-2.0.7 → morphata-2.0.8}/bench/test_sere_nlm_perf.py +0 -0
  42. {morphata-2.0.7 → morphata-2.0.8}/ci/make-release +0 -0
  43. {morphata-2.0.7 → morphata-2.0.8}/cliff.toml +0 -0
  44. {morphata-2.0.7 → morphata-2.0.8}/docs/_static/navigation.html +0 -0
  45. {morphata-2.0.7 → morphata-2.0.8}/docs/api/modules.rst +0 -0
  46. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.acceptance.rst +0 -0
  47. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.alphabet.rst +0 -0
  48. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.automaton.rst +0 -0
  49. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.builder.rst +0 -0
  50. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.hoa.rst +0 -0
  51. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.logic.ltl.rst +0 -0
  52. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.logic.psl.rst +0 -0
  53. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.logic.sere.rst +0 -0
  54. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.logic.strel.rst +0 -0
  55. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.operators.matrix.rst +0 -0
  56. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.operators.polynomial.rst +0 -0
  57. {morphata-2.0.7 → morphata-2.0.8}/docs/api/morphata.operators.rst +0 -0
  58. {morphata-2.0.7 → morphata-2.0.8}/docs/concepts/acceptance.rst +0 -0
  59. {morphata-2.0.7 → morphata-2.0.8}/docs/concepts/hoa.rst +0 -0
  60. {morphata-2.0.7 → morphata-2.0.8}/docs/concepts/index.rst +0 -0
  61. {morphata-2.0.7 → morphata-2.0.8}/docs/concepts/operators.rst +0 -0
  62. {morphata-2.0.7 → morphata-2.0.8}/docs/concepts/spec.rst +0 -0
  63. {morphata-2.0.7 → morphata-2.0.8}/docs/concepts/weights.rst +0 -0
  64. {morphata-2.0.7 → morphata-2.0.8}/docs/conf.py +0 -0
  65. {morphata-2.0.7 → morphata-2.0.8}/docs/index.rst +0 -0
  66. {morphata-2.0.7 → morphata-2.0.8}/docs/quick-start.rst +0 -0
  67. {morphata-2.0.7 → morphata-2.0.8}/docs/symbolic-transforms.rst +0 -0
  68. {morphata-2.0.7 → morphata-2.0.8}/justfile +0 -0
  69. {morphata-2.0.7 → morphata-2.0.8}/mypy.ini +0 -0
  70. {morphata-2.0.7 → morphata-2.0.8}/pixi.lock +0 -0
  71. {morphata-2.0.7 → morphata-2.0.8}/pixi.toml +0 -0
  72. {morphata-2.0.7 → morphata-2.0.8}/project.utf-8.add +0 -0
  73. {morphata-2.0.7 → morphata-2.0.8}/project.utf-8.add.spl +0 -0
  74. {morphata-2.0.7 → morphata-2.0.8}/pyrefly.toml +0 -0
  75. {morphata-2.0.7 → morphata-2.0.8}/pyrightconfig.json +0 -0
  76. {morphata-2.0.7 → morphata-2.0.8}/pytest.toml +0 -0
  77. {morphata-2.0.7 → morphata-2.0.8}/ruff.toml +0 -0
  78. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/__init__.py +0 -0
  79. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/acceptance.py +0 -0
  80. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/alphabet.py +0 -0
  81. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/automaton.py +0 -0
  82. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/builder.py +0 -0
  83. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/hoa/__init__.py +0 -0
  84. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/hoa/__main__.py +0 -0
  85. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/hoa/acc_expr.py +0 -0
  86. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/hoa/exporter.py +0 -0
  87. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/hoa/hoa.lark +0 -0
  88. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/hoa/parser.py +0 -0
  89. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/logic/__init__.py +0 -0
  90. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/logic/_common.py +0 -0
  91. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/logic/ltl.py +0 -0
  92. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/logic/psl.py +0 -0
  93. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/logic/sere.py +0 -0
  94. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/logic/strel.py +0 -0
  95. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/operators/__init__.py +0 -0
  96. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/operators/_backend.py +0 -0
  97. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/operators/matrix.py +0 -0
  98. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/operators/polynomial.py +0 -0
  99. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/py.typed +0 -0
  100. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/symbolic/bdd.py +0 -0
  101. {morphata-2.0.7 → morphata-2.0.8}/src/morphata/symbolic/persistence.py +0 -0
  102. {morphata-2.0.7 → morphata-2.0.8}/tests/conftest.py +0 -0
  103. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut1.hoa +0 -0
  104. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut11.hoa +0 -0
  105. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut2.hoa +0 -0
  106. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut3.2.hoa +0 -0
  107. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut3.hoa +0 -0
  108. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut4.hoa +0 -0
  109. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut5.hoa +0 -0
  110. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut6.hoa +0 -0
  111. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut7.hoa +0 -0
  112. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/aut8.hoa +0 -0
  113. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/finite_aut.hoa +0 -0
  114. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/gen_buchi_3.hoa +0 -0
  115. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/gen_cobuchi_2.hoa +0 -0
  116. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/rabin_2pair.hoa +0 -0
  117. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/streett_2pair.hoa +0 -0
  118. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/test_exporter_basic.py +0 -0
  119. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/test_exporter_roundtrip.py +0 -0
  120. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/test_exporter_streaming.py +0 -0
  121. {morphata-2.0.7 → morphata-2.0.8}/tests/hoa/test_parser.py +0 -0
  122. {morphata-2.0.7 → morphata-2.0.8}/tests/logic/__init__.py +0 -0
  123. {morphata-2.0.7 → morphata-2.0.8}/tests/logic/test_ltl.py +0 -0
  124. {morphata-2.0.7 → morphata-2.0.8}/tests/logic/test_psl.py +0 -0
  125. {morphata-2.0.7 → morphata-2.0.8}/tests/logic/test_sere.py +0 -0
  126. {morphata-2.0.7 → morphata-2.0.8}/tests/logic/test_sere_properties.py +0 -0
  127. {morphata-2.0.7 → morphata-2.0.8}/tests/logic/test_strel.py +0 -0
  128. {morphata-2.0.7 → morphata-2.0.8}/tests/operators/test_matrix_from_ir.py +0 -0
  129. {morphata-2.0.7 → morphata-2.0.8}/tests/operators/test_matrix_from_ltl.py +0 -0
  130. {morphata-2.0.7 → morphata-2.0.8}/tests/operators/test_polynomial_from_ir.py +0 -0
  131. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_bdd_helpers.py +0 -0
  132. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_from_hoaf.py +0 -0
  133. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_persistence.py +0 -0
  134. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_step.py +0 -0
  135. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_symbolic_from_ir.py +0 -0
  136. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_to_hoa.py +0 -0
  137. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_transforms_ir.py +0 -0
  138. {morphata-2.0.7 → morphata-2.0.8}/tests/symbolic/test_transforms_properties.py +0 -0
  139. {morphata-2.0.7 → morphata-2.0.8}/tests/test_alphabet.py +0 -0
  140. {morphata-2.0.7 → morphata-2.0.8}/tests/test_automaton.py +0 -0
  141. {morphata-2.0.7 → morphata-2.0.8}/tests/test_builder.py +0 -0
  142. {morphata-2.0.7 → morphata-2.0.8}/tests/test_from_parsed.py +0 -0
  143. {morphata-2.0.7 → morphata-2.0.8}/ty.toml +0 -0
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.8 — 2026-06-03
4
+
5
+ ### Added
6
+
7
+ - `compose_min_dfa` and `min_dfa_of_conjunction` are variadic now
8
+ and always return a `CompositionMap`: the per-factor automata,
9
+ the representative-to-tuple map,
10
+ and the full surjection from reachable per-factor tuples to composed states.
11
+ I initially wanted the section-only view,
12
+ but minimization can merge classes such
13
+ that a representative's per-factor successor lands on a non-representative tuple,
14
+ so the full surjection has to be there.
15
+ - `iter_composed_transitions` enumerates `(state, symbol, successor)`
16
+ of a composed DFA without building the product BDD, walking factor
17
+ deltas directly and hitting `tuple_to_class` once per
18
+ `(state, symbol)`.
19
+
20
+ ### Performance
21
+
22
+ - `successors_for_alphabet` drops the `|Sigma| * |rows|` cofactor sweep
23
+ for a per-row support-projected symbol partition with a constant-guard fast path.
24
+ Fixes the small-row regression from 2.0.6 while keeping the wide-state win.
25
+ Same treatment for `language_equivalent` and `canonical_form`,
26
+ so sparse guards over a big AP universe stop iterating the full powerset.
27
+ - Bucketing rows by guard support amortizes the partition
28
+ and per-class point dicts across same-support rows.
29
+ Conjunction-fold DFAs get 1.3-1.6x; `F a & F b & F c & F d` q1 went ~343us -> ~175us.
30
+ - `isomorphic` routes through a compact support-reduced fingerprint
31
+ and skips the broadcast expansion. `|AP|=19` sparse case:
32
+ ~333ms -> ~83ms.
33
+
34
+ ### Changed
35
+
36
+ - `SymbolicAutomaton` carries a derived `ap_inverse: Mapping[str, AP]`
37
+ populated in `__post_init__`; `to_automaton` and
38
+ `compose._merge_managers` route through it.
39
+ - New symbolic benches are pytest-benchmark cases, so timings get
40
+ recorded rather than gated on a hard-coded threshold.
41
+
3
42
  ## 2.0.7 — 2026-06-02
4
43
 
5
44
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphata
3
- Version: 2.0.7
3
+ Version: 2.0.8
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
@@ -0,0 +1,57 @@
1
+ """Micro-benchmarks for the rewritten equivalence functions.
2
+
3
+ The case: an LTLf min-DFA whose AP universe is large (|aps| ~ 19) but
4
+ whose guards mention only 4 APs. Pre-rewrite this regime explodes;
5
+ post-rewrite the per-pair (language_equivalent) and per-automaton
6
+ (isomorphic) support reductions keep work bounded.
7
+
8
+ pytest-benchmark records the timing distribution; no hard-coded
9
+ thresholds, the dataset is the regression signal.
10
+
11
+ Run with: ``pytest bench/test_equivalence_perf.py -v``
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logic_asts.ltl as ltl
17
+ from pytest_benchmark.fixture import BenchmarkFixture as Benchmark
18
+
19
+ from morphata.alphabet import powerset_alphabet
20
+ from morphata.automaton import Automaton
21
+ from morphata.logic.ltl import Input, ltl_to_automaton
22
+ from morphata.symbolic import isomorphic
23
+ from morphata.symbolic.automaton import SymbolicAutomaton
24
+ from morphata.symbolic.equivalence import language_equivalent
25
+
26
+
27
+ def _sparse_big_alphabet_dfa() -> SymbolicAutomaton[str]:
28
+ aps = frozenset({f"p{i}" for i in range(15)} | {"a", "b", "c", "d"})
29
+ formula = ltl.And(
30
+ (
31
+ ltl.Eventually(ltl.Variable("a")),
32
+ ltl.Eventually(ltl.Variable("b")),
33
+ ltl.Eventually(ltl.Variable("c")),
34
+ ltl.Eventually(ltl.Variable("d")),
35
+ )
36
+ )
37
+ alphabet = powerset_alphabet(aps)
38
+ aut: Automaton[int, Input[str], str] = ltl_to_automaton(formula, finite=True)
39
+ return SymbolicAutomaton.from_automaton(aut, alphabet=alphabet).to_min_dfa()
40
+
41
+
42
+ def test_language_equivalent_sparse_big_alphabet(benchmark: Benchmark) -> None:
43
+ dfa = _sparse_big_alphabet_dfa()
44
+
45
+ def _run() -> bool:
46
+ return language_equivalent(dfa, dfa)
47
+
48
+ assert benchmark(_run)
49
+
50
+
51
+ def test_isomorphic_sparse_big_alphabet(benchmark: Benchmark) -> None:
52
+ dfa = _sparse_big_alphabet_dfa()
53
+
54
+ def _run() -> bool:
55
+ return isomorphic(dfa, dfa)
56
+
57
+ assert benchmark(_run)
@@ -0,0 +1,124 @@
1
+ """Micro-benchmarks for :func:`morphata.symbolic.successors_for_alphabet`.
2
+
3
+ Two cases:
4
+
5
+ - Wide-cofactor: source = initial state of the min-DFA for
6
+ ``F a & F b & F c & F d``; many outgoing rows, ~1-4 APs in support
7
+ per row. Exercises the support-projection win.
8
+ - Narrow-cofactor: source = a deep-BFS state of the same DFA after
9
+ most eventualities discharged; few rows, narrow support. Exercises
10
+ non-regression vs. the pre-rewrite ``T_src`` shape.
11
+
12
+ Run with: ``pytest bench/test_successors_for_alphabet_perf.py -v``
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+
19
+ import logic_asts.ltl as ltl
20
+ from pytest_benchmark.fixture import BenchmarkFixture as Benchmark
21
+
22
+ from morphata.alphabet import BooleanAlphabet, finite_alphabet
23
+ from morphata.symbolic import SymbolicAutomaton, successors_for_alphabet
24
+
25
+
26
+ def _build_min_dfa() -> SymbolicAutomaton[str]:
27
+ formula = ltl.And(
28
+ (
29
+ ltl.Eventually(ltl.Variable("a")),
30
+ ltl.Eventually(ltl.Variable("b")),
31
+ ltl.Eventually(ltl.Variable("c")),
32
+ ltl.Eventually(ltl.Variable("d")),
33
+ )
34
+ )
35
+ alphabet = finite_alphabet(frozenset({"a", "b", "c", "d"}))
36
+ sym = SymbolicAutomaton.from_ltl(formula, alphabet=alphabet, finite=True)
37
+ return sym.to_min_dfa()
38
+
39
+
40
+ def _wide_alphabet_with_overlap() -> BooleanAlphabet[str, str]:
41
+ """26-symbol alphabet over AP universe {a, b, c, d}.
42
+
43
+ sym_0..sym_3 carry the AP truths for a/b/c/d (one each); sym_4..sym_25
44
+ carry no AP truth. Forces ``successors_for_alphabet`` to iterate over
45
+ a moderate-size symbol list with non-trivial classes per row.
46
+ """
47
+ aps = frozenset({"a", "b", "c", "d"})
48
+ labels: dict[str, frozenset[str]] = {f"sym_{i}": frozenset() for i in range(26)}
49
+ labels["sym_0"] = frozenset({"a"})
50
+ labels["sym_1"] = frozenset({"b"})
51
+ labels["sym_2"] = frozenset({"c"})
52
+ labels["sym_3"] = frozenset({"d"})
53
+ return BooleanAlphabet(predicates=aps, labeling=lambda s: labels[s])
54
+
55
+
56
+ def _initial_index(sym: SymbolicAutomaton[str]) -> int:
57
+ for k in range(sym.num_states):
58
+ if sym.initial == sym.manager.var(f"q{k}"):
59
+ return k
60
+ raise AssertionError("min-DFA initial must be a single state variable")
61
+
62
+
63
+ def test_wide_cofactor_state_runs_quickly() -> None:
64
+ """Initial state of F a & F b & F c & F d: many outgoing rows, wide support."""
65
+ sym = _build_min_dfa()
66
+ alphabet = _wide_alphabet_with_overlap()
67
+ symbols = [f"sym_{i}" for i in range(26)]
68
+ initial = _initial_index(sym)
69
+
70
+ t0 = time.perf_counter()
71
+ pairs = list(successors_for_alphabet(sym, initial, alphabet, symbols))
72
+ elapsed = time.perf_counter() - t0
73
+
74
+ assert pairs, "expected at least one successor for the initial state"
75
+ # Generous bound; rewrite runs in well under 1s on this case.
76
+ assert elapsed < 1.0, f"successors_for_alphabet on initial took {elapsed:.3f}s"
77
+
78
+
79
+ def test_narrow_cofactor_state_runs_quickly() -> None:
80
+ """A deep-BFS state with few rows must not regress vs. the old T_src path."""
81
+ sym = _build_min_dfa()
82
+ alphabet = _wide_alphabet_with_overlap()
83
+ symbols = [f"sym_{i}" for i in range(26)]
84
+
85
+ # Walk a few steps from the initial state to discharge a/b/c.
86
+ cur = sym.initial
87
+ for s in ("sym_0", "sym_1", "sym_2"):
88
+ cur = sym.step(cur, s)
89
+ deep_index = -1
90
+ for k in range(sym.num_states):
91
+ if cur == sym.manager.var(f"q{k}"):
92
+ deep_index = k
93
+ break
94
+ assert deep_index != -1, "expected to land on a single state after three steps"
95
+
96
+ t0 = time.perf_counter()
97
+ pairs = list(successors_for_alphabet(sym, deep_index, alphabet, symbols))
98
+ elapsed = time.perf_counter() - t0
99
+
100
+ assert pairs, "expected at least one successor"
101
+ assert elapsed < 0.5, f"deep-state enumeration took {elapsed:.3f}s"
102
+
103
+
104
+ def test_q1_post_discharge_state_per_call_cost(benchmark: Benchmark) -> None:
105
+ """Per-call cost of successors_for_alphabet at the 16-row / sup-4 state.
106
+
107
+ q1 has 16 outgoing rows that all share support {a, b, c, d}. With
108
+ per-bucket amortization the symbol partition and per-class point
109
+ dicts are built once for the bucket and shared across the 16
110
+ rows; the per-row cost reduces to |classes| cofactor calls.
111
+ pytest-benchmark records the timing distribution -- no hard-coded
112
+ threshold here, the dataset is the regression signal.
113
+ """
114
+ sym = _build_min_dfa()
115
+ alphabet = _wide_alphabet_with_overlap()
116
+ symbols = [f"sym_{i}" for i in range(26)]
117
+
118
+ q1 = next(k for k in range(sym.num_states) if len(sym.transitions[k]) == 16)
119
+
120
+ def _run() -> int:
121
+ return sum(1 for _ in successors_for_alphabet(sym, q1, alphabet, symbols))
122
+
123
+ n_yielded = benchmark(_run)
124
+ assert n_yielded > 0
@@ -33,6 +33,13 @@ Structural Transforms
33
33
  :members:
34
34
  :show-inheritance:
35
35
 
36
+ Composition
37
+ -----------
38
+
39
+ .. automodule:: morphata.symbolic.compose
40
+ :members:
41
+ :show-inheritance:
42
+
36
43
  Language Equivalence
37
44
  --------------------
38
45
 
@@ -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.7"
5
+ version = "2.0.8"
6
6
  requires-python = ">=3.12"
7
7
  classifiers = [
8
8
  "Intended Audience :: Science/Research",
@@ -38,7 +38,9 @@ from morphata.symbolic.bdd import guard_to_bdd as guard_to_bdd
38
38
  from morphata.symbolic.bdd import make_ap_registry as make_ap_registry
39
39
  from morphata.symbolic.bdd import make_manager as make_manager
40
40
  from morphata.symbolic.bdd import state_var_name as state_var_name
41
+ from morphata.symbolic.compose import CompositionMap as CompositionMap
41
42
  from morphata.symbolic.compose import compose_min_dfa as compose_min_dfa
43
+ from morphata.symbolic.compose import iter_composed_transitions as iter_composed_transitions
42
44
  from morphata.symbolic.compose import min_dfa_of_conjunction as min_dfa_of_conjunction
43
45
  from morphata.symbolic.equivalence import canonical_form as canonical_form
44
46
  from morphata.symbolic.equivalence import isomorphic as isomorphic
@@ -32,6 +32,7 @@ see :mod:`morphata.symbolic.transforms`.
32
32
  from __future__ import annotations
33
33
 
34
34
  import typing
35
+ from collections import defaultdict
35
36
  from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping, Sequence
36
37
  from collections.abc import Set as AbstractSet
37
38
  from dataclasses import dataclass, field
@@ -88,6 +89,10 @@ class SymbolicAutomaton(Generic[AP]):
88
89
  variables. Both live in :attr:`manager`.
89
90
  ap_registry : APRegistry[AP]
90
91
  Maps each AP value to its BDD variable name in :attr:`manager`.
92
+ ap_inverse : Mapping[str, AP]
93
+ Derived inverse of :attr:`ap_registry`. Maps each manager
94
+ variable name back to its AP. Excludes state variables.
95
+ Populated in ``__post_init__``; not serialised.
91
96
  accepting_states : frozenset[int]
92
97
  Indices of accepting states. Populated when the acceptance condition
93
98
  is :class:`~morphata.acceptance.Finite`; empty otherwise.
@@ -111,6 +116,12 @@ class SymbolicAutomaton(Generic[AP]):
111
116
  num_states: int
112
117
  acceptance: AcceptanceCondition[int] = field()
113
118
  alphabet: "BooleanAlphabet[Any, AP]" = field(kw_only=True)
119
+ ap_inverse: Mapping[str, AP] = field(init=False)
120
+
121
+ def __post_init__(self) -> None:
122
+ # Derived inverse of ``ap_registry``. Manager-var-name -> AP.
123
+ # Excludes state vars (they are not in ``ap_registry``).
124
+ object.__setattr__(self, "ap_inverse", {var: ap for ap, var in self.ap_registry.items()})
114
125
 
115
126
  # ------------------------------------------------------------------
116
127
  # Symbolic primitives
@@ -193,7 +204,7 @@ class SymbolicAutomaton(Generic[AP]):
193
204
  "reconstruct SymbolicAutomaton with cache_transitions=True."
194
205
  )
195
206
 
196
- ap_decoder_map: dict[str, AP] = {var: ap for ap, var in self.ap_registry.items()}
207
+ ap_decoder_map: Mapping[str, AP] = self.ap_inverse
197
208
 
198
209
  def _ap_decode(name: str) -> AP:
199
210
  try:
@@ -449,17 +460,62 @@ def successors_for_alphabet(
449
460
  mgr = sym_aut.manager
450
461
  rows = sym_aut.transitions[source]
451
462
  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
+ ap_inverse = sym_aut.ap_inverse
464
+
465
+ # Materialise the symbol list and precompute AP-truth sets once.
466
+ symbols_list = list(symbols)
467
+ n = len(symbols_list)
468
+ truths_list: list[AbstractSet[AP]] = [alphabet.labeling(s) for s in symbols_list]
469
+ accumulators: list[Function] = [mgr.false] * n
470
+
471
+ # Bucket rows by their support shape. Rows in the same bucket
472
+ # share the partition over input symbols and the per-class
473
+ # ``point`` dict; only the cofactor target ``guard_bdd`` varies.
474
+ buckets: dict[frozenset[str], list[tuple[Function, Function]]] = defaultdict(list)
475
+ for guard_bdd, succ_bdd in rows:
476
+ sup_key = frozenset(guard_bdd.support)
477
+ buckets[sup_key].append((guard_bdd, succ_bdd))
478
+
479
+ for sup, group_rows in buckets.items():
480
+ if not sup:
481
+ # Constant-guard bucket: no cofactor needed.
482
+ for guard_bdd, succ_bdd in group_rows:
483
+ if guard_bdd == mgr.true:
484
+ for i in range(n):
485
+ accumulators[i] = mgr.apply("or", accumulators[i], succ_bdd)
486
+ # ``mgr.false`` rows contribute nothing.
487
+ continue
488
+
489
+ # Translate manager-var-names in ``sup`` back to AP labels.
490
+ sup_aps: list[AP] = [ap_inverse[name] for name in sup]
491
+
492
+ # Partition symbol indices by their projection onto ``sup_aps``.
493
+ classes: dict[frozenset[AP], list[int]] = defaultdict(list)
494
+ for i, truths in enumerate(truths_list):
495
+ proj = frozenset(ap for ap in sup_aps if ap in truths)
496
+ classes[proj].append(i)
497
+
498
+ # Per-class ``point`` dicts are determined by ``sup_aps`` and
499
+ # the projection; hoist out of the row loop. Materialise the
500
+ # parallel ``(point, indices)`` list once so the per-row inner
501
+ # loop is a tight zip with no dict lookups.
502
+ class_points_indices: list[tuple[dict[str, Function], list[int]]] = [
503
+ (
504
+ {ap_registry[ap]: (mgr.true if ap in proj else mgr.false) for ap in sup_aps},
505
+ indices,
506
+ )
507
+ for proj, indices in classes.items()
508
+ ]
509
+
510
+ for guard_bdd, succ_bdd in group_rows:
511
+ for point, indices in class_points_indices:
512
+ if mgr.let(point, guard_bdd) == mgr.true:
513
+ for i in indices:
514
+ accumulators[i] = mgr.apply("or", accumulators[i], succ_bdd)
515
+
516
+ for i in range(n):
517
+ if accumulators[i] != mgr.false:
518
+ yield symbols_list[i], accumulators[i]
463
519
 
464
520
 
465
521
  # ---------------------------------------------------------------------------