unicode-logic-kit 0.31.0__py3-none-any.whl

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 (237) hide show
  1. unicode_logic_kit/__init__.py +385 -0
  2. unicode_logic_kit/__main__.py +520 -0
  3. unicode_logic_kit/_deadline.py +219 -0
  4. unicode_logic_kit/ace/__init__.py +126 -0
  5. unicode_logic_kit/ace/_align.py +135 -0
  6. unicode_logic_kit/ace/chem_lexicon.py +128 -0
  7. unicode_logic_kit/ace/drs_reader.py +570 -0
  8. unicode_logic_kit/ace/mapping.py +666 -0
  9. unicode_logic_kit/ace/reverse_modal.py +138 -0
  10. unicode_logic_kit/ace/runner.py +551 -0
  11. unicode_logic_kit/ace/translate.py +452 -0
  12. unicode_logic_kit/ace/verbalize.py +1070 -0
  13. unicode_logic_kit/api.py +1284 -0
  14. unicode_logic_kit/atp/__init__.py +177 -0
  15. unicode_logic_kit/atp/_ascii_names.py +113 -0
  16. unicode_logic_kit/atp/_html.py +72 -0
  17. unicode_logic_kit/atp/_substructural_input.py +228 -0
  18. unicode_logic_kit/atp/_tff_problem.py +715 -0
  19. unicode_logic_kit/atp/_tptp_problem.py +1111 -0
  20. unicode_logic_kit/atp/_writer_support.py +289 -0
  21. unicode_logic_kit/atp/clingo_backend.py +1180 -0
  22. unicode_logic_kit/atp/cvc5_backend.py +1385 -0
  23. unicode_logic_kit/atp/eprover_backend.py +732 -0
  24. unicode_logic_kit/atp/finite_domain.py +1055 -0
  25. unicode_logic_kit/atp/fitch.py +1547 -0
  26. unicode_logic_kit/atp/fitch_search.py +551 -0
  27. unicode_logic_kit/atp/hets_backend.py +339 -0
  28. unicode_logic_kit/atp/hybrid_down.py +120 -0
  29. unicode_logic_kit/atp/incremental.py +250 -0
  30. unicode_logic_kit/atp/kripke_enum.py +741 -0
  31. unicode_logic_kit/atp/lambek.py +436 -0
  32. unicode_logic_kit/atp/leo3_backend.py +332 -0
  33. unicode_logic_kit/atp/linear.py +738 -0
  34. unicode_logic_kit/atp/lj.py +705 -0
  35. unicode_logic_kit/atp/logic_backends.py +566 -0
  36. unicode_logic_kit/atp/ltl_tableau.py +1084 -0
  37. unicode_logic_kit/atp/minizinc_backend.py +1402 -0
  38. unicode_logic_kit/atp/modal_tableau.py +1382 -0
  39. unicode_logic_kit/atp/nanocop_backend.py +410 -0
  40. unicode_logic_kit/atp/portfolio.py +489 -0
  41. unicode_logic_kit/atp/protocol.py +1803 -0
  42. unicode_logic_kit/atp/prover9_entailment.py +1153 -0
  43. unicode_logic_kit/atp/resolution.py +1376 -0
  44. unicode_logic_kit/atp/resolution_check.py +1114 -0
  45. unicode_logic_kit/atp/sequent.py +1050 -0
  46. unicode_logic_kit/atp/tableau.py +921 -0
  47. unicode_logic_kit/atp/tableau_check.py +543 -0
  48. unicode_logic_kit/atp/tptp_ncl.py +811 -0
  49. unicode_logic_kit/atp/tptp_tff.py +1546 -0
  50. unicode_logic_kit/atp/tstp.py +1333 -0
  51. unicode_logic_kit/atp/tstp_check.py +1096 -0
  52. unicode_logic_kit/atp/twee_backend.py +236 -0
  53. unicode_logic_kit/atp/twee_check.py +711 -0
  54. unicode_logic_kit/atp/twee_entailment.py +953 -0
  55. unicode_logic_kit/atp/vampire_entailment.py +540 -0
  56. unicode_logic_kit/atp/z3_arith.py +470 -0
  57. unicode_logic_kit/atp/z3_equivalence.py +36 -0
  58. unicode_logic_kit/atp/z3_fuzzy.py +362 -0
  59. unicode_logic_kit/atp/z3_input.py +500 -0
  60. unicode_logic_kit/atp/z3_models.py +208 -0
  61. unicode_logic_kit/chem/__init__.py +88 -0
  62. unicode_logic_kit/chem/_naming.py +284 -0
  63. unicode_logic_kit/chem/cache.py +185 -0
  64. unicode_logic_kit/chem/interop.py +244 -0
  65. unicode_logic_kit/chem/mol.py +525 -0
  66. unicode_logic_kit/chem/signature.py +112 -0
  67. unicode_logic_kit/comorphism.py +497 -0
  68. unicode_logic_kit/dl/__init__.py +384 -0
  69. unicode_logic_kit/dl/classification.py +227 -0
  70. unicode_logic_kit/dl/concepts.py +632 -0
  71. unicode_logic_kit/dl/datatypes.py +818 -0
  72. unicode_logic_kit/dl/owl_functional.py +2433 -0
  73. unicode_logic_kit/dl/owl_manchester.py +1637 -0
  74. unicode_logic_kit/dl/owl_reasoner.py +790 -0
  75. unicode_logic_kit/dl/parser.py +391 -0
  76. unicode_logic_kit/dl/tableau.py +4048 -0
  77. unicode_logic_kit/dl/translate.py +2704 -0
  78. unicode_logic_kit/drt/__init__.py +94 -0
  79. unicode_logic_kit/drt/export.py +179 -0
  80. unicode_logic_kit/drt/nodes.py +506 -0
  81. unicode_logic_kit/drt/parser.py +965 -0
  82. unicode_logic_kit/drt/resolve.py +195 -0
  83. unicode_logic_kit/drt/reverse.py +175 -0
  84. unicode_logic_kit/eval/__init__.py +106 -0
  85. unicode_logic_kit/eval/batch.py +382 -0
  86. unicode_logic_kit/eval/canonical.py +663 -0
  87. unicode_logic_kit/eval/chem_batch.py +606 -0
  88. unicode_logic_kit/eval/converses.py +200 -0
  89. unicode_logic_kit/eval/datasets/__init__.py +136 -0
  90. unicode_logic_kit/eval/datasets/_base.py +263 -0
  91. unicode_logic_kit/eval/datasets/_proofwriter_proof.py +422 -0
  92. unicode_logic_kit/eval/datasets/c3po.py +678 -0
  93. unicode_logic_kit/eval/datasets/folio.py +158 -0
  94. unicode_logic_kit/eval/datasets/fracas.py +418 -0
  95. unicode_logic_kit/eval/datasets/groves.py +191 -0
  96. unicode_logic_kit/eval/datasets/logicbench.py +467 -0
  97. unicode_logic_kit/eval/datasets/logicnli.py +303 -0
  98. unicode_logic_kit/eval/datasets/malls.py +133 -0
  99. unicode_logic_kit/eval/datasets/pfolio.py +594 -0
  100. unicode_logic_kit/eval/datasets/pmb.py +242 -0
  101. unicode_logic_kit/eval/datasets/prontoqa.py +611 -0
  102. unicode_logic_kit/eval/datasets/proofwriter.py +1431 -0
  103. unicode_logic_kit/eval/datasets/proverqa.py +674 -0
  104. unicode_logic_kit/eval/datasets/willow.py +478 -0
  105. unicode_logic_kit/eval/equivalence.py +466 -0
  106. unicode_logic_kit/eval/exercise_gen.py +533 -0
  107. unicode_logic_kit/eval/explain.py +791 -0
  108. unicode_logic_kit/eval/generality.py +750 -0
  109. unicode_logic_kit/eval/metric_hf.py +458 -0
  110. unicode_logic_kit/eval/predicate_match.py +343 -0
  111. unicode_logic_kit/eval/theory_check.py +1170 -0
  112. unicode_logic_kit/eval/validate.py +306 -0
  113. unicode_logic_kit/fol/__init__.py +177 -0
  114. unicode_logic_kit/fol/_atom_keys.py +510 -0
  115. unicode_logic_kit/fol/_fol_nodes.py +3586 -0
  116. unicode_logic_kit/fol/_free_parameters.py +105 -0
  117. unicode_logic_kit/fol/_ho_nodes.py +448 -0
  118. unicode_logic_kit/fol/_hybrid_nodes.py +308 -0
  119. unicode_logic_kit/fol/_identifiers.py +1091 -0
  120. unicode_logic_kit/fol/_lambek_nodes.py +112 -0
  121. unicode_logic_kit/fol/_linear_nodes.py +352 -0
  122. unicode_logic_kit/fol/_modal_nodes.py +1467 -0
  123. unicode_logic_kit/fol/_msfl_nodes.py +2196 -0
  124. unicode_logic_kit/fol/_numeral_symbols.py +231 -0
  125. unicode_logic_kit/fol/_so_nodes.py +200 -0
  126. unicode_logic_kit/fol/_symbol_names.py +81 -0
  127. unicode_logic_kit/fol/_team_nodes.py +181 -0
  128. unicode_logic_kit/fol/_tptp_symbols.py +551 -0
  129. unicode_logic_kit/fol/_truth_constants.py +117 -0
  130. unicode_logic_kit/fol/casl_export.py +1135 -0
  131. unicode_logic_kit/fol/casl_import.py +929 -0
  132. unicode_logic_kit/fol/derivation.py +367 -0
  133. unicode_logic_kit/fol/dialect_detect.py +70 -0
  134. unicode_logic_kit/fol/dialect_repair.py +537 -0
  135. unicode_logic_kit/fol/frames.py +637 -0
  136. unicode_logic_kit/fol/grammars/terminals.lark +31 -0
  137. unicode_logic_kit/fol/lambda_tools.py +297 -0
  138. unicode_logic_kit/fol/latex_input.py +429 -0
  139. unicode_logic_kit/fol/modal_translation.py +944 -0
  140. unicode_logic_kit/fol/msflparser.py +1033 -0
  141. unicode_logic_kit/fol/naming.py +422 -0
  142. unicode_logic_kit/fol/nodes.py +241 -0
  143. unicode_logic_kit/fol/normalforms.py +492 -0
  144. unicode_logic_kit/fol/pal.py +287 -0
  145. unicode_logic_kit/fol/prolog_export.py +566 -0
  146. unicode_logic_kit/fol/prolog_input.py +505 -0
  147. unicode_logic_kit/fol/prover9_input.py +1325 -0
  148. unicode_logic_kit/fol/qml.py +1760 -0
  149. unicode_logic_kit/fol/qmltp_input.py +525 -0
  150. unicode_logic_kit/fol/sanitize.py +221 -0
  151. unicode_logic_kit/fol/serialize.py +79 -0
  152. unicode_logic_kit/fol/signature.py +1290 -0
  153. unicode_logic_kit/fol/simplify_check.py +544 -0
  154. unicode_logic_kit/fol/spans.py +594 -0
  155. unicode_logic_kit/fol/tptp_input.py +1503 -0
  156. unicode_logic_kit/fol/tptp_repair.py +941 -0
  157. unicode_logic_kit/fol/unification.py +157 -0
  158. unicode_logic_kit/fol/verbalize.py +263 -0
  159. unicode_logic_kit/hets/__init__.py +163 -0
  160. unicode_logic_kit/hets/bridge.py +142 -0
  161. unicode_logic_kit/hets/client.py +748 -0
  162. unicode_logic_kit/hets/docker.py +420 -0
  163. unicode_logic_kit/hets/dol.py +712 -0
  164. unicode_logic_kit/hets/haskell_json.py +355 -0
  165. unicode_logic_kit/hets/owl_backend.py +794 -0
  166. unicode_logic_kit/hets/owl_cli.py +598 -0
  167. unicode_logic_kit/hets/symbols.py +512 -0
  168. unicode_logic_kit/hol/__init__.py +140 -0
  169. unicode_logic_kit/hol/_ho_common.py +323 -0
  170. unicode_logic_kit/hol/_isabelle_binders.py +125 -0
  171. unicode_logic_kit/hol/classical.py +812 -0
  172. unicode_logic_kit/hol/deepshallow/__init__.py +45 -0
  173. unicode_logic_kit/hol/deepshallow/_common.py +177 -0
  174. unicode_logic_kit/hol/deepshallow/conditional.py +225 -0
  175. unicode_logic_kit/hol/deepshallow/intuitionistic.py +181 -0
  176. unicode_logic_kit/hol/deepshallow/modal.py +217 -0
  177. unicode_logic_kit/hol/deepshallow/qml.py +406 -0
  178. unicode_logic_kit/hol/deepshallow/relevant.py +206 -0
  179. unicode_logic_kit/hol/free.py +753 -0
  180. unicode_logic_kit/hol/goedel.py +336 -0
  181. unicode_logic_kit/hol/ho_modal.py +1743 -0
  182. unicode_logic_kit/hol/intuitionistic.py +403 -0
  183. unicode_logic_kit/hol/isabelle_conditional.py +593 -0
  184. unicode_logic_kit/hol/isabelle_modal.py +1908 -0
  185. unicode_logic_kit/hol/isabelle_relevant.py +412 -0
  186. unicode_logic_kit/hol/isabelle_runner.py +1147 -0
  187. unicode_logic_kit/hol/isabelle_substructural.py +884 -0
  188. unicode_logic_kit/hol/lean.py +1018 -0
  189. unicode_logic_kit/hol/manyvalued.py +921 -0
  190. unicode_logic_kit/hol/secondorder.py +687 -0
  191. unicode_logic_kit/hol/thf_modal.py +941 -0
  192. unicode_logic_kit/hol/thirdorder.py +397 -0
  193. unicode_logic_kit/ilp/__init__.py +89 -0
  194. unicode_logic_kit/ilp/readback.py +389 -0
  195. unicode_logic_kit/ilp/separation.py +153 -0
  196. unicode_logic_kit/ilp/task.py +730 -0
  197. unicode_logic_kit/logic.py +163 -0
  198. unicode_logic_kit/mcp/__init__.py +28 -0
  199. unicode_logic_kit/mcp/__main__.py +5 -0
  200. unicode_logic_kit/mcp/chem_tools.py +1031 -0
  201. unicode_logic_kit/mcp/server.py +2453 -0
  202. unicode_logic_kit/mcp/syntax_spec.py +681 -0
  203. unicode_logic_kit/prob/__init__.py +53 -0
  204. unicode_logic_kit/prob/_bdd.py +225 -0
  205. unicode_logic_kit/prob/_column_gen.py +668 -0
  206. unicode_logic_kit/prob/distribution.py +686 -0
  207. unicode_logic_kit/prob/nilsson.py +470 -0
  208. unicode_logic_kit/py.typed +0 -0
  209. unicode_logic_kit/semantics/__init__.py +137 -0
  210. unicode_logic_kit/semantics/_modal_reject.py +156 -0
  211. unicode_logic_kit/semantics/action_models.py +466 -0
  212. unicode_logic_kit/semantics/asp_models.py +1200 -0
  213. unicode_logic_kit/semantics/conditional.py +580 -0
  214. unicode_logic_kit/semantics/dynamic_epistemic.py +95 -0
  215. unicode_logic_kit/semantics/free_logic.py +913 -0
  216. unicode_logic_kit/semantics/fuzzy.py +384 -0
  217. unicode_logic_kit/semantics/fuzzy_kripke.py +442 -0
  218. unicode_logic_kit/semantics/intuitionistic.py +581 -0
  219. unicode_logic_kit/semantics/kripke.py +1139 -0
  220. unicode_logic_kit/semantics/manyvalued.py +580 -0
  221. unicode_logic_kit/semantics/matrix.py +342 -0
  222. unicode_logic_kit/semantics/model_eval.py +1135 -0
  223. unicode_logic_kit/semantics/modelfinder.py +1036 -0
  224. unicode_logic_kit/semantics/nonmonotonic.py +372 -0
  225. unicode_logic_kit/semantics/relevant.py +331 -0
  226. unicode_logic_kit/semantics/secondorder.py +657 -0
  227. unicode_logic_kit/semantics/structures.py +352 -0
  228. unicode_logic_kit/semantics/tarski.py +975 -0
  229. unicode_logic_kit/semantics/team.py +315 -0
  230. unicode_logic_kit/semantics/team_translation.py +416 -0
  231. unicode_logic_kit/semantics/thirdorder.py +358 -0
  232. unicode_logic_kit/semantics/tnorm.py +85 -0
  233. unicode_logic_kit/semantics/truthtable.py +201 -0
  234. unicode_logic_kit-0.31.0.dist-info/METADATA +333 -0
  235. unicode_logic_kit-0.31.0.dist-info/RECORD +237 -0
  236. unicode_logic_kit-0.31.0.dist-info/WHEEL +4 -0
  237. unicode_logic_kit-0.31.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,544 @@
1
+ """Model-checking-oriented simplification of FOL formulas, and a bridge between
2
+ Count and the "distinct existential witnesses" encoding an LLM tends to write.
3
+
4
+ Motivation: an LLM translating a class definition such as "has at least 40
5
+ carbons" produces ``∃x_0…∃x_39 (⋀_i c(x_i) ∧ ⋀_{i<j} x_i≠x_j)`` — 40
6
+ existentially-quantified variables plus all ``binomial(40,2) = 780`` pairwise
7
+ inequalities between them. Those constraints are unnecessary for a model
8
+ checker that already interprets separately introduced variables as distinct,
9
+ and FOL definitions that rely on such predicates, or that carry trivial
10
+ pairwise inequality constraints, are prone to timeouts. What the shape really
11
+ asks for is a parameterised built-in — an "at least n carbons" counter,
12
+ evaluated directly by the model checker — and this kit already has one:
13
+ :class:`~unicode_logic_kit.fol.nodes.Count`
14
+ (``∃≥n x φ``), which is first-order EXPRESSIBLE (so nothing about the target
15
+ logic changes) but is represented and can be *evaluated* without ever
16
+ materialising the O(n²) encoding (see :mod:`unicode_logic_kit.semantics` for the
17
+ structure-checking side of that).
18
+
19
+ This module supplies the missing piece: recognising and rewriting the
20
+ NL-generated distinct-witnesses pattern (in both directions), plus a separate,
21
+ narrower simplification pass that removes exactly the redundant pairwise
22
+ inequalities WITHOUT requiring the whole formula to be that one clean pattern
23
+ — real LLM output routinely interleaves the inequalities with other content
24
+ (actual chemical bonds between the same variables, for instance), which
25
+ :func:`count_from_existential_chain` must conservatively refuse to touch.
26
+
27
+ Two independent tools, two independent soundness stories
28
+ ----------------------------------------------------------
29
+ ``simplify_for_checking(formula, all_different=...)``
30
+ A conservative rewriter that never changes what the formula MEANS. Most
31
+ of its rules (duplicate conjuncts/disjuncts, ``t=t``, vacuous quantifiers,
32
+ double negation) hold under ordinary classical FOL semantics — the
33
+ ``removed``/`changed`` bookkeeping documents exactly what left the tree
34
+ and why. Its one semantics-DEPENDENT rule (``all_different=True``) removes
35
+ pairwise inequalities between separately ∃-bound variables under the
36
+ *specific, non-standard convention some model checkers adopt* —
37
+ separately introduced existential variables are already distinct —
38
+ which is NOT a theorem of ordinary FOL (a domain with one element makes
39
+ ``∃x∃y(x≠y)`` false there). ``all_different`` therefore defaults to
40
+ ``False``: soundness under standard semantics is the default, and that
41
+ convention must be opted into explicitly, one call at a time.
42
+
43
+ ``count_from_existential_chain`` / ``expand_count``
44
+ A structural equivalence, sound under ORDINARY FOL semantics (no flag,
45
+ no assumption beyond a non-empty domain): ``∃x_0…∃x_{n-1} (⋀ φ(x_i) ∧
46
+ ⋀_{i<j} x_i≠x_j)`` and ``Count("ge", n, x, φ(x))`` denote the same set of
47
+ structures, because the former is exactly what
48
+ :meth:`~unicode_logic_kit.fol.nodes.Count._expand` produces when lowering
49
+ the latter. ``count_from_existential_chain`` is therefore conservative in
50
+ a DIFFERENT sense than the flag above: it recognises only a complete,
51
+ exact instance of the pattern (every φ(x_i), every C(n,2) inequality,
52
+ nothing else) and returns ``None`` rather than guess at a rewrite that
53
+ would silently drop real content (e.g. a bond literal connecting two of
54
+ the witnesses). ``expand_count`` is the inverse direction, for backends
55
+ that have no ``Count`` node of their own; it does not duplicate the
56
+ distinct-witnesses encoding — it walks the tree and reuses
57
+ :meth:`Count._expand` on every ``Count`` node found (the same lowering
58
+ ``Count.to_z3``/``to_prover9``/``to_tptp`` already use internally).
59
+
60
+ Both rewriters are purely syntactic (no ``to_z3``, no model). Semantic
61
+ equivalence between the two directions is checked in the test suite via
62
+ :func:`unicode_logic_kit.atp.z3_equivalence.formulas_are_equivalent` on small
63
+ instances (n≤4) — see tests/test_simplify_check.py.
64
+ """
65
+
66
+ from dataclasses import dataclass, fields
67
+ from typing import Dict, List, Optional, Tuple
68
+
69
+ from .nodes import (
70
+ Node, Variable, Constant, Number, Atom, Not, And, Or, Quantifier, Count,
71
+ Cardinality, SortedQuantifier, SortedCount, SortedCardinality, Lambda,
72
+ free_variables,
73
+ )
74
+
75
+ __all__ = [
76
+ "SimplifyResult", "simplify_for_checking",
77
+ "count_from_existential_chain", "expand_count",
78
+ ]
79
+
80
+ _EXISTS = ("∃", "exists")
81
+ _FORALL = ("∀", "forall")
82
+
83
+ # Sentinel marking a "combine the two children just pushed" step in the
84
+ # iterative chain walk below — distinct from any real Node by identity.
85
+ _COMBINE = object()
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class SimplifyResult:
90
+ """The result of :func:`simplify_for_checking`.
91
+
92
+ ``simplified`` is the rewritten formula (structurally identical to the
93
+ input, down to the exact ∧/∨ bracketing, whenever nothing was removed —
94
+ see :func:`_rebuild_like`). ``removed`` is one human-readable description
95
+ per individual construct that was dropped or collapsed (so
96
+ ``len(result.removed)`` counts, e.g., exactly how many redundant pairwise
97
+ inequalities a call with ``all_different=True`` removed). ``changed`` is
98
+ ``True`` iff ``removed`` is non-empty; it never disagrees with ``removed``
99
+ because every rewrite rule below logs its own action before applying it —
100
+ there is no rule that changes the tree silently.
101
+ """
102
+
103
+ simplified: Node
104
+ removed: Tuple[str, ...]
105
+ changed: bool
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Iterative flatten/rebuild over a binary ∧- or ∨-chain.
110
+ #
111
+ # Both are iterative (explicit stack), not recursive, on purpose: an n-ary
112
+ # conjunction as an LLM actually writes it (left- or right-nested, never
113
+ # balanced) recurses to depth n in the naive implementation, and n is exactly
114
+ # the O(n²)-inequality count this module exists to simplify (820 conjuncts for
115
+ # an "at least 40 carbons" formula) — close enough to Python's default
116
+ # recursion limit (1000) to be a real risk, not a theoretical one.
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def _chain_ops(node: Node, cls) -> List:
120
+ """POSTORDER walk of a binary ``cls``-chain (``cls`` is ``And`` or ``Or``).
121
+
122
+ Returns a flat list mixing leaf Nodes (in left-to-right order) with the
123
+ ``_COMBINE`` sentinel marking "pop two values and join them" — enough
124
+ information to both read off the leaves (:func:`_flatten_chain`) and
125
+ rebuild the exact original shape around a substitute leaf sequence
126
+ (:func:`_rebuild_like`), without walking the tree twice recursively.
127
+ """
128
+ ops: List = []
129
+ stack = [(node, False)]
130
+ while stack:
131
+ cur, expanded = stack.pop()
132
+ if isinstance(cur, cls):
133
+ if not expanded:
134
+ stack.append((cur, True))
135
+ stack.append((cur.right, False))
136
+ stack.append((cur.left, False))
137
+ else:
138
+ ops.append(_COMBINE)
139
+ else:
140
+ ops.append(cur)
141
+ return ops
142
+
143
+
144
+ def _flatten_chain(node: Node, cls) -> List[Node]:
145
+ """The leaves of a binary ``cls``-chain, left to right (``[node]`` if
146
+ ``node`` is not itself a ``cls``)."""
147
+ return [op for op in _chain_ops(node, cls) if op is not _COMBINE]
148
+
149
+
150
+ def _fold(cls, parts: List[Node]) -> Node:
151
+ """Left-associative rebuild of ``parts`` under ``cls`` — the shape used
152
+ whenever the leaf sequence actually changed (so there is no "original
153
+ shape" left to preserve)."""
154
+ result = parts[0]
155
+ for part in parts[1:]:
156
+ result = cls(result, part)
157
+ return result
158
+
159
+
160
+ def _rebuild_like(template: Node, cls, leaves) -> Node:
161
+ """Rebuild EXACTLY ``template``'s bracketing, substituting ``leaves`` (an
162
+ iterator, consumed left to right) for its original leaf positions.
163
+
164
+ Used only when nothing was removed from the chain, so the result is
165
+ byte-for-byte the same shape as the input (with each leaf replaced by its
166
+ own, independently simplified, form) — callers that compare
167
+ ``result.simplified`` against the input with plain ``==`` when
168
+ ``changed`` is ``False`` get exact equality, not just equivalence.
169
+ """
170
+ stack: List[Node] = []
171
+ for op in _chain_ops(template, cls):
172
+ if op is _COMBINE:
173
+ right = stack.pop()
174
+ left = stack.pop()
175
+ stack.append(cls(left, right))
176
+ else:
177
+ stack.append(next(leaves))
178
+ return stack[0]
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Eligibility for "drop this conjunct" — the two rules that only make sense
183
+ # INSIDE a conjunction (dropping a conjunct that is always true preserves
184
+ # meaning; dropping it from a DISJUNCTION would not: A ∨ True ≡ True, not A).
185
+ # ---------------------------------------------------------------------------
186
+
187
+ def _is_trivial_equality(node: Node) -> bool:
188
+ """``t = t`` for any term ``t`` (not just a bare variable): reflexivity of
189
+ ``=`` makes this valid regardless of what ``t`` denotes, so ``φ ∧ (t=t) ≡
190
+ φ`` always — no ``all_different`` assumption needed."""
191
+ return (isinstance(node, Atom) and node.predicate == "="
192
+ and len(node.args) == 2 and node.args[0] == node.args[1])
193
+
194
+
195
+ def _is_redundant_all_diff_ne(node: Node, binders: Dict[str, str]) -> bool:
196
+ """``x ≠ y`` where both ``x`` and ``y`` are separately ∃-bound variables —
197
+ redundant ONLY under the ``all_different=True`` convention (see the module
198
+ docstring). Deliberately narrow:
199
+
200
+ - both sides must be plain :class:`Variable` terms (a constant or a
201
+ function application on either side is excluded — the convention is
202
+ about how a model checker treats freshly INTRODUCED existential
203
+ variables, and says nothing about a comparison against a fixed
204
+ denotation);
205
+ - the two names must differ (``x ≠ x`` is not "two separate variables
206
+ happen to be distinct", it is an outright contradiction — leaving it
207
+ alone is the conservative choice, not an oversight);
208
+ - both must be bound by an enclosing ``∃`` specifically — a ``∀``-bound
209
+ variable ranges over the WHOLE domain, so "this witness is distinct
210
+ from that other witness" is not something the model checker's
211
+ convention says anything about.
212
+ """
213
+ if not (isinstance(node, Atom) and node.predicate == "≠" and len(node.args) == 2):
214
+ return False
215
+ left, right = node.args
216
+ if not (isinstance(left, Variable) and isinstance(right, Variable)):
217
+ return False
218
+ if left.name == right.name:
219
+ return False
220
+ return binders.get(left.name) == "exists" and binders.get(right.name) == "exists"
221
+
222
+
223
+ def _is_removable_conjunct(node: Node, all_different: bool, binders: Dict[str, str]) -> bool:
224
+ return _is_trivial_equality(node) or (all_different and _is_redundant_all_diff_ne(node, binders))
225
+
226
+
227
+ def _describe_removed_conjunct(node: Node, all_different: bool) -> str:
228
+ if _is_trivial_equality(node):
229
+ return ("dropped trivial reflexive equality from a conjunction "
230
+ f"(φ ∧ (t=t) ≡ φ): {node.to_unicode_str()}")
231
+ left, right = node.args
232
+ return ("dropped redundant pairwise inequality under all_different=True "
233
+ f"(φ ∧ ({left.name}≠{right.name}) ≡ φ under this kit's "
234
+ "all_different convention, since both are separately ∃-bound "
235
+ f"and hence already distinct): {node.to_unicode_str()}")
236
+
237
+
238
+ def _dedupe_chain(parts: List[Node], op_glyph: str, removed: List[str]) -> List[Node]:
239
+ """Drop later occurrences of a structurally-identical operand (idempotence
240
+ of ∧/∨: ``φ op φ ≡ φ``) — valid regardless of ``op_glyph`` and regardless
241
+ of ``all_different``, so this runs unconditionally for both And and Or."""
242
+ seen = set()
243
+ kept: List[Node] = []
244
+ for part in parts:
245
+ if part in seen:
246
+ removed.append(
247
+ f"dropped duplicate {op_glyph}-operand (idempotence: "
248
+ f"φ {op_glyph} φ ≡ φ): {part.to_unicode_str()}"
249
+ )
250
+ continue
251
+ seen.add(part)
252
+ kept.append(part)
253
+ return kept
254
+
255
+
256
+ def _filter_and_conjuncts(parts: List[Node], all_different: bool,
257
+ binders: Dict[str, str], removed: List[str]) -> List[Node]:
258
+ """Drop every removable conjunct (:func:`_is_removable_conjunct`), except
259
+ that when EVERY conjunct in the chain is removable, one is kept —
260
+ otherwise the chain would collapse to an empty (nonexistent) formula.
261
+ The kept one is equivalent to True anyway (it is one of the removable
262
+ ones), so keeping it changes nothing observable; which one is kept is an
263
+ arbitrary but deterministic choice (the first, by list order)."""
264
+ eligible = [i for i, p in enumerate(parts) if _is_removable_conjunct(p, all_different, binders)]
265
+ drop = set(eligible)
266
+ if eligible and len(eligible) == len(parts):
267
+ drop.discard(eligible[0])
268
+ kept: List[Node] = []
269
+ for i, part in enumerate(parts):
270
+ if i in drop:
271
+ removed.append(_describe_removed_conjunct(part, all_different))
272
+ continue
273
+ kept.append(part)
274
+ return kept
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # The recursive engine.
279
+ # ---------------------------------------------------------------------------
280
+
281
+ # Node classes that bind exactly one variable over exactly one Node-valued
282
+ # "body" field but are NOT a Quantifier subclass, so the dedicated Quantifier
283
+ # branch above never sees them — Count/Cardinality/SortedQuantifier/
284
+ # SortedCount/SortedCardinality all bind via a field named ``variable`` over
285
+ # a field named ``formula``; Lambda binds via ``param`` (a LambdaVar, not a
286
+ # Variable) over ``body``. Mirrors exactly the case list
287
+ # :func:`unicode_logic_kit.fol._msfl_nodes.free_variables` already special-cases
288
+ # for the same reason (a binder whose bound name is not tracked as its own
289
+ # scope leaks the ENCLOSING scope's variable classification into a name that,
290
+ # inside this node, actually refers to something else entirely).
291
+ #
292
+ # Used below to SHADOW (remove, not reclassify as "exists"/"forall") the
293
+ # bound name out of ``binders`` while descending into the body field only —
294
+ # this is deliberately not the same as adding it with a "exists" kind: the
295
+ # all_different convention is documented as being about separately ∃-bound
296
+ # Quantifier variables specifically, so these binders still never make the
297
+ # rule fire on THEIR OWN bound name; the fix here is purely to stop an
298
+ # outer, differently-scoped ∃/∀ of the identical name from being applied to
299
+ # occurrences that this binder has re-bound (see Finding 1 in the task brief:
300
+ # ``all_different=True`` could otherwise drop a ``≠`` between a variable
301
+ # re-bound by an enclosing Count/Cardinality/SortedQuantifier/SortedCount/
302
+ # SortedCardinality/Lambda and an unrelated, same-named ∃-bound variable
303
+ # further out — unsound, since the two occurrences do not denote the same
304
+ # thing).
305
+ _SINGLE_VAR_BINDER_FIELDS: Dict[type, Tuple[str, str]] = {
306
+ Count: ("variable", "formula"),
307
+ Cardinality: ("variable", "formula"),
308
+ SortedQuantifier: ("variable", "formula"),
309
+ SortedCount: ("variable", "formula"),
310
+ SortedCardinality: ("variable", "formula"),
311
+ Lambda: ("param", "body"),
312
+ }
313
+
314
+
315
+ def _simplify(n: Node, binders: Dict[str, str], all_different: bool,
316
+ removed: List[str]) -> Node:
317
+ if isinstance(n, (Variable, Constant, Number, Atom)):
318
+ return n # leaves/terms: none of the rules below rewrite term structure
319
+
320
+ if isinstance(n, Not):
321
+ inner = _simplify(n.formula, binders, all_different, removed)
322
+ if isinstance(inner, Not):
323
+ # ¬¬φ ≡ φ, unconditionally (classical double negation elimination).
324
+ removed.append(
325
+ f"collapsed double negation (¬¬φ ≡ φ): ¬¬{inner.formula.to_unicode_str()}"
326
+ )
327
+ return inner.formula
328
+ return Not(inner)
329
+
330
+ if isinstance(n, And):
331
+ raw = _flatten_chain(n, And)
332
+ parts = [_simplify(p, binders, all_different, removed) for p in raw]
333
+ kept = _dedupe_chain(parts, "∧", removed)
334
+ kept = _filter_and_conjuncts(kept, all_different, binders, removed)
335
+ if kept == parts:
336
+ return _rebuild_like(n, And, iter(parts))
337
+ return _fold(And, kept)
338
+
339
+ if isinstance(n, Or):
340
+ raw = _flatten_chain(n, Or)
341
+ parts = [_simplify(p, binders, all_different, removed) for p in raw]
342
+ kept = _dedupe_chain(parts, "∨", removed)
343
+ if kept == parts:
344
+ return _rebuild_like(n, Or, iter(parts))
345
+ return _fold(Or, kept)
346
+
347
+ if isinstance(n, Quantifier):
348
+ if n.type in _EXISTS:
349
+ kind = "exists"
350
+ elif n.type in _FORALL:
351
+ kind = "forall"
352
+ else:
353
+ raise ValueError(f"simplify_for_checking: unknown quantifier type {n.type!r}")
354
+ new_binders = dict(binders)
355
+ new_binders[n.variable.name] = kind
356
+ body = _simplify(n.formula, new_binders, all_different, removed)
357
+ # ∃x φ ≡ φ / ∀x φ ≡ φ when x does not occur free in φ — valid because
358
+ # this kit's semantics (like Count._expand's own '≥0' case) assumes a
359
+ # NON-EMPTY domain throughout, so a quantifier that never inspects its
360
+ # variable is a no-op regardless of quantifier kind.
361
+ if n.variable.name not in {v.name for v in free_variables(body)}:
362
+ removed.append(
363
+ f"dropped vacuous {n.type}{n.variable.name} ({n.variable.name} "
364
+ "does not occur free in the body; valid on a non-empty domain): "
365
+ f"{n.type}{n.variable.name}"
366
+ )
367
+ return body
368
+ return Quantifier(n.type, n.variable, body)
369
+
370
+ # Generic structural fallback: every other node type (Implies, Iff, Xor,
371
+ # Count, Cardinality, Measure, and any modal/hybrid/linear/second-order/
372
+ # MSFL node). Recurse into Node-valued fields; leave the node's own type
373
+ # and every non-Node field untouched. This makes the function total
374
+ # (it never raises on an unfamiliar node) while never MISAPPLYING a
375
+ # classical rule to a same-looking but semantically different construct —
376
+ # e.g. Łukasiewicz LukNegation is a distinct class from Not, so double
377
+ # negation collapse never touches it; WeakConjunction is a distinct class
378
+ # from And, so it never gets the classical idempotence/trivial-conjunct
379
+ # treatment. A Count/Cardinality/SortedQuantifier/SortedCount/
380
+ # SortedCardinality/Lambda node's own bound variable is deliberately NOT
381
+ # added to ``binders`` here as "exists"/"forall" (none of these are a
382
+ # Quantifier subclass, and the all_different convention is documented as
383
+ # being specifically about separately ∃-bound Quantifier variables) — but
384
+ # its name MUST still be shadowed (removed from ``binders``) while
385
+ # descending into the body, or an outer ∃/∀ of the identical name would
386
+ # incorrectly keep classifying occurrences that this binder has re-bound
387
+ # to something else entirely (see ``_SINGLE_VAR_BINDER_FIELDS``).
388
+ binder_fields = _SINGLE_VAR_BINDER_FIELDS.get(type(n))
389
+ shadowed_binders = binders
390
+ shadow_field = None
391
+ if binder_fields is not None:
392
+ var_field, shadow_field = binder_fields
393
+ bound_name = getattr(n, var_field).name
394
+ if bound_name in binders:
395
+ shadowed_binders = dict(binders)
396
+ del shadowed_binders[bound_name]
397
+
398
+ new_kwargs = {}
399
+ for f in fields(n):
400
+ val = getattr(n, f.name)
401
+ field_binders = shadowed_binders if f.name == shadow_field else binders
402
+ if isinstance(val, Node):
403
+ new_kwargs[f.name] = _simplify(val, field_binders, all_different, removed)
404
+ elif isinstance(val, (list, tuple)):
405
+ new_kwargs[f.name] = type(val)(
406
+ _simplify(c, field_binders, all_different, removed) if isinstance(c, Node) else c
407
+ for c in val
408
+ )
409
+ else:
410
+ new_kwargs[f.name] = val
411
+ return type(n)(**new_kwargs)
412
+
413
+
414
+ def simplify_for_checking(formula: Node, *, all_different: bool = False) -> SimplifyResult:
415
+ """Simplify ``formula`` for model checking; see the module docstring for
416
+ the full rule list and their equivalence arguments.
417
+
418
+ Args:
419
+ formula: any :class:`Node`.
420
+ all_different: if ``True``, ALSO remove pairwise inequalities between
421
+ separately ∃-bound variables (see
422
+ :func:`_is_redundant_all_diff_ne`). This is sound only under the
423
+ model checker's own "separately introduced existential variables
424
+ are already distinct" convention, NOT under standard FOL
425
+ semantics — leave it ``False`` (the default) unless the
426
+ downstream checker actually implements that convention.
427
+
428
+ Returns:
429
+ A :class:`SimplifyResult`. Applying :func:`simplify_for_checking`
430
+ again to ``result.simplified`` (same ``all_different``) always yields
431
+ ``changed=False`` — every rule above leaves no further instance of
432
+ its own pattern behind (checked in the test suite).
433
+ """
434
+ removed: List[str] = []
435
+ simplified = _simplify(formula, {}, all_different, removed)
436
+ return SimplifyResult(simplified=simplified, removed=tuple(removed), changed=bool(removed))
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Existential-chain <-> Count.
441
+ # ---------------------------------------------------------------------------
442
+
443
+ def count_from_existential_chain(formula: Node) -> Optional[Node]:
444
+ """Recognise ``∃x_0…∃x_{n-1} (⋀_i φ(x_i) ∧ ⋀_{i<j} x_i≠x_j)`` and rewrite
445
+ it to ``Count("ge", n, x, φ(x))``. Returns ``None`` if ``formula`` is not
446
+ EXACTLY this pattern.
447
+
448
+ Requires, at the top of ``formula``:
449
+
450
+ - a chain of ``n ≥ 1`` consecutive ``∃`` quantifiers over ``n`` distinct
451
+ variable names (a ``∀`` anywhere in the prefix, or a shadowed/repeated
452
+ name, is not this pattern);
453
+ - a body that — once fully flattened as a ∧-chain — has EXACTLY
454
+ ``n + n·(n-1)/2`` conjuncts: for every quantified variable exactly one
455
+ unary atom ``φ(x_i)`` (same predicate ``φ`` for all of them), and for
456
+ every unordered pair exactly one ``x_i≠x_j``, and NOTHING else.
457
+
458
+ Any deviation — a variable appearing in a further literal (a bond between
459
+ two of the witnesses, say), a mismatched predicate, an inequality against
460
+ a constant, a missing or duplicated pair, an extra or missing conjunct —
461
+ returns ``None`` rather than guessing: since the rewrite DISCARDS
462
+ everything except "n witnesses, all φ, all pairwise distinct", rewriting
463
+ a formula that also asserts something else about those witnesses would
464
+ silently drop that content. This is the CONSERVATIVE direction; the
465
+ all_different flag on :func:`simplify_for_checking` is the narrower,
466
+ non-conservative-in-scope tool for formulas that don't fit this pattern
467
+ exactly but still have redundant pairwise inequalities in them.
468
+
469
+ The equivalence this relies on is exactly :meth:`Count._expand`'s own
470
+ encoding (``∃≥n x φ`` unfolds to precisely this shape) — checked via Z3
471
+ equivalence for n≤4 in the test suite, in both directions.
472
+ """
473
+ variables: List[Variable] = []
474
+ body = formula
475
+ while isinstance(body, Quantifier) and body.type in _EXISTS:
476
+ variables.append(body.variable)
477
+ body = body.formula
478
+ n = len(variables)
479
+ if n == 0:
480
+ return None
481
+ names = [v.name for v in variables]
482
+ if len(set(names)) != n:
483
+ return None
484
+ name_set = set(names)
485
+
486
+ parts = _flatten_chain(body, And)
487
+ expected = n + n * (n - 1) // 2
488
+ if len(parts) != expected:
489
+ return None
490
+
491
+ phi_name: Optional[str] = None
492
+ matched_var: Dict[str, str] = {}
493
+ pairs_seen = set()
494
+ all_pairs = {frozenset((names[i], names[j])) for i in range(n) for j in range(i + 1, n)}
495
+
496
+ for part in parts:
497
+ if isinstance(part, Atom) and part.predicate == "≠" and len(part.args) == 2:
498
+ left, right = part.args
499
+ if not (isinstance(left, Variable) and isinstance(right, Variable)):
500
+ return None
501
+ if left.name not in name_set or right.name not in name_set or left.name == right.name:
502
+ return None
503
+ pair = frozenset((left.name, right.name))
504
+ if pair in pairs_seen:
505
+ return None
506
+ pairs_seen.add(pair)
507
+ continue
508
+ if (isinstance(part, Atom) and len(part.args) == 1
509
+ and isinstance(part.args[0], Variable)
510
+ and part.args[0].name in name_set):
511
+ v = part.args[0].name
512
+ if v in matched_var:
513
+ return None
514
+ if phi_name is None:
515
+ phi_name = part.predicate
516
+ elif part.predicate != phi_name:
517
+ return None
518
+ matched_var[v] = part.predicate
519
+ continue
520
+ return None # anything else — extra/foreign literal — disqualifies the rewrite
521
+
522
+ if set(matched_var) != name_set or pairs_seen != all_pairs:
523
+ return None
524
+
525
+ rep = variables[0]
526
+ return Count("ge", Number(n), rep, Atom(phi_name, [rep]))
527
+
528
+
529
+ def expand_count(formula: Node) -> Node:
530
+ """Lower every ``Count`` node anywhere in ``formula`` to plain FOL, for
531
+ backends with no ``Count`` node of their own.
532
+
533
+ Reuses :meth:`Count._expand` (the same distinct-witnesses lowering
534
+ ``Count.to_z3``/``to_prover9``/``to_tptp`` already call internally)
535
+ rather than duplicating it — this function only supplies the tree walk.
536
+ Mirrors the ``Count``-handling branch of
537
+ :func:`unicode_logic_kit.fol._msfl_nodes.to_fol`'s ``_reduce_nl_nodes``,
538
+ minus that function's other, unrelated reductions (Łukasiewicz collapse,
539
+ sort relativisation): this one does exactly one thing.
540
+ """
541
+ reduced = formula.map_children(expand_count)
542
+ if isinstance(reduced, Count):
543
+ return reduced._expand()
544
+ return reduced