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,2196 @@
1
+ """MSFL node classes (sorted quantifiers/constants, Łukasiewicz operators) and to_fol reduction."""
2
+
3
+ import contextvars
4
+ import logging
5
+ from dataclasses import dataclass, is_dataclass, replace
6
+ from typing import List, Optional, Tuple, TYPE_CHECKING
7
+
8
+ from ._fol_nodes import (
9
+ Node, Z3Env, Variable, Constant, Number, Function,
10
+ Atom, Not, And, Or, Xor, Implies, Iff, Quantifier,
11
+ Count, Cardinality, Contrast, _COUNT_OPS, _COUNT_TOKEN_TO_OP, _count_bound,
12
+ NODE_CLASSES, OPERATORS, register_operator,
13
+ register_parser_op, _fold_binary, _number_text, _prover9_outermost,
14
+ )
15
+ from ._identifiers import (
16
+ constant_text, fresh_like, fresh_variable_like, fresh_variables, symbol_names,
17
+ variable_names,
18
+ )
19
+ from ._team_nodes import SlashedExists
20
+ # PredicateTerm is a term-level leaf like Variable/Constant, so the shared
21
+ # renderers and the lambda-scope resolver here have to know it by name.
22
+ # Safe to import at module scope: _ho_nodes reaches only _fol_nodes and
23
+ # _so_nodes at import time, and takes its own Lambda dependency lazily.
24
+ from ._ho_nodes import PredicateTerm
25
+
26
+ if TYPE_CHECKING:
27
+ # Type-hint only: a real (runtime) import here would be circular
28
+ # (signature.py imports from .nodes, which re-exports the classes this
29
+ # module defines) — see subsort_axioms's own docstring for why a Signature
30
+ # is instead duck-typed at runtime (only its .subsorts mapping is read).
31
+ from .signature import Signature
32
+
33
+ _logger = logging.getLogger(__name__)
34
+
35
+ # Why the Łukasiewicz nodes REFUSE the classical exports below: their to_msfol
36
+ # collapse (min/max/t-norm -> And/Or/...) changes the LOGIC, not just the syntax
37
+ # — fuzzy weak-disjunction excluded middle P ∨ ¬P is not Łukasiewicz-valid but
38
+ # its classical image is, so a silent collapse inside is_valid /
39
+ # is_valid_resolution returned wrong verdicts. The collapse stays available, but
40
+ # only explicitly: to_fol(node) first.
41
+ _LUK_NO_CLASSICAL_EXPORT = (
42
+ "Łukasiewicz connectives are not classical — a silent classical collapse "
43
+ "would decide the WRONG logic (fuzzy P ∨ ¬P is not Łukasiewicz-valid, its "
44
+ "classical image is). Evaluate with semantics.fuzzy.evaluate or decide with "
45
+ "atp.z3_fuzzy.fuzzy_is_valid / fuzzy_is_satisfiable; if the classical "
46
+ "skeleton is really wanted, collapse explicitly with to_fol(node) first."
47
+ )
48
+
49
+
50
+ # =========================
51
+ # MSFL Nodes
52
+ # =========================
53
+
54
+ @dataclass(frozen=True)
55
+ class SortedQuantifier(Node):
56
+ """A sort-restricted quantifier binding a variable to a named sort.
57
+
58
+ type is "∀" (universal) or "∃" (existential).
59
+ Semantics: ∀x:S φ asserts φ holds for all elements of sort S;
60
+ ∃x:S φ asserts φ holds for some element of sort S.
61
+ Reduction to plain FOL is a later step.
62
+ """
63
+
64
+ type: str
65
+ variable: Variable
66
+ sort: str
67
+ formula: Node
68
+
69
+ def _tree_parts(self):
70
+ return f"{self.type} {self.variable.name}:{self.sort}", [self.formula]
71
+
72
+ def to_dict(self):
73
+ return {
74
+ "_type": "SortedQuantifier",
75
+ "type": self.type,
76
+ "variable": self.variable.to_dict(),
77
+ "sort": self.sort,
78
+ "formula": self.formula.to_dict(),
79
+ }
80
+
81
+ @staticmethod
82
+ def from_dict(d):
83
+ return SortedQuantifier(
84
+ d["type"],
85
+ Node.from_dict(d["variable"]),
86
+ d["sort"],
87
+ Node.from_dict(d["formula"]),
88
+ )
89
+
90
+ def to_msfol(self) -> "Node":
91
+ return SortedQuantifier(self.type, self.variable, self.sort, self.formula.to_msfol())
92
+
93
+ def _relativize(self, facts: list) -> "Node":
94
+ body = self.formula._relativize(facts)
95
+ sort_atom = Atom(self.sort, (self.variable,))
96
+ if self.type == "∀":
97
+ return Quantifier("∀", self.variable, Implies(sort_atom, body))
98
+ elif self.type == "∃":
99
+ return Quantifier("∃", self.variable, And(sort_atom, body))
100
+ raise ValueError(f"Unknown quantifier type: {self.type}")
101
+
102
+ def to_z3(self, env: Optional[Z3Env] = None):
103
+ _logger.info("Auto-reducing %s to FOL for Z3 export.", type(self).__name__)
104
+ return to_fol(self).to_z3(env)
105
+
106
+ @_prover9_outermost
107
+ def to_prover9(self) -> str:
108
+ _logger.info("Auto-reducing %s to FOL for Prover9 export.", type(self).__name__)
109
+ return to_fol(self).to_prover9()
110
+
111
+ def to_tptp(self) -> str:
112
+ _logger.info("Auto-reducing %s to FOL for TPTP export.", type(self).__name__)
113
+ return to_fol(self).to_tptp()
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class SortedConstant(Node):
118
+ """A constant symbol annotated with a sort name, e.g. alice:Human.
119
+
120
+ Semantics: a ground term that belongs to the named sort.
121
+ Reduction to plain FOL is a later step.
122
+ """
123
+
124
+ name: str
125
+ sort: str
126
+
127
+ def _tree_parts(self):
128
+ return f"{self.name}:{self.sort}", []
129
+
130
+ def to_dict(self):
131
+ return {"_type": "SortedConstant", "name": self.name, "sort": self.sort}
132
+
133
+ @staticmethod
134
+ def from_dict(d):
135
+ return SortedConstant(d["name"], d["sort"])
136
+
137
+ def to_msfol(self) -> "Node":
138
+ return SortedConstant(self.name, self.sort)
139
+
140
+ def _relativize(self, facts: list) -> "Node":
141
+ facts.append(Atom(self.sort, (Constant(self.name),)))
142
+ return Constant(self.name)
143
+
144
+ def to_z3(self, env: Optional[Z3Env] = None):
145
+ _logger.info("Auto-reducing %s to FOL for Z3 export.", type(self).__name__)
146
+ return to_fol(self).to_z3(env)
147
+
148
+ def to_prover9(self) -> str:
149
+ _logger.info("Auto-reducing %s to FOL for Prover9 export.", type(self).__name__)
150
+ return to_fol(self).to_prover9()
151
+
152
+ def to_tptp(self) -> str:
153
+ _logger.info("Auto-reducing %s to FOL for TPTP export.", type(self).__name__)
154
+ return to_fol(self).to_tptp()
155
+
156
+ def _tptp_symbol(self):
157
+ """The constant word this node writes: it renders as the plain ``Constant`` of the same name."""
158
+ return Constant(self.name)._tptp_symbol()
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class SortedCount(Node):
163
+ """A sort-restricted counting quantifier ∃≥n / ∃≤n / ∃=n x:S over one sorted variable.
164
+
165
+ The many-sorted counterpart of :class:`Count`: ``∃≥n x:S φ`` is true iff at least
166
+ ``n`` DISTINCT elements OF SORT ``S`` satisfy ``φ`` (``∃≤n`` at most, ``∃=n`` exactly).
167
+ ``op`` is ``"ge"`` / ``"le"`` / ``"eq"``; ``n`` is a non-negative-integer :class:`Number`
168
+ kept SYMBOLIC (never expanded); ``variable`` is the bound counting variable; ``sort`` the
169
+ sort name it ranges over; ``formula`` its matrix. Reduction to plain FOL relativises the
170
+ matrix with the sort guard and reuses the distinct-witnesses encoding of :class:`Count`
171
+ (see :meth:`_relativize`); :meth:`to_z3` / :meth:`to_prover9` / :meth:`to_tptp` auto-reduce.
172
+ """
173
+
174
+ op: str
175
+ n: Number
176
+ variable: Variable
177
+ sort: str
178
+ formula: Node
179
+
180
+ def __post_init__(self):
181
+ """Validate the op code and that n is a non-negative integer Number."""
182
+ if self.op not in _COUNT_OPS:
183
+ raise ValueError(
184
+ f"SortedCount: unknown op {self.op!r}; expected one of 'ge', 'le', 'eq'.")
185
+ if not (isinstance(self.n, Number) and isinstance(self.n.value, int)
186
+ and self.n.value >= 0):
187
+ raise ValueError(
188
+ "SortedCount: n must be a Number wrapping a non-negative integer.")
189
+
190
+ def _tree_parts(self):
191
+ """Return the ∃≥n x:S label (with the sorted bound variable) and the matrix."""
192
+ return (f"{_COUNT_OPS[self.op]}{self.n.value} {self.variable.name}:{self.sort}",
193
+ [self.formula])
194
+
195
+ def to_dict(self):
196
+ """Serialise to dict with op, n, sorted bound variable, and serialised matrix."""
197
+ return {"_type": "SortedCount", "op": self.op, "n": self.n.to_dict(),
198
+ "variable": self.variable.to_dict(), "sort": self.sort,
199
+ "formula": self.formula.to_dict()}
200
+
201
+ @staticmethod
202
+ def from_dict(d):
203
+ """Deserialise a SortedCount from a dict produced by to_dict."""
204
+ return SortedCount(d["op"], Node.from_dict(d["n"]),
205
+ Node.from_dict(d["variable"]), d["sort"],
206
+ Node.from_dict(d["formula"]))
207
+
208
+ def to_msfol(self) -> "Node":
209
+ """Reduce the matrix to classical FOL; keep the sorted counting binder."""
210
+ return SortedCount(self.op, self.n, self.variable, self.sort,
211
+ self.formula.to_msfol())
212
+
213
+ def _relativize(self, facts: list) -> "Node":
214
+ """Guard the matrix with the sort predicate, then delegate to unsorted Count.
215
+
216
+ ``∃≥n x:S φ`` ≡ '≥ n distinct x with (S(x) ∧ φ)'. Guarding the matrix and reusing
217
+ :class:`Count`'s encoding is correct for EVERY op, because Count's ≤/= readings are
218
+ themselves defined via 'at least' over the (now sort-guarded) matrix.
219
+ """
220
+ guarded = And(Atom(self.sort, (self.variable,)), self.formula._relativize(facts))
221
+ return Count(self.op, self.n, self.variable, guarded)
222
+
223
+ def to_z3(self, env: Optional[Z3Env] = None):
224
+ """Auto-reduce to FOL (sort-guarded Count), then translate to Z3."""
225
+ _logger.info("Auto-reducing %s to FOL for Z3 export.", type(self).__name__)
226
+ return to_fol(self).to_z3(env)
227
+
228
+ @_prover9_outermost
229
+ def to_prover9(self) -> str:
230
+ """Auto-reduce to FOL (sort-guarded Count), then render Prover9 syntax."""
231
+ _logger.info("Auto-reducing %s to FOL for Prover9 export.", type(self).__name__)
232
+ return to_fol(self).to_prover9()
233
+
234
+ def to_tptp(self) -> str:
235
+ """Auto-reduce to FOL (sort-guarded Count), then render TPTP syntax."""
236
+ _logger.info("Auto-reducing %s to FOL for TPTP export.", type(self).__name__)
237
+ return to_fol(self).to_tptp()
238
+
239
+
240
+ # Shared rejection message: a sorted set-cardinality term is not first-order definable.
241
+ _NO_SORTED_CARDINALITY_EXPORT = (
242
+ "SortedCardinality terms (|{v:S : φ}|) denote set cardinality, a second-order notion "
243
+ "with no first-order counterpart. Keep the term at the AST level, or express a "
244
+ "fixed-bound sorted count with the SortedCount quantifier (∃≥n / ∃≤n / ∃=n x:S)."
245
+ )
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class SortedCardinality(Node):
250
+ """A sort-restricted set-cardinality term ``|{v:S : φ}|``: how many
251
+ elements of sort ``S`` satisfy ``φ``.
252
+
253
+ The many-sorted counterpart of :class:`Cardinality`. It BINDS ``variable`` (of sort
254
+ ``sort``) over the matrix ``formula``. Set cardinality is genuinely second-order, so it
255
+ has no first-order export: :meth:`to_z3` / :meth:`to_prover9` / :meth:`to_tptp` reject.
256
+ """
257
+
258
+ variable: Variable
259
+ sort: str
260
+ formula: Node
261
+
262
+ def _tree_parts(self):
263
+ """Return the |v:S| cardinality label (with the sorted bound variable) and the matrix."""
264
+ return f"|{self.variable.name}:{self.sort}|", [self.formula]
265
+
266
+ def to_dict(self):
267
+ """Serialise to dict with the sorted bound variable and serialised matrix."""
268
+ return {"_type": "SortedCardinality", "variable": self.variable.to_dict(),
269
+ "sort": self.sort, "formula": self.formula.to_dict()}
270
+
271
+ @staticmethod
272
+ def from_dict(d):
273
+ """Deserialise a SortedCardinality from a dict produced by to_dict."""
274
+ return SortedCardinality(Node.from_dict(d["variable"]), d["sort"],
275
+ Node.from_dict(d["formula"]))
276
+
277
+ def to_msfol(self) -> "Node":
278
+ """Reduce the matrix to classical FOL; keep the sorted cardinality binder."""
279
+ return SortedCardinality(self.variable, self.sort, self.formula.to_msfol())
280
+
281
+ def _relativize(self, facts: list) -> "Node":
282
+ """Guard the matrix with the sort predicate and fall back to unsorted Cardinality.
283
+
284
+ ``|{v:S : φ}|`` = ``|{v : S(v) ∧ φ}|``. The result is still export-free (Cardinality
285
+ rejects), but the reduction keeps the term semantically faithful under to_fol.
286
+ """
287
+ guarded = And(Atom(self.sort, (self.variable,)), self.formula._relativize(facts))
288
+ return Cardinality(self.variable, guarded)
289
+
290
+ def to_z3(self, env: Optional[Z3Env] = None):
291
+ """Reject Z3 export: sorted set cardinality has no first-order counterpart."""
292
+ raise NotImplementedError(_NO_SORTED_CARDINALITY_EXPORT)
293
+
294
+ def to_prover9(self) -> str:
295
+ """Reject Prover9 export: sorted set cardinality has no first-order counterpart."""
296
+ raise NotImplementedError(_NO_SORTED_CARDINALITY_EXPORT)
297
+
298
+ def to_tptp(self) -> str:
299
+ """Reject TPTP export: sorted set cardinality has no first-order counterpart."""
300
+ raise NotImplementedError(_NO_SORTED_CARDINALITY_EXPORT)
301
+
302
+
303
+ @dataclass(frozen=True)
304
+ class WeakConjunction(Node):
305
+ """Łukasiewicz weak conjunction (fuzzy min): min{x, y}.
306
+
307
+ Uses the same glyph ∧ as classical And; distinct by class identity.
308
+ """
309
+
310
+ left: Node
311
+ right: Node
312
+
313
+ def _tree_parts(self):
314
+ return "∧", [self.left, self.right]
315
+
316
+ def to_dict(self):
317
+ return {"_type": "WeakConjunction", "left": self.left.to_dict(), "right": self.right.to_dict()}
318
+
319
+ @staticmethod
320
+ def from_dict(d):
321
+ return WeakConjunction(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
322
+
323
+ def to_msfol(self) -> "Node":
324
+ return And(self.left.to_msfol(), self.right.to_msfol())
325
+
326
+ def _relativize(self, facts: list) -> "Node":
327
+ raise RuntimeError("WeakConjunction._relativize called; call to_msfol() before _relativize.")
328
+
329
+ def to_z3(self, env: Optional[Z3Env] = None):
330
+ """Reject Z3 export: the classical collapse must be explicit (to_fol first)."""
331
+ raise NotImplementedError(f"to_z3: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
332
+
333
+ def to_prover9(self) -> str:
334
+ """Reject Prover9 export: the classical collapse must be explicit (to_fol first)."""
335
+ raise NotImplementedError(f"to_prover9: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
336
+
337
+ def to_tptp(self) -> str:
338
+ """Reject TPTP export: the classical collapse must be explicit (to_fol first)."""
339
+ raise NotImplementedError(f"to_tptp: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
340
+
341
+
342
+ @dataclass(frozen=True)
343
+ class WeakDisjunction(Node):
344
+ """Łukasiewicz weak disjunction (fuzzy max): max{x, y}.
345
+
346
+ Uses the same glyph ∨ as classical Or; distinct by class identity.
347
+ """
348
+
349
+ left: Node
350
+ right: Node
351
+
352
+ def _tree_parts(self):
353
+ return "∨", [self.left, self.right]
354
+
355
+ def to_dict(self):
356
+ return {"_type": "WeakDisjunction", "left": self.left.to_dict(), "right": self.right.to_dict()}
357
+
358
+ @staticmethod
359
+ def from_dict(d):
360
+ return WeakDisjunction(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
361
+
362
+ def to_msfol(self) -> "Node":
363
+ return Or(self.left.to_msfol(), self.right.to_msfol())
364
+
365
+ def _relativize(self, facts: list) -> "Node":
366
+ raise RuntimeError("WeakDisjunction._relativize called; call to_msfol() before _relativize.")
367
+
368
+ def to_z3(self, env: Optional[Z3Env] = None):
369
+ """Reject Z3 export: the classical collapse must be explicit (to_fol first)."""
370
+ raise NotImplementedError(f"to_z3: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
371
+
372
+ def to_prover9(self) -> str:
373
+ """Reject Prover9 export: the classical collapse must be explicit (to_fol first)."""
374
+ raise NotImplementedError(f"to_prover9: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
375
+
376
+ def to_tptp(self) -> str:
377
+ """Reject TPTP export: the classical collapse must be explicit (to_fol first)."""
378
+ raise NotImplementedError(f"to_tptp: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
379
+
380
+
381
+ @dataclass(frozen=True)
382
+ class StrongConjunction(Node):
383
+ """Łukasiewicz strong conjunction (t-norm): max{0, x+y−1}."""
384
+
385
+ left: Node
386
+ right: Node
387
+
388
+ def _tree_parts(self):
389
+ return "⊗", [self.left, self.right]
390
+
391
+ def to_dict(self):
392
+ return {"_type": "StrongConjunction", "left": self.left.to_dict(), "right": self.right.to_dict()}
393
+
394
+ @staticmethod
395
+ def from_dict(d):
396
+ return StrongConjunction(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
397
+
398
+ def to_msfol(self) -> "Node":
399
+ return And(self.left.to_msfol(), self.right.to_msfol())
400
+
401
+ def _relativize(self, facts: list) -> "Node":
402
+ raise RuntimeError("StrongConjunction._relativize called; call to_msfol() before _relativize.")
403
+
404
+ def to_z3(self, env: Optional[Z3Env] = None):
405
+ """Reject Z3 export: the classical collapse must be explicit (to_fol first)."""
406
+ raise NotImplementedError(f"to_z3: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
407
+
408
+ def to_prover9(self) -> str:
409
+ """Reject Prover9 export: the classical collapse must be explicit (to_fol first)."""
410
+ raise NotImplementedError(f"to_prover9: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
411
+
412
+ def to_tptp(self) -> str:
413
+ """Reject TPTP export: the classical collapse must be explicit (to_fol first)."""
414
+ raise NotImplementedError(f"to_tptp: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
415
+
416
+
417
+ @dataclass(frozen=True)
418
+ class StrongDisjunction(Node):
419
+ """Łukasiewicz strong disjunction (t-conorm): min{1, x+y}."""
420
+
421
+ left: Node
422
+ right: Node
423
+
424
+ def _tree_parts(self):
425
+ return "⊕", [self.left, self.right]
426
+
427
+ def to_dict(self):
428
+ return {"_type": "StrongDisjunction", "left": self.left.to_dict(), "right": self.right.to_dict()}
429
+
430
+ @staticmethod
431
+ def from_dict(d):
432
+ return StrongDisjunction(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
433
+
434
+ def to_msfol(self) -> "Node":
435
+ return Or(self.left.to_msfol(), self.right.to_msfol())
436
+
437
+ def _relativize(self, facts: list) -> "Node":
438
+ raise RuntimeError("StrongDisjunction._relativize called; call to_msfol() before _relativize.")
439
+
440
+ def to_z3(self, env: Optional[Z3Env] = None):
441
+ """Reject Z3 export: the classical collapse must be explicit (to_fol first)."""
442
+ raise NotImplementedError(f"to_z3: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
443
+
444
+ def to_prover9(self) -> str:
445
+ """Reject Prover9 export: the classical collapse must be explicit (to_fol first)."""
446
+ raise NotImplementedError(f"to_prover9: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
447
+
448
+ def to_tptp(self) -> str:
449
+ """Reject TPTP export: the classical collapse must be explicit (to_fol first)."""
450
+ raise NotImplementedError(f"to_tptp: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
451
+
452
+
453
+ @dataclass(frozen=True)
454
+ class LukNegation(Node):
455
+ """Łukasiewicz negation: 1−x.
456
+
457
+ Uses the same glyph ¬ as classical Not; distinct by class identity.
458
+ """
459
+
460
+ formula: Node
461
+
462
+ def _tree_parts(self):
463
+ return "¬", [self.formula]
464
+
465
+ def to_dict(self):
466
+ return {"_type": "LukNegation", "formula": self.formula.to_dict()}
467
+
468
+ @staticmethod
469
+ def from_dict(d):
470
+ return LukNegation(Node.from_dict(d["formula"]))
471
+
472
+ def to_msfol(self) -> "Node":
473
+ return Not(self.formula.to_msfol())
474
+
475
+ def _relativize(self, facts: list) -> "Node":
476
+ raise RuntimeError("LukNegation._relativize called; call to_msfol() before _relativize.")
477
+
478
+ def to_z3(self, env: Optional[Z3Env] = None):
479
+ """Reject Z3 export: the classical collapse must be explicit (to_fol first)."""
480
+ raise NotImplementedError(f"to_z3: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
481
+
482
+ def to_prover9(self) -> str:
483
+ """Reject Prover9 export: the classical collapse must be explicit (to_fol first)."""
484
+ raise NotImplementedError(f"to_prover9: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
485
+
486
+ def to_tptp(self) -> str:
487
+ """Reject TPTP export: the classical collapse must be explicit (to_fol first)."""
488
+ raise NotImplementedError(f"to_tptp: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
489
+
490
+
491
+ @dataclass(frozen=True)
492
+ class LukImplication(Node):
493
+ """Łukasiewicz implication: min{1, 1−x+y}.
494
+
495
+ Uses the same glyph → as classical Implies; distinct by class identity.
496
+ """
497
+
498
+ left: Node
499
+ right: Node
500
+
501
+ def _tree_parts(self):
502
+ return "→", [self.left, self.right]
503
+
504
+ def to_dict(self):
505
+ return {"_type": "LukImplication", "left": self.left.to_dict(), "right": self.right.to_dict()}
506
+
507
+ @staticmethod
508
+ def from_dict(d):
509
+ return LukImplication(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
510
+
511
+ def to_msfol(self) -> "Node":
512
+ return Implies(self.left.to_msfol(), self.right.to_msfol())
513
+
514
+ def _relativize(self, facts: list) -> "Node":
515
+ raise RuntimeError("LukImplication._relativize called; call to_msfol() before _relativize.")
516
+
517
+ def to_z3(self, env: Optional[Z3Env] = None):
518
+ """Reject Z3 export: the classical collapse must be explicit (to_fol first)."""
519
+ raise NotImplementedError(f"to_z3: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
520
+
521
+ def to_prover9(self) -> str:
522
+ """Reject Prover9 export: the classical collapse must be explicit (to_fol first)."""
523
+ raise NotImplementedError(f"to_prover9: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
524
+
525
+ def to_tptp(self) -> str:
526
+ """Reject TPTP export: the classical collapse must be explicit (to_fol first)."""
527
+ raise NotImplementedError(f"to_tptp: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
528
+
529
+
530
+ @dataclass(frozen=True)
531
+ class LukEquivalence(Node):
532
+ """Łukasiewicz equivalence: 1−|x−y|.
533
+
534
+ Uses the same glyph ↔ as classical Iff; distinct by class identity.
535
+ """
536
+
537
+ left: Node
538
+ right: Node
539
+
540
+ def _tree_parts(self):
541
+ return "↔", [self.left, self.right]
542
+
543
+ def to_dict(self):
544
+ return {"_type": "LukEquivalence", "left": self.left.to_dict(), "right": self.right.to_dict()}
545
+
546
+ @staticmethod
547
+ def from_dict(d):
548
+ return LukEquivalence(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
549
+
550
+ def to_msfol(self) -> "Node":
551
+ return Iff(self.left.to_msfol(), self.right.to_msfol())
552
+
553
+ def _relativize(self, facts: list) -> "Node":
554
+ raise RuntimeError("LukEquivalence._relativize called; call to_msfol() before _relativize.")
555
+
556
+ def to_z3(self, env: Optional[Z3Env] = None):
557
+ """Reject Z3 export: the classical collapse must be explicit (to_fol first)."""
558
+ raise NotImplementedError(f"to_z3: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
559
+
560
+ def to_prover9(self) -> str:
561
+ """Reject Prover9 export: the classical collapse must be explicit (to_fol first)."""
562
+ raise NotImplementedError(f"to_prover9: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
563
+
564
+ def to_tptp(self) -> str:
565
+ """Reject TPTP export: the classical collapse must be explicit (to_fol first)."""
566
+ raise NotImplementedError(f"to_tptp: {type(self).__name__} — " + _LUK_NO_CLASSICAL_EXPORT)
567
+
568
+
569
+ # =========================
570
+ # Operator registration
571
+ # =========================
572
+ #
573
+ # Self-register the Łukasiewicz operators with the central renderers. Each uses
574
+ # the same glyph/markup as its classical counterpart but is distinct by class
575
+ # identity (e.g. LukNegation renders ¬ like Not but lowers differently).
576
+
577
+ register_operator(LukNegation, "prefix", "¬", "\\lnot ", 4)
578
+ register_operator(WeakConjunction, "level2", "∧", "\\land", 3)
579
+ register_operator(WeakDisjunction, "level2", "∨", "\\lor", 3)
580
+ register_operator(StrongConjunction, "level2", "⊗", "\\otimes", 3)
581
+ register_operator(StrongDisjunction, "level2", "⊕", "\\oplus", 3)
582
+ register_operator(LukImplication, "binary_implies", "→", "\\rightarrow", 2)
583
+ register_operator(LukEquivalence, "binary_iff", "↔", "\\leftrightarrow", 1)
584
+
585
+
586
+ # =========================
587
+ # Parser registration (MSFL / FL Łukasiewicz + sorted quantifier/constant)
588
+ # =========================
589
+ #
590
+ # The Łukasiewicz connectives appear in both MSFL (sorted) and FL (unsorted)
591
+ # modes with identical grammar fragments; they register once per mode. The
592
+ # transforms mirror LukConnectivesMixin exactly. The sorted quantifier registers
593
+ # for MSFOL and MSFL (both sorted), the unsorted Łukasiewicz quantifier for FL,
594
+ # and sorted-constant handling rides on the SORTED term-layer flag (selected by
595
+ # build_grammar for msfol/msfl) plus the sorted_const_ transform here.
596
+
597
+
598
+ def _sorted_quantifier_transform(items):
599
+ """Build a SortedQuantifier from [FORALL/EXISTS, Variable, SORT, body]."""
600
+ quant_tok, var, sort_tok, formula = items
601
+ sort = str(sort_tok)[1:] # strip leading ':'
602
+ return SortedQuantifier(str(quant_tok), var, sort, formula)
603
+
604
+
605
+ def _sorted_const_transform(items):
606
+ """Build a SortedConstant from [NAME/CONSTANT/QUOTED_NAME, SORT].
607
+
608
+ NAME and QUOTED_NAME are converted to a Constant by their token handlers (the
609
+ quoted one with its escapes undone), CONSTANT arrives as a raw token; either way
610
+ the name is the constant's own.
611
+ """
612
+ first, sort_tok = items
613
+ name = first.name if isinstance(first, Constant) else str(first)
614
+ sort = str(sort_tok)[1:] # strip leading ':'
615
+ return SortedConstant(name, sort)
616
+
617
+
618
+ def _luk_quantifier_transform(items):
619
+ """Build an unsorted Quantifier (FL mode) from [FORALL/EXISTS, Variable, body]."""
620
+ return Quantifier(str(items[0]), items[1], items[2])
621
+
622
+
623
+ # --- prefix: ¬ (LukNegation) in MSFL and FL ---
624
+ for _m in ("msfl", "fl"):
625
+ register_parser_op(LukNegation, _m, "prefix", "luk_not_", '"¬" prefix',
626
+ lambda items: LukNegation(items[0]))
627
+
628
+ # --- level2: ∧ ∨ ⊗ ⊕ (weak/strong) in MSFL and FL ---
629
+ for _m in ("msfl", "fl"):
630
+ register_parser_op(WeakConjunction, _m, "level2", "weak_and_", '"∧"',
631
+ lambda items: _fold_binary(items, WeakConjunction),
632
+ only_name="only_weak_and")
633
+ register_parser_op(WeakDisjunction, _m, "level2", "weak_or_", '"∨"',
634
+ lambda items: _fold_binary(items, WeakDisjunction),
635
+ only_name="only_weak_or")
636
+ register_parser_op(StrongConjunction, _m, "level2", "strong_and_", '"⊗"',
637
+ lambda items: _fold_binary(items, StrongConjunction),
638
+ only_name="only_strong_and")
639
+ register_parser_op(StrongDisjunction, _m, "level2", "strong_or_", '"⊕"',
640
+ lambda items: _fold_binary(items, StrongDisjunction),
641
+ only_name="only_strong_or")
642
+
643
+ # --- implication: → (LukImplication) in MSFL and FL ---
644
+ # Binary levels store just the glyph; build_grammar assembles the right-assoc rule.
645
+ for _m in ("msfl", "fl"):
646
+ register_parser_op(LukImplication, _m, "implication", "luk_implies_", '"→"',
647
+ lambda items: LukImplication(items[0], items[1]))
648
+
649
+ # --- biimplication: ↔ (LukEquivalence) in MSFL and FL ---
650
+ for _m in ("msfl", "fl"):
651
+ register_parser_op(LukEquivalence, _m, "biimplication", "luk_iff_", '"↔"',
652
+ lambda items: LukEquivalence(items[0], items[1]))
653
+
654
+ # --- quantifier: sorted ∀x:S / ∃x:S (MSFOL, MSFL); unsorted (FL) ---
655
+ register_parser_op(SortedQuantifier, "msfol", "quantifier", "sorted_quantifier_",
656
+ "(FORALL | EXISTS) VARIABLE SORT prefix", _sorted_quantifier_transform)
657
+ register_parser_op(SortedQuantifier, "msfl", "quantifier", "sorted_quantifier_",
658
+ "(FORALL | EXISTS) VARIABLE SORT prefix", _sorted_quantifier_transform)
659
+ register_parser_op(Quantifier, "fl", "quantifier", "quantifier_",
660
+ "(FORALL | EXISTS) VARIABLE prefix", _luk_quantifier_transform)
661
+
662
+ # --- sorted constant transform (term layer, MSFOL + MSFL via the SORTED flag) ---
663
+ # build_grammar emits the NAME SORT / CONSTANT SORT / QUOTED_NAME SORT ->
664
+ # sorted_const_ rules for
665
+ # sorted modes; the transform is registered as a non-grammar-contributing handler
666
+ # so it is attached to the assembled Transformer for those modes.
667
+ for _m in ("msfol", "msfl"):
668
+ register_parser_op(SortedConstant, _m, "quantifier", "sorted_const_",
669
+ "", _sorted_const_transform)
670
+
671
+
672
+ def _sorted_count_transform(items):
673
+ """Build a SortedCount from [COUNTOP glyph, NUMBER, Variable, SORT, body]."""
674
+ op = _COUNT_TOKEN_TO_OP[str(items[0])]
675
+ n = _count_bound(items[0], items[1]) # a CountBoundError (ParsingError) if not a count
676
+ sort = str(items[3])[1:] # strip leading ':'
677
+ return SortedCount(op, Number(n), items[2], sort, items[4])
678
+
679
+
680
+ def _sorted_cardinality_transform(items):
681
+ """Build a SortedCardinality from [VARIABLE, SORT, formula]."""
682
+ var, sort_tok, formula = items
683
+ return SortedCardinality(var, str(sort_tok)[1:], formula) # strip leading ':'
684
+
685
+
686
+ # --- sorted counting quantifier: ∃≥n / ∃≤n / ∃=n x:S (SortedCount), MSFOL only ---
687
+ # The classical unsorted Count lives in fol/modal/second-order; MSFOL, where every
688
+ # binder is sorted, gets the sort-annotated counting quantifier instead. COUNTOP is
689
+ # the shared priority-5 terminal (∃ followed by ≥/≤/=).
690
+ register_parser_op(SortedCount, "msfol", "quantifier", "sorted_count_",
691
+ "COUNTOP NUMBER VARIABLE SORT prefix", _sorted_count_transform,
692
+ terminal_name="COUNTOP", terminal_def="COUNTOP.5: /∃[≥≤=]/")
693
+
694
+ # --- sorted set-cardinality term: |{v:S : φ}| (SortedCardinality), MSFOL only ---
695
+ # Handler-only op: the grammar alternative is emitted by the SORTED term-extra in
696
+ # build_grammar (like sorted_const_); the transform is attached to the Transformer.
697
+ register_parser_op(SortedCardinality, "msfol", "quantifier", "sorted_cardinality_",
698
+ "", _sorted_cardinality_transform)
699
+
700
+
701
+ # =========================
702
+ # Lambda-Calculus Nodes
703
+ # =========================
704
+
705
+ @dataclass(frozen=True)
706
+ class LambdaVar(Node):
707
+ """A lambda-bound variable, distinct from logical Variable.
708
+
709
+ Kept separate so lambda binding and logical binding never get confused.
710
+ """
711
+
712
+ name: str
713
+
714
+ def _tree_parts(self):
715
+ return f"LambdaVar: {self.name}", []
716
+
717
+ def to_dict(self):
718
+ return {"_type": "LambdaVar", "name": self.name}
719
+
720
+ @staticmethod
721
+ def from_dict(d):
722
+ return LambdaVar(d["name"])
723
+
724
+ def to_msfol(self) -> "Node":
725
+ raise NotImplementedError("Beta-reduce lambda terms before the MSFL export pipeline.")
726
+
727
+ def _relativize(self, facts: list) -> "Node":
728
+ raise NotImplementedError("Beta-reduce lambda terms before the MSFL export pipeline.")
729
+
730
+ def to_z3(self, env: Optional[Z3Env] = None):
731
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
732
+
733
+ def to_prover9(self) -> str:
734
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
735
+
736
+ def to_tptp(self) -> str:
737
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
738
+
739
+
740
+ @dataclass(frozen=True)
741
+ class Lambda(Node):
742
+ """A lambda abstraction λparam. body.
743
+
744
+ param is a LambdaVar object, mirroring how Quantifier holds a Variable object.
745
+ """
746
+
747
+ param: LambdaVar
748
+ body: Node
749
+
750
+ def _tree_parts(self):
751
+ return f"λ {self.param.name}", [self.body]
752
+
753
+ def to_dict(self):
754
+ return {"_type": "Lambda", "param": self.param.to_dict(), "body": self.body.to_dict()}
755
+
756
+ @staticmethod
757
+ def from_dict(d):
758
+ return Lambda(LambdaVar.from_dict(d["param"]), Node.from_dict(d["body"]))
759
+
760
+ def to_msfol(self) -> "Node":
761
+ raise NotImplementedError("Beta-reduce lambda terms before the MSFL export pipeline.")
762
+
763
+ def _relativize(self, facts: list) -> "Node":
764
+ raise NotImplementedError("Beta-reduce lambda terms before the MSFL export pipeline.")
765
+
766
+ def to_z3(self, env: Optional[Z3Env] = None):
767
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
768
+
769
+ def to_prover9(self) -> str:
770
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
771
+
772
+ def to_tptp(self) -> str:
773
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
774
+
775
+
776
+ @dataclass(frozen=True)
777
+ class Application(Node):
778
+ """A lambda application func(arg)."""
779
+
780
+ func: Node
781
+ arg: Node
782
+
783
+ def _tree_parts(self):
784
+ return "App", [self.func, self.arg]
785
+
786
+ def to_dict(self):
787
+ return {"_type": "Application", "func": self.func.to_dict(), "arg": self.arg.to_dict()}
788
+
789
+ @staticmethod
790
+ def from_dict(d):
791
+ return Application(Node.from_dict(d["func"]), Node.from_dict(d["arg"]))
792
+
793
+ def to_msfol(self) -> "Node":
794
+ raise NotImplementedError("Beta-reduce lambda terms before the MSFL export pipeline.")
795
+
796
+ def _relativize(self, facts: list) -> "Node":
797
+ raise NotImplementedError("Beta-reduce lambda terms before the MSFL export pipeline.")
798
+
799
+ def to_z3(self, env: Optional[Z3Env] = None):
800
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
801
+
802
+ def to_prover9(self) -> str:
803
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
804
+
805
+ def to_tptp(self) -> str:
806
+ raise NotImplementedError("Lambda terms must be beta-reduced and lambda-eliminated before export.")
807
+
808
+
809
+ # =========================
810
+ # Free-variable computation
811
+ # =========================
812
+
813
+ def free_variables(node: Node) -> set:
814
+ """Return the set of free Variable and LambdaVar occurrences in node.
815
+
816
+ The returned set is mixed: it may contain Variable objects (bound by
817
+ Quantifier / SortedQuantifier) and LambdaVar objects (bound by Lambda).
818
+ The two kinds are kept distinct so that a lambda binder over LambdaVar("x")
819
+ never accidentally removes a logical Variable("x") from the free set.
820
+ """
821
+ if isinstance(node, (Variable, LambdaVar)):
822
+ return {node}
823
+ if isinstance(node, (Constant, Number, SortedConstant)):
824
+ return set()
825
+ if isinstance(node, Lambda):
826
+ return free_variables(node.body) - {node.param}
827
+ if isinstance(node, SlashedExists):
828
+ # The slash set is not decoration: each name is a free OCCURRENCE of the
829
+ # variable it references (the team column the witness must be independent
830
+ # of), so it belongs in the free set alongside the matrix's variables. A
831
+ # slash name is free unless the SlashedExists' own binder captures it (it
832
+ # cannot — a variable never independent of itself is meaningless, but the
833
+ # subtraction below keeps the rule uniform). Omitting them would let the
834
+ # capture-avoidance machinery (canonicalize / _subst) mint a fresh name
835
+ # equal to a free slash name and silently capture it.
836
+ slashed = {Variable(n) for n in node.slashed}
837
+ return (free_variables(node.formula) | slashed) - {node.variable}
838
+ if isinstance(node, (Quantifier, SortedQuantifier, Count, Cardinality,
839
+ SortedCount, SortedCardinality)):
840
+ # Count / Cardinality (and their sorted variants) also bind their variable
841
+ # over the matrix; Count's n is a Number (no free variables) and the sort is
842
+ # a plain string, so the binder rule is the same.
843
+ return free_variables(node.formula) - {node.variable}
844
+ if not is_dataclass(node):
845
+ raise TypeError(f"free_variables: unknown node type {type(node).__name__}")
846
+ # Structural: for any non-binder, non-leaf node the free variables are the
847
+ # union of its children's. Covers Atom, Function, Application, Not, and every
848
+ # binary connective uniformly — and any future structural node type.
849
+ result: set = set()
850
+ for child in node._child_nodes():
851
+ result |= free_variables(child)
852
+ return result
853
+
854
+
855
+ # =========================
856
+ # Capture-avoiding beta-reduction
857
+ # =========================
858
+
859
+ BETA_REDUCTION_LIMIT = 10_000
860
+
861
+
862
+ class ReductionLimitError(Exception):
863
+ """Raised when a lambda reduction exceeds its limit.
864
+
865
+ ``beta_reduce`` raises it after ``BETA_REDUCTION_LIMIT`` (10 000) steps, and
866
+ ``beta_eta_normalize`` after ``BETA_ETA_ROUND_LIMIT`` (100) alternation
867
+ rounds — both signalling a term that is (likely) not strongly normalizing.
868
+ """
869
+ pass
870
+
871
+
872
+ def _names_in(node: Node) -> set:
873
+ """Return all Variable and LambdaVar nodes appearing in node (free and bound)."""
874
+ if isinstance(node, (Variable, LambdaVar)):
875
+ return {node}
876
+ if isinstance(node, (Constant, Number, SortedConstant)):
877
+ return set()
878
+ # Structural union over children. A binder's bound variable is itself a Node
879
+ # child (Lambda.param, Quantifier.variable), so it is included here — exactly
880
+ # what "names in, free and bound" requires.
881
+ result: set = set()
882
+ if isinstance(node, SlashedExists):
883
+ # Slash names are variable references but plain strings, so _child_nodes()
884
+ # misses them; add them so a fresh-name mint never collides with one.
885
+ result |= {Variable(n) for n in node.slashed}
886
+ for child in node._child_nodes():
887
+ result |= _names_in(child)
888
+ return result
889
+
890
+
891
+ def subst_slash_set(term: "SlashedExists", target: Node, replacement: Node) -> Node:
892
+ """Rewrite a slashed existential's SLASH SET under ``target := replacement``.
893
+
894
+ The slash set names variables (team columns) bound by ENCLOSING quantifiers, so a
895
+ mention of ``target`` there is rewritten *before* the binder's own shadowing check
896
+ — unlike the matrix, the annotation is not shadowed by ``term.variable``.
897
+ Substituting a slash entry transforms the annotation by what replaces it:
898
+
899
+ * another **Variable** ``z`` → rename the entry ``x ↦ z``; independence is now
900
+ from column ``z``. The constraint is preserved, never dropped.
901
+ * a **ground or compound term** → that column no longer exists, so "independent
902
+ of ``x``" is vacuous and the entry is dropped. Since
903
+ :class:`~unicode_logic_kit.fol._team_nodes.SlashedExists` requires a non-empty
904
+ slash set, an emptied one degrades to a plain existential — so the return type
905
+ is a general :class:`Node`, not necessarily a ``SlashedExists``.
906
+
907
+ The matrix is left untouched; callers substitute into it themselves.
908
+ """
909
+ slashed = term.slashed
910
+ if not (isinstance(target, Variable) and target.name in slashed):
911
+ return term
912
+ if isinstance(replacement, Variable):
913
+ return replace(term, slashed=tuple(replacement.name if n == target.name else n
914
+ for n in slashed))
915
+ slashed = tuple(n for n in slashed if n != target.name)
916
+ if not slashed:
917
+ return Quantifier("∃", term.variable, term.formula)
918
+ return replace(term, slashed=slashed)
919
+
920
+
921
+ def _fresh_name(base: str, avoid: set) -> str:
922
+ """A fresh object-variable name for the binder ``base`` that is being alpha-renamed.
923
+
924
+ ``avoid`` is a set of ``Variable`` / ``LambdaVar`` nodes and the result differs
925
+ from the name of every one of them. It is also a name the kit's own parser
926
+ reads back: one letter and digits (``y`` becomes ``y0``, then ``y1``, …).
927
+ The earlier ``y_0`` was printed by ``to_unicode_str`` and rejected by every
928
+ parser mode, because VARIABLE takes no underscore. Deterministic: the first
929
+ free ``letter`` + N, N = 0, 1, …
930
+
931
+ A name is only as safe as the set it is checked against: the caller passes
932
+ the replacement's free variables AND every name inside the scope being
933
+ renamed, bound ones included, or the renamed occurrences are captured by an
934
+ inner binder that already used the new name.
935
+ """
936
+ return fresh_variable_like(base, {n.name for n in avoid})
937
+
938
+
939
+ def _binder_avoid(binder: Node, body: Node, target: Node, replacement: Node,
940
+ fv_repl: set, slashed: Tuple[str, ...] = ()) -> frozenset:
941
+ """Every name a binder that is renamed during ``body[target := replacement]`` must avoid.
942
+
943
+ ``binder`` is the bound variable being given a new name and ``body`` its scope. The new
944
+ name is checked, as a string, against
945
+
946
+ * every name the scope and the replacement carry, of every kind: a constant, a function,
947
+ a predicate or a sort spelled like the new binder would print as the very same word,
948
+ and a constant of the replacement that ends up inside the scope is one of them;
949
+ * the free variables of the replacement: the binder is renamed because of them;
950
+ * the TARGET: the renamed scope is substituted next, and a binder renamed onto the
951
+ target's own spelling would have its occurrences replaced by the replacement (``∀x1 P(x1)``
952
+ under ``x0 := x1`` would come out as ``∀x0 P(x1)``, with a free ``x1`` the input
953
+ does not have);
954
+ * the old binder itself, and the slash names of a slashed existential, which refer to
955
+ enclosing variables and are plain strings no walk of the scope would find.
956
+
957
+ The one place this set is built, so the generic substitution and the proof searches'
958
+ substitution (:func:`unicode_logic_kit.atp.fitch._subst_var`) mint the same name: a checker
959
+ that recomputes an instance compares it with the searched one structurally.
960
+ """
961
+ names = set(symbol_names(body, replacement))
962
+ names.update(v.name for v in fv_repl)
963
+ names.update(slashed)
964
+ names.add(binder.name)
965
+ target_name = getattr(target, "name", None)
966
+ if target_name is not None:
967
+ names.add(target_name)
968
+ return frozenset(names)
969
+
970
+
971
+ def _fresh_binder_name(binder: Node, body: Node, target: Node, replacement: Node,
972
+ fv_repl: set, slashed: Tuple[str, ...] = ()) -> str:
973
+ """A name for the renamed object variable ``binder`` (see :func:`_binder_avoid`)."""
974
+ return fresh_variable_like(
975
+ binder.name, _binder_avoid(binder, body, target, replacement, fv_repl, slashed))
976
+
977
+
978
+ def _spelled_like_a_free_variable(binder: Node, fv_repl: set) -> bool:
979
+ """True iff ``binder`` has the name of a free variable of the replacement, of either kind.
980
+
981
+ A :class:`Variable` and a :class:`LambdaVar` of one name are two nodes, but one name in
982
+ every text the kit writes: under ``λy.`` the text ``R(y, y)`` cannot say that its first
983
+ ``y`` is the free variable that was substituted in and its second the parameter, and it
984
+ reads back with both bound. So a binder is renamed whenever the NAME meets, whichever
985
+ kind of variable it binds.
986
+ """
987
+ return any(free.name == binder.name for free in fv_repl)
988
+
989
+
990
+ def _rename(term: Node, old_var, new_var) -> Node:
991
+ """Replace all free occurrences of old_var with new_var, stopping at shadowing binders.
992
+
993
+ Caller guarantees new_var.name does not appear anywhere in term,
994
+ so no capture check is needed here.
995
+ """
996
+ if term == old_var:
997
+ return new_var
998
+ if isinstance(term, (Variable, LambdaVar, Constant, Number, SortedConstant)):
999
+ return term # leaf that does not match old_var
1000
+ if isinstance(term, Lambda):
1001
+ if term.param == old_var:
1002
+ return term # shadowed
1003
+ return Lambda(term.param, _rename(term.body, old_var, new_var))
1004
+ if isinstance(term, Quantifier):
1005
+ if term.variable == old_var:
1006
+ return term # shadowed
1007
+ return Quantifier(term.type, term.variable, _rename(term.formula, old_var, new_var))
1008
+ if isinstance(term, SortedQuantifier):
1009
+ if term.variable == old_var:
1010
+ return term # shadowed
1011
+ return SortedQuantifier(term.type, term.variable, term.sort,
1012
+ _rename(term.formula, old_var, new_var))
1013
+ if isinstance(term, (Count, Cardinality, SortedCount, SortedCardinality)):
1014
+ if term.variable == old_var:
1015
+ return term # shadowed by the counting/cardinality binder
1016
+ return replace(term, formula=_rename(term.formula, old_var, new_var))
1017
+ if isinstance(term, SlashedExists):
1018
+ # The slash set refers to ENCLOSING binders by name, so a mention of the
1019
+ # renamed variable is updated even when the matrix is shadowed.
1020
+ slashed = tuple(new_var.name if n == old_var.name else n
1021
+ for n in term.slashed)
1022
+ if term.variable == old_var:
1023
+ return replace(term, slashed=slashed) # matrix shadowed
1024
+ return replace(term, slashed=slashed,
1025
+ formula=_rename(term.formula, old_var, new_var))
1026
+ # Structural: Atom, Function, Application, Not, and the binary connectives —
1027
+ # none are binders, so recurse uniformly into every child.
1028
+ return term.map_children(lambda c: _rename(c, old_var, new_var))
1029
+
1030
+
1031
+ def _subst(term: Node, target: LambdaVar, replacement: Node, fv_repl: set) -> Node:
1032
+ """Capture-avoiding substitution of target with replacement in term.
1033
+
1034
+ fv_repl = free_variables(replacement), precomputed by the caller.
1035
+ target is a LambdaVar (beta-reduction) or a Variable (grounding a quantified
1036
+ object variable); replacement may be any Node. Substitution stops at a binder that
1037
+ rebinds the target (Lambda/Quantifier/SortedQuantifier) and alpha-renames any binder
1038
+ that would otherwise capture a free variable of replacement.
1039
+ """
1040
+ if term == target:
1041
+ return replacement
1042
+ if isinstance(term, (Variable, LambdaVar, Constant, Number, SortedConstant)):
1043
+ return term
1044
+ if isinstance(term, Lambda):
1045
+ if term.param == target:
1046
+ return term # target rebound here — substitution stops
1047
+ if _spelled_like_a_free_variable(term.param, fv_repl):
1048
+ # The binder is spelled like a free variable of the replacement (a LambdaVar it
1049
+ # would capture, or a Variable its text could not tell from itself); alpha-convert.
1050
+ # A parameter may be a variable (``λx.``), a NAME (``λfoo.``) or a predicate
1051
+ # (``λP.``), and the body uses it in that position, so the new name keeps the
1052
+ # kind: ``x`` becomes ``x0``, ``foo`` becomes ``foo_0`` and ``P`` becomes
1053
+ # ``P_0`` (see :func:`~unicode_logic_kit.fol._identifiers.fresh_like`). It avoids
1054
+ # the target as well as the scope and the replacement: the renamed body is
1055
+ # substituted next.
1056
+ fresh_param = LambdaVar(fresh_like(
1057
+ term.param.name,
1058
+ _binder_avoid(term.param, term.body, target, replacement, fv_repl)))
1059
+ new_body = _rename(term.body, term.param, fresh_param)
1060
+ return Lambda(fresh_param, _subst(new_body, target, replacement, fv_repl))
1061
+ return Lambda(term.param, _subst(term.body, target, replacement, fv_repl))
1062
+ if isinstance(term, Quantifier):
1063
+ # If the quantifier rebinds the target, the body is shadowed and substitution
1064
+ # stops here. This matters when target is a Variable (e.g. satisfies_modal
1065
+ # grounding an object quantifier); for a LambdaVar target the equality is always
1066
+ # False (distinct classes), so this is a no-op on the beta-reduction path.
1067
+ if term.variable == target:
1068
+ return term
1069
+ # Otherwise the quantifier variable may capture a free Variable from replacement.
1070
+ if _spelled_like_a_free_variable(term.variable, fv_repl):
1071
+ fresh = Variable(_fresh_binder_name(
1072
+ term.variable, term.formula, target, replacement, fv_repl))
1073
+ new_formula = _rename(term.formula, term.variable, fresh)
1074
+ return Quantifier(term.type, fresh,
1075
+ _subst(new_formula, target, replacement, fv_repl))
1076
+ return Quantifier(term.type, term.variable,
1077
+ _subst(term.formula, target, replacement, fv_repl))
1078
+ if isinstance(term, SortedQuantifier):
1079
+ if term.variable == target:
1080
+ return term # target rebound here — substitution stops
1081
+ if _spelled_like_a_free_variable(term.variable, fv_repl):
1082
+ fresh = Variable(_fresh_binder_name(
1083
+ term.variable, term.formula, target, replacement, fv_repl))
1084
+ new_formula = _rename(term.formula, term.variable, fresh)
1085
+ return SortedQuantifier(term.type, fresh, term.sort,
1086
+ _subst(new_formula, target, replacement, fv_repl))
1087
+ return SortedQuantifier(term.type, term.variable, term.sort,
1088
+ _subst(term.formula, target, replacement, fv_repl))
1089
+ if isinstance(term, SlashedExists):
1090
+ rewritten = subst_slash_set(term, target, replacement)
1091
+ if not isinstance(rewritten, SlashedExists):
1092
+ # The slash set was emptied and degraded to a plain ∃; substitute there.
1093
+ return _subst(rewritten, target, replacement, fv_repl)
1094
+ term = rewritten
1095
+ if term.variable == target:
1096
+ return term # target rebound here — substitution stops
1097
+ if _spelled_like_a_free_variable(term.variable, fv_repl):
1098
+ # The slash set names variables too (plain strings, so no walk of the
1099
+ # matrix can see them): a fresh binder called like one of them would
1100
+ # silently rewire the independence set.
1101
+ fresh = Variable(_fresh_binder_name(
1102
+ term.variable, term.formula, target, replacement, fv_repl,
1103
+ slashed=term.slashed))
1104
+ new_formula = _rename(term.formula, term.variable, fresh)
1105
+ return replace(term, variable=fresh,
1106
+ formula=_subst(new_formula, target, replacement, fv_repl))
1107
+ return replace(term, formula=_subst(term.formula, target, replacement, fv_repl))
1108
+ if isinstance(term, (Count, Cardinality, SortedCount, SortedCardinality)):
1109
+ # Count / Cardinality (and sorted variants) bind their variable over the
1110
+ # matrix — same shadowing / capture-avoidance rules as Quantifier (replace()
1111
+ # preserves Count's op/n and the sorted variants' sort).
1112
+ if term.variable == target:
1113
+ return term # target rebound here — substitution stops
1114
+ if _spelled_like_a_free_variable(term.variable, fv_repl):
1115
+ fresh = Variable(_fresh_binder_name(
1116
+ term.variable, term.formula, target, replacement, fv_repl))
1117
+ new_formula = _rename(term.formula, term.variable, fresh)
1118
+ return replace(term, variable=fresh,
1119
+ formula=_subst(new_formula, target, replacement, fv_repl))
1120
+ return replace(term, formula=_subst(term.formula, target, replacement, fv_repl))
1121
+ # Structural: Atom, Function, Application, Not, and the binary connectives.
1122
+ # The binders above handle capture avoidance; these are not binders, so just
1123
+ # substitute into every child.
1124
+ return term.map_children(lambda c: _subst(c, target, replacement, fv_repl))
1125
+
1126
+
1127
+ def substitute(term: Node, target, replacement: Node) -> Node:
1128
+ """Substitute target with replacement in term, with full capture avoidance.
1129
+
1130
+ ``target`` is a LambdaVar (beta-reduction) or a Variable (grounding a quantified
1131
+ object variable into a Constant). Returns a new Node; the input is never mutated.
1132
+ """
1133
+ return _subst(term, target, replacement, free_variables(replacement))
1134
+
1135
+
1136
+ def _beta_reduce(node: Node, steps: list) -> Node:
1137
+ # The Application case is iterative so that divergent terms (e.g. Omega) hit the step
1138
+ # counter before Python's recursion limit. All other cases recurse normally.
1139
+ while True:
1140
+ if isinstance(node, Application):
1141
+ func = _beta_reduce(node.func, steps)
1142
+ if isinstance(func, Lambda):
1143
+ steps[0] += 1
1144
+ if steps[0] > BETA_REDUCTION_LIMIT:
1145
+ raise ReductionLimitError(
1146
+ f"beta-reduction exceeded {BETA_REDUCTION_LIMIT} steps; "
1147
+ "term may not be strongly normalizing."
1148
+ )
1149
+ node = substitute(func.body, func.param, node.arg)
1150
+ continue # reduce the substituted result in the same frame
1151
+ return Application(func, _beta_reduce(node.arg, steps))
1152
+ if isinstance(node, (Variable, LambdaVar, Constant, Number, SortedConstant)):
1153
+ return node # leaves
1154
+ # Structural (Lambda, Quantifier, SortedQuantifier, Atom, Function, Not,
1155
+ # and the binary connectives): reduce inside every child. Bound-variable
1156
+ # fields are leaves, so they pass through unchanged.
1157
+ return node.map_children(lambda c: _beta_reduce(c, steps))
1158
+
1159
+
1160
+ def beta_reduce(node: Node) -> Node:
1161
+ """Reduce node to beta-normal form using normal-order strategy.
1162
+
1163
+ Raises ReductionLimitError if more than BETA_REDUCTION_LIMIT steps are taken.
1164
+ Returns a new Node; the input is never mutated.
1165
+ """
1166
+ steps = [0]
1167
+ return _beta_reduce(node, steps)
1168
+
1169
+
1170
+ # =========================
1171
+ # Eta-reduction
1172
+ # =========================
1173
+
1174
+ def _eta_reduce(node: Node) -> Node:
1175
+ """Single bottom-up pass contracting all eta-redexes.
1176
+
1177
+ At each Lambda node, after recursing the body, checks three conditions:
1178
+ 1. body is an Application,
1179
+ 2. body.arg is the bound parameter (same LambdaVar),
1180
+ 3. the parameter is NOT free in body.func.
1181
+ When all hold, contracts λp. f(p) → f. One pass suffices because
1182
+ contraction returns body.func, which was already recursed.
1183
+ """
1184
+ if isinstance(node, (Variable, LambdaVar, Constant, Number, SortedConstant)):
1185
+ return node
1186
+ if isinstance(node, Lambda):
1187
+ reduced_body = _eta_reduce(node.body)
1188
+ if (isinstance(reduced_body, Application)
1189
+ and reduced_body.arg == node.param
1190
+ and node.param not in free_variables(reduced_body.func)):
1191
+ return reduced_body.func # eta-contract: λp. f(p) → f
1192
+ return Lambda(node.param, reduced_body)
1193
+ # Structural (Application, Quantifier, SortedQuantifier, Atom, Function, Not,
1194
+ # and the binary connectives): recurse into every child. A Quantifier is
1195
+ # never an eta-redex; it is only recursed into.
1196
+ return node.map_children(_eta_reduce)
1197
+
1198
+
1199
+ def eta_reduce(node: Node) -> Node:
1200
+ """Reduce node to eta-normal form (λx. f(x) → f when x ∉ fv(f)).
1201
+
1202
+ Contracts all eta-redexes bottom-up in a single structural pass.
1203
+ Quantifiers are NOT treated as eta-redexes; they are only recursed into.
1204
+ Returns a new Node; the input is never mutated.
1205
+ """
1206
+ return _eta_reduce(node)
1207
+
1208
+
1209
+ BETA_ETA_ROUND_LIMIT = 100
1210
+
1211
+
1212
+ def beta_eta_normalize(node: Node) -> Node:
1213
+ """Reduce node to beta-eta normal form by alternating beta_reduce and eta_reduce.
1214
+
1215
+ The alternation loop is a genuine necessity: eta-reduction can expose fresh
1216
+ beta-redexes (e.g. eta-contracting a func position turns an Application into
1217
+ a beta-redex), so the combined loop must iterate to a fixpoint rather than
1218
+ running each pass exactly once.
1219
+
1220
+ Raises ReductionLimitError if beta_reduce internally exceeds
1221
+ BETA_REDUCTION_LIMIT steps, or if the alternation loop itself exceeds
1222
+ BETA_ETA_ROUND_LIMIT rounds (which only fires on pathological terms that
1223
+ are not strongly normalizing under beta-eta).
1224
+ Returns a new Node; the input is never mutated.
1225
+ """
1226
+ for _ in range(BETA_ETA_ROUND_LIMIT):
1227
+ after_beta = beta_reduce(node) # may raise ReductionLimitError
1228
+ after_eta = eta_reduce(after_beta)
1229
+ if after_eta == node:
1230
+ return after_eta
1231
+ node = after_eta
1232
+ raise ReductionLimitError(
1233
+ f"beta-eta normalization exceeded {BETA_ETA_ROUND_LIMIT} rounds; "
1234
+ "term may not be strongly normalizing."
1235
+ )
1236
+
1237
+
1238
+ # =========================
1239
+ # Lambda scope resolution
1240
+ # =========================
1241
+
1242
+ def _resolve(node: Node, bound: frozenset) -> Node:
1243
+ """Top-down resolver threading the frozenset of currently lambda-bound names."""
1244
+ if isinstance(node, Variable):
1245
+ return LambdaVar(node.name) if node.name in bound else node
1246
+ if isinstance(node, PredicateTerm):
1247
+ # A predicate name in ARGUMENT position, under a lambda binder of
1248
+ # the same name: the binder captures it exactly as it captures an
1249
+ # individual variable.
1250
+ return LambdaVar(node.name) if node.name in bound else node
1251
+ if isinstance(node, (LambdaVar, Constant, Number, SortedConstant)):
1252
+ return node
1253
+ if isinstance(node, Lambda):
1254
+ return Lambda(node.param, _resolve(node.body, bound | {node.param.name}))
1255
+ if isinstance(node, Quantifier):
1256
+ # quantifier shadows any outer lambda of the same name — remove from bound set
1257
+ return Quantifier(node.type, node.variable,
1258
+ _resolve(node.formula, bound - {node.variable.name}))
1259
+ if isinstance(node, SortedQuantifier):
1260
+ return SortedQuantifier(node.type, node.variable, node.sort,
1261
+ _resolve(node.formula, bound - {node.variable.name}))
1262
+ if isinstance(node, (Count, Cardinality, SortedCount, SortedCardinality,
1263
+ SlashedExists)):
1264
+ # Counting / cardinality / slashed binders shadow an outer lambda of the
1265
+ # same name, exactly like a quantifier (replace() keeps op/n/sort/slash).
1266
+ return replace(node, formula=_resolve(node.formula, bound - {node.variable.name}))
1267
+ if isinstance(node, Atom):
1268
+ resolved_args = tuple(_resolve(a, bound) for a in node.args)
1269
+ if node.predicate in bound:
1270
+ result: Node = LambdaVar(node.predicate)
1271
+ for arg in resolved_args:
1272
+ result = Application(result, arg)
1273
+ return result # zero-arg → bare LambdaVar; n-arg → left-nested Application
1274
+ return Atom(node.predicate, resolved_args)
1275
+ if isinstance(node, Function):
1276
+ # Function names can be lambda-bound (e.g. λfoo. P(foo(x)) parses body as
1277
+ # Atom("P", [Function("foo", ...)]) because NAME "(" termlist ")" → function_).
1278
+ resolved_args = tuple(_resolve(a, bound) for a in node.args)
1279
+ if node.name in bound:
1280
+ result = LambdaVar(node.name)
1281
+ for arg in resolved_args:
1282
+ result = Application(result, arg)
1283
+ return result
1284
+ return Function(node.name, resolved_args)
1285
+ if not is_dataclass(node):
1286
+ raise TypeError(f"resolve_lambda_scope: unknown node type {type(node).__name__}")
1287
+ # Structural: Not, the binary connectives, and Application introduce no
1288
+ # binders — recurse into every child with the same bound set.
1289
+ return node.map_children(lambda c: _resolve(c, bound))
1290
+
1291
+
1292
+ def resolve_lambda_scope(node: Node) -> Node:
1293
+ """Rewrite body occurrences of lambda-bound names using lexical scope.
1294
+
1295
+ After parsing, lambda parameters are LambdaVar but body occurrences keep
1296
+ their default parse types (Variable for single-letter params, Atom for
1297
+ predicate-class params). This pass performs two rewrites driven by the
1298
+ current lambda-bound set:
1299
+
1300
+ 1. Variable(name) whose name is lambda-bound → LambdaVar(name).
1301
+ 2. Atom(pred, args) or Function(name, args) whose pred/name is lambda-bound
1302
+ → left-nested curried Application over LambdaVar(pred/name) and the
1303
+ recursively resolved args. Zero args → bare LambdaVar.
1304
+
1305
+ Scope rules — innermost binder wins:
1306
+
1307
+ - Lambda(p, body): p.name is ADDED to the bound set for body.
1308
+ - Quantifier / SortedQuantifier(_, v, _, body): v.name is REMOVED from the
1309
+ bound set for body. The quantifier shadows any outer lambda of the same
1310
+ name; inside the quantifier, the name is logical (Variable), not lambda-bound.
1311
+
1312
+ Returns a new Node; the input is never mutated.
1313
+ """
1314
+ return _resolve(node, frozenset())
1315
+
1316
+
1317
+ # =========================
1318
+ # Unicode rendering (parser round-trip)
1319
+ # =========================
1320
+ #
1321
+ # These functions render any node back to a Unicode formula string that, when
1322
+ # re-parsed in the matching MSFLParser mode, yields a structurally equal AST.
1323
+ # Dispatch is by class name so this single block covers both the FOL nodes
1324
+ # (from _fol_nodes.py) and the MSFL/lambda nodes defined above.
1325
+ #
1326
+ # The regular formula operators (every connective/modal that the precedence-driven
1327
+ # renderers below format) self-register an OperatorSpec in OPERATORS next to their
1328
+ # class definition. The renderers read OPERATORS at call time, so adding an
1329
+ # operator needs NO edit here. The fixed entries below are the NON-operator
1330
+ # nodes the renderers still special-case explicitly — Lambda/Application and the
1331
+ # three quantifier binders — together with their formula precedences.
1332
+ #
1333
+ # Formula precedence — higher binds tighter — mirrors the grammar layering
1334
+ # (biimplication < implication < same-level binary < prefix < atomic). Operators
1335
+ # carry their precedence in their spec; these are the base (non-operator) entries:
1336
+ _UNI_BASE_PREC = {
1337
+ "Lambda": 0, "Application": 0,
1338
+ "Quantifier": 4, "SortedQuantifier": 4, "SecondOrderQuantifier": 4,
1339
+ "Count": 4, "SortedCount": 4, "SlashedExists": 4,
1340
+ # PAL announcements bind like a prefix operator (their post-formula slot is
1341
+ # a prefix-level operand); they are not in the operator registry because no
1342
+ # registered fixity can express the bracket-delimited two-argument shape.
1343
+ "Announce": 4, "AnnounceDiamond": 4,
1344
+ # Group-epistemic operators E_{…}/D_{…}/C_{…} are prefix operators over a
1345
+ # variable-length agent list, which no registered fixity expresses either.
1346
+ "EverybodyKnows": 4, "DistributedKnowledge": 4, "CommonKnowledge": 4,
1347
+ # The ↓ binder: same binding tightness as a plain quantifier (it IS one,
1348
+ # structurally — see fol._hybrid_nodes.Down's docstring).
1349
+ "Down": 4,
1350
+ }
1351
+
1352
+ # The same-level binary group (∧ ∨ ⊗ ⊕, grammar precedence 3) is identified by
1353
+ # its registered fixity == 'level2' — see the dispatch in _uni()/_latex(), which
1354
+ # reads it straight off the operator's spec. Membership is therefore derived from
1355
+ # the registry: a new same-level operator needs no edit here. Such operators
1356
+ # cannot be mixed without parentheses, and chains are left-folded.
1357
+
1358
+ _UNI_INFIX_COMPARE = frozenset({"=", "≠", "<", ">", "≤", "≥"})
1359
+ _UNI_ARITH_OPS = frozenset({"+", "-", "*", "/"})
1360
+
1361
+ #: The two truth constants (the nullary atoms ``$true`` / ``$false``, and the nullary
1362
+ #: atoms named like their glyphs, which are the same constants) as
1363
+ #: ``(unicode glyph, LaTeX command)``.
1364
+ _TRUTH_GLYPH = {"$true": ("⊤", "\\top"), "$false": ("⊥", "\\bot"),
1365
+ "⊤": ("⊤", "\\top"), "⊥": ("⊥", "\\bot")}
1366
+
1367
+
1368
+ def _uni_prec(node) -> float:
1369
+ """Formula precedence of a node; atomic nodes (atoms, terms) default to 5.
1370
+
1371
+ Regular operators read their precedence from the registry; the binders,
1372
+ Lambda and Application fall back to the fixed base table; anything else
1373
+ (atoms, terms) is atomic at 5.
1374
+ """
1375
+ cls = type(node).__name__
1376
+ spec = OPERATORS.get(cls)
1377
+ if spec is not None:
1378
+ return spec.precedence
1379
+ return _UNI_BASE_PREC.get(cls, 5)
1380
+
1381
+
1382
+ def _uni_wrap(node, min_prec: float) -> str:
1383
+ """Render node, parenthesising it when it binds looser than the slot allows."""
1384
+ s = _uni(node)
1385
+ return f"({s})" if _uni_prec(node) < min_prec else s
1386
+
1387
+
1388
+ def _uni_level2_child(node, parent_cls: str, side: str) -> str:
1389
+ """Render a same-level (∧ ∨ ⊗ ⊕) operand with no-mixing / left-assoc parens.
1390
+
1391
+ Left operand: a same-class chain stays flat (a ∧ b ∧ c); a different
1392
+ same-level operator is parenthesised (no silent mixing). Right operand:
1393
+ any same-level node is parenthesised, since the parser left-folds chains.
1394
+ """
1395
+ s = _uni(node)
1396
+ p = _uni_prec(node)
1397
+ if side == "left":
1398
+ need = p < 3 or (p == 3 and type(node).__name__ != parent_cls)
1399
+ else:
1400
+ need = p < 4
1401
+ return f"({s})" if need else s
1402
+
1403
+
1404
+ def _uni_atom(node) -> str:
1405
+ """Render an Atom: infix comparison, nullary predicate, or applied predicate."""
1406
+ if node.predicate in _UNI_INFIX_COMPARE and len(node.args) == 2:
1407
+ return f"{_uni_term(node.args[0])} {node.predicate} {_uni_term(node.args[1])}"
1408
+ if not node.args and node.predicate in _TRUTH_GLYPH:
1409
+ # The truth constants, which every unicode grammar mode reads back.
1410
+ return _TRUTH_GLYPH[node.predicate][0]
1411
+ if not node.args:
1412
+ return node.predicate
1413
+ return f"{node.predicate}(" + ", ".join(_uni_term(a) for a in node.args) + ")"
1414
+
1415
+
1416
+ def _uni_term_prec(node) -> int:
1417
+ """Arithmetic term precedence: + - → 1, * / → 2, everything atomic → 3."""
1418
+ if (type(node).__name__ == "Function"
1419
+ and node.name in _UNI_ARITH_OPS and len(node.args) == 2):
1420
+ return 2 if node.name in ("*", "/") else 1
1421
+ return 3
1422
+
1423
+
1424
+ def _uni_term_wrap(node, parent_prec: int, is_right: bool) -> str:
1425
+ """Render an arithmetic operand, parenthesising per left-associative precedence."""
1426
+ s = _uni_term(node)
1427
+ p = _uni_term_prec(node)
1428
+ need = p < parent_prec or (p == parent_prec and is_right)
1429
+ return f"({s})" if need else s
1430
+
1431
+
1432
+ def _uni_spine(node):
1433
+ """Uncurry a left-nested Application into (head, [arg0, arg1, …])."""
1434
+ args = []
1435
+ n = node
1436
+ while isinstance(n, Application):
1437
+ args.append(n.arg)
1438
+ n = n.func
1439
+ args.reverse()
1440
+ return n, args
1441
+
1442
+
1443
+ #: Whether the rendering in progress writes every constant by its bare name (the key
1444
+ #: of an atom, see :func:`key_text`) instead of by the text that reads back as it. A
1445
+ #: context variable and not a parameter: a rendering recurses through methods of
1446
+ #: several node modules (modal, linear, ...) that call ``to_unicode_str()`` on their
1447
+ #: children, and a parameter would be lost there.
1448
+ _BARE_CONSTANTS = contextvars.ContextVar("unicode_logic_kit_bare_constants", default=False)
1449
+
1450
+
1451
+ def key_text(node) -> str:
1452
+ """The text of ``node`` used as a KEY: ``to_unicode_str()`` with every constant
1453
+ written by its bare name.
1454
+
1455
+ ``Node.to_unicode_str()`` is the text of a formula, and a constant whose name
1456
+ does not read back bare is written in quotes there (``P('a')``). A route that
1457
+ names an atom, a model entry, a sort order or a target identifier by the printed
1458
+ text of an atom needs the name of the constant instead, as it was before the
1459
+ quoted form existed: a user types ``"P(a)"`` as the key of the atom ``P`` over
1460
+ the domain element ``a``, and the identifier of a target is derived from that
1461
+ text. Use this function wherever the text is stored, looked up, compared,
1462
+ sorted, hashed or turned into a name; use ``to_unicode_str()`` wherever it is
1463
+ shown to a person as a formula or handed to the parser.
1464
+
1465
+ The two renderings are one code path and differ ONLY in the text of
1466
+ ``Constant`` and ``SortedConstant`` names, so for a node whose constants all
1467
+ read back bare they are the same string. Here a name is written as it is and is
1468
+ never refused (the empty name is written as nothing, as it always was).
1469
+
1470
+ The setting is a context variable that is restored when the call ends, however it
1471
+ ends, so a rendering that is running is not disturbed by a call to this
1472
+ function, nor this call by a rendering.
1473
+ """
1474
+ token = _BARE_CONSTANTS.set(True)
1475
+ try:
1476
+ return node.to_unicode_str()
1477
+ finally:
1478
+ _BARE_CONSTANTS.reset(token)
1479
+
1480
+
1481
+ def _constant_name_text(name) -> str:
1482
+ """The text of the name of a constant in the rendering that is running."""
1483
+ return name if _BARE_CONSTANTS.get() else constant_text(name)
1484
+
1485
+
1486
+ def _uni_term(node) -> str:
1487
+ """Render a node occurring in term (argument) position.
1488
+
1489
+ Higher-order applications produced by scope resolution (e.g. foo(x) under
1490
+ λfoo, parsed as a Function then rewritten to Application(LambdaVar, …)) are
1491
+ rendered back as function-call syntax so they re-parse and re-resolve to the
1492
+ same node.
1493
+
1494
+ A ``Constant`` or ``SortedConstant`` is written by ``constant_text``: bare when
1495
+ its name reads back as that constant, in quotes when it does not (``'k2'``,
1496
+ ``'Alice'``, ``'G-910'``), and a name that has no text is refused. ``key_text``
1497
+ switches this one place to the bare name. A variable, a lambda variable, a
1498
+ predicate term and the head of a function or of an application keep printing
1499
+ their name as it is.
1500
+ """
1501
+ cls = type(node).__name__
1502
+ if cls == "Constant":
1503
+ return _constant_name_text(node.name)
1504
+ if cls in ("Variable", "LambdaVar", "PredicateTerm"):
1505
+ return node.name
1506
+ if cls == "Number":
1507
+ return _number_text(node.value)
1508
+ if cls == "SortedConstant":
1509
+ return f"{_constant_name_text(node.name)}:{node.sort}"
1510
+ if cls == "Measure":
1511
+ # μ(entity, dimension) — a measure-function term.
1512
+ return f"μ({_uni_term(node.entity)}, {_uni_term(node.dimension)})"
1513
+ if cls == "Cardinality":
1514
+ # |{v : φ}| — a set-cardinality term; φ renders at the full formula level.
1515
+ return "|{" + node.variable.name + " : " + _uni(node.formula) + "}|"
1516
+ if cls == "SortedCardinality":
1517
+ # |{v:S : φ}| — a sort-restricted set-cardinality term.
1518
+ return ("|{" + node.variable.name + ":" + node.sort + " : "
1519
+ + _uni(node.formula) + "}|")
1520
+ if cls == "Function":
1521
+ if node.name in _UNI_ARITH_OPS and len(node.args) == 2:
1522
+ p = _uni_term_prec(node)
1523
+ left = _uni_term_wrap(node.args[0], p, is_right=False)
1524
+ right = _uni_term_wrap(node.args[1], p, is_right=True)
1525
+ return f"{left} {node.name} {right}"
1526
+ return f"{node.name}(" + ", ".join(_uni_term(a) for a in node.args) + ")"
1527
+ if cls == "Application":
1528
+ head, args = _uni_spine(node)
1529
+ if isinstance(head, (LambdaVar, Variable, Constant)) and args:
1530
+ return f"{head.name}(" + ", ".join(_uni_term(a) for a in args) + ")"
1531
+ return f"({_uni(node.func)})({_uni(node.arg)})"
1532
+ # Atoms / other formula nodes are not valid terms; best-effort fall-through.
1533
+ return _uni(node)
1534
+
1535
+
1536
+ def _uni(node) -> str:
1537
+ """Render node as a formula-level Unicode string (no surrounding parens)."""
1538
+ cls = type(node).__name__
1539
+
1540
+ if cls in ("Variable", "LambdaVar", "Constant", "Number", "SortedConstant",
1541
+ "Function", "Measure", "Cardinality", "SortedCardinality",
1542
+ "PredicateTerm"):
1543
+ return _uni_term(node)
1544
+ if cls == "Atom":
1545
+ return _uni_atom(node)
1546
+
1547
+ # Regular operators are driven entirely by the registry: the spec's fixity
1548
+ # selects the operand arrangement and spec.unicode supplies the glyph/prefix.
1549
+ spec = OPERATORS.get(cls)
1550
+ if spec is not None:
1551
+ fix = spec.fixity
1552
+ if fix == "prefix":
1553
+ # Prefix (¬ and the prefix modal/temporal ops) bind like ¬: operand
1554
+ # wrapped at the prefix level.
1555
+ return spec.unicode + _uni_wrap(node.formula, 4)
1556
+ if fix == "agent_prefix":
1557
+ # K_<agent> / B_<agent>: glyph, agent's name, space, then wrapped operand.
1558
+ # The agent is a term (Variable/Constant); a bare string is also accepted.
1559
+ agent = node.agent
1560
+ agent = agent if isinstance(agent, str) else getattr(agent, "name", None) or agent.to_unicode_str()
1561
+ return f"{spec.unicode}{agent} " + _uni_wrap(node.formula, 4)
1562
+ if fix == "binary_until":
1563
+ # Ⓤ right-assoc: left slot same_level_ops (≥3), right slot until (≥2.5).
1564
+ return f"{_uni_wrap(node.left, 3)} {spec.unicode} {_uni_wrap(node.right, 2.5)}"
1565
+ if fix == "binary_iff":
1566
+ # ↔ right-assoc: left slot is implication (≥2), right slot biimplication (≥1)
1567
+ return f"{_uni_wrap(node.left, 2)} {spec.unicode} {_uni_wrap(node.right, 1)}"
1568
+ if fix == "binary_implies":
1569
+ # → right-assoc: left slot same_level_ops (≥3), right slot implication (≥2)
1570
+ return f"{_uni_wrap(node.left, 3)} {spec.unicode} {_uni_wrap(node.right, 2)}"
1571
+ if fix == "level2":
1572
+ left = _uni_level2_child(node.left, cls, "left")
1573
+ right = _uni_level2_child(node.right, cls, "right")
1574
+ return f"{left} {spec.unicode} {right}"
1575
+
1576
+ if cls == "Quantifier":
1577
+ return f"{node.type}{node.variable.name} " + _uni_wrap(node.formula, 4)
1578
+ if cls == "Count":
1579
+ # ∃≥n / ∃≤n / ∃=n x body (binds as tightly as a quantifier).
1580
+ return (f"{_COUNT_OPS[node.op]}{node.n.value} {node.variable.name} "
1581
+ + _uni_wrap(node.formula, 4))
1582
+ if cls == "SortedCount":
1583
+ # ∃≥n / ∃≤n / ∃=n x:S body (sorted counting quantifier).
1584
+ return (f"{_COUNT_OPS[node.op]}{node.n.value} {node.variable.name}:{node.sort} "
1585
+ + _uni_wrap(node.formula, 4))
1586
+ if cls == "SlashedExists":
1587
+ # ∃x/{y, z} body (IF-logic slashed existential; binds like a quantifier).
1588
+ return (f"∃{node.variable.name}/{{{', '.join(node.slashed)}}} "
1589
+ + _uni_wrap(node.formula, 4))
1590
+ if cls == "Nominal":
1591
+ # A hybrid nominal renders as its bare (NAME-legal) name.
1592
+ return node.name
1593
+ if cls == "Down":
1594
+ # ↓x.body (binds as tightly as a quantifier — see _UNI_BASE_PREC; the
1595
+ # "." separator mirrors the grammar and Lambda's own "λx. body").
1596
+ return f"↓{node.variable.name}." + _uni_wrap(node.formula, 4)
1597
+ if cls == "One":
1598
+ # The multiplicative unit of the linear mode.
1599
+ return "𝟙"
1600
+ if cls == "Top":
1601
+ # The additive truth of the linear mode.
1602
+ return "⊤"
1603
+ if cls == "Zero":
1604
+ # The additive falsity of the linear mode.
1605
+ return "𝟘"
1606
+ if cls in ("Announce", "AnnounceDiamond",
1607
+ "EverybodyKnows", "DistributedKnowledge", "CommonKnowledge"):
1608
+ # PAL announcements ([φ!]ψ / ⟨φ!⟩ψ) and the group-epistemic prefixes
1609
+ # (E_{a,b} φ …) render via their own methods; precedence for the
1610
+ # enclosing slot comes from _UNI_BASE_PREC above.
1611
+ return node.to_unicode_str()
1612
+ if cls == "Dependence":
1613
+ # The dependence atom =(t1, …, tn); terms render at the term level.
1614
+ return "=(" + ", ".join(_uni_term(a) for a in node.args) + ")"
1615
+ if cls == "SortedQuantifier":
1616
+ return f"{node.type}{node.variable.name}:{node.sort} " + _uni_wrap(node.formula, 4)
1617
+ if cls == "SecondOrderQuantifier":
1618
+ # Arity is NOT printed: it is re-inferred from the body on re-parse, so
1619
+ # printing it would break the round-trip. e.g. ∀P P(x, y).
1620
+ return f"{node.type}{node.predicate} " + _uni_wrap(node.formula, 4)
1621
+
1622
+ if cls == "Lambda":
1623
+ # Body extends rightward through the whole formula; never wrapped here.
1624
+ return f"λ{node.param.name}. " + _uni(node.body)
1625
+ if cls == "Application":
1626
+ # (func)(arg): both sides are delimited by parens in the grammar.
1627
+ return f"({_uni(node.func)})({_uni(node.arg)})"
1628
+
1629
+ raise TypeError(f"to_unicode_str: unknown node type {cls}")
1630
+
1631
+
1632
+ # =========================
1633
+ # LaTeX rendering
1634
+ # =========================
1635
+ #
1636
+ # Mirrors the Unicode renderer above but emits LaTeX math-mode markup. It reuses
1637
+ # the same precedence machinery (the operator registry and _uni_prec) so the
1638
+ # parenthesisation is identical; only the operator markup (spec.latex) and term
1639
+ # formatting differ. Output is not parseable by MSFLParser (so no round-trip),
1640
+ # hence tests assert on exact strings.
1641
+
1642
+ # The LaTeX markup for every regular operator now lives in its OperatorSpec.latex
1643
+ # (read at call time below), so the per-operator LaTeX glyph tables are gone.
1644
+ # Only the term-level / quantifier-level tables — which are NOT driven by the
1645
+ # operator registry — remain here.
1646
+
1647
+ _LATEX_COMPARE = {
1648
+ "=": "=", "≠": "\\neq", "<": "<", ">": ">", "≤": "\\leq", "≥": "\\geq",
1649
+ }
1650
+
1651
+ _LATEX_ARITH = {"+": "+", "-": "-", "*": "\\cdot", "/": "/"}
1652
+
1653
+ _LATEX_QUANT = {"∀": "\\forall", "forall": "\\forall", "∃": "\\exists", "exists": "\\exists"}
1654
+
1655
+
1656
+ def _latex_escape(name: str) -> str:
1657
+ """Escape LaTeX math-mode specials in a verbatim symbol name.
1658
+
1659
+ Only the underscore can occur in a grammar-produced name (a c_-prefixed
1660
+ constant such as ``c_zero``); left bare it would be read as the subscript
1661
+ operator, so it is backslash-escaped. Other name classes (variables,
1662
+ predicates, functions, sorts) cannot contain LaTeX specials.
1663
+ """
1664
+ return name.replace("_", "\\_")
1665
+
1666
+
1667
+ def _latex_wrap(node, min_prec: float) -> str:
1668
+ s = _latex(node)
1669
+ return f"({s})" if _uni_prec(node) < min_prec else s
1670
+
1671
+
1672
+ def _latex_level2_child(node, parent_cls: str, side: str) -> str:
1673
+ s = _latex(node)
1674
+ p = _uni_prec(node)
1675
+ if side == "left":
1676
+ need = p < 3 or (p == 3 and type(node).__name__ != parent_cls)
1677
+ else:
1678
+ need = p < 4
1679
+ return f"({s})" if need else s
1680
+
1681
+
1682
+ def _latex_term(node) -> str:
1683
+ cls = type(node).__name__
1684
+ if cls in ("Variable", "LambdaVar", "Constant", "PredicateTerm"):
1685
+ return _latex_escape(node.name)
1686
+ if cls == "Number":
1687
+ return _number_text(node.value)
1688
+ if cls == "SortedConstant":
1689
+ return f"{_latex_escape(node.name)}{{:}}\\mathrm{{{node.sort}}}"
1690
+ if cls == "Measure":
1691
+ return f"\\mu({_latex_term(node.entity)}, {_latex_term(node.dimension)})"
1692
+ if cls == "Cardinality":
1693
+ return ("\\lvert\\{" + _latex_escape(node.variable.name) + " : "
1694
+ + _latex(node.formula) + "\\}\\rvert")
1695
+ if cls == "SortedCardinality":
1696
+ return ("\\lvert\\{" + _latex_escape(node.variable.name)
1697
+ + "{:}\\mathrm{" + node.sort + "} : "
1698
+ + _latex(node.formula) + "\\}\\rvert")
1699
+ if cls == "Function":
1700
+ if node.name in _UNI_ARITH_OPS and len(node.args) == 2:
1701
+ p = _uni_term_prec(node)
1702
+ left = node.args[0]
1703
+ right = node.args[1]
1704
+ ls = _latex_term(left)
1705
+ rs = _latex_term(right)
1706
+ if _uni_term_prec(left) < p:
1707
+ ls = f"({ls})"
1708
+ if _uni_term_prec(right) < p or (_uni_term_prec(right) == p):
1709
+ rs = f"({rs})"
1710
+ return f"{ls} {_LATEX_ARITH[node.name]} {rs}"
1711
+ return f"{node.name}(" + ", ".join(_latex_term(a) for a in node.args) + ")"
1712
+ if cls == "Application":
1713
+ head, args = _uni_spine(node)
1714
+ if isinstance(head, (LambdaVar, Variable, Constant)) and args:
1715
+ return f"{head.name}(" + ", ".join(_latex_term(a) for a in args) + ")"
1716
+ return f"({_latex(node.func)})({_latex(node.arg)})"
1717
+ return _latex(node)
1718
+
1719
+
1720
+ def _latex_atom(node) -> str:
1721
+ if node.predicate in _UNI_INFIX_COMPARE and len(node.args) == 2:
1722
+ return f"{_latex_term(node.args[0])} {_LATEX_COMPARE[node.predicate]} {_latex_term(node.args[1])}"
1723
+ if not node.args and node.predicate in _TRUTH_GLYPH:
1724
+ return _TRUTH_GLYPH[node.predicate][1]
1725
+ if not node.args:
1726
+ return node.predicate
1727
+ return f"{node.predicate}(" + ", ".join(_latex_term(a) for a in node.args) + ")"
1728
+
1729
+
1730
+ def _latex(node) -> str:
1731
+ """Render node as a LaTeX math-mode string (no surrounding parens)."""
1732
+ cls = type(node).__name__
1733
+
1734
+ if cls in ("Variable", "LambdaVar", "Constant", "Number", "SortedConstant",
1735
+ "Function", "Measure", "Cardinality", "SortedCardinality",
1736
+ "PredicateTerm"):
1737
+ return _latex_term(node)
1738
+ if cls == "Atom":
1739
+ return _latex_atom(node)
1740
+
1741
+ # Regular operators are driven entirely by the registry: the spec's fixity
1742
+ # selects the operand arrangement and spec.latex supplies the markup (already
1743
+ # including any trailing space for the prefix forms).
1744
+ spec = OPERATORS.get(cls)
1745
+ if spec is not None:
1746
+ fix = spec.fixity
1747
+ if fix == "prefix":
1748
+ return spec.latex + _latex_wrap(node.formula, 4)
1749
+ if fix == "agent_prefix":
1750
+ agent = node.agent
1751
+ agent = agent if isinstance(agent, str) else getattr(agent, "name", None) or agent.to_unicode_str()
1752
+ return f"{spec.latex}_{{{agent}}} " + _latex_wrap(node.formula, 4)
1753
+ if fix == "binary_until":
1754
+ return f"{_latex_wrap(node.left, 3)} {spec.latex} {_latex_wrap(node.right, 2.5)}"
1755
+ if fix == "binary_iff":
1756
+ return f"{_latex_wrap(node.left, 2)} {spec.latex} {_latex_wrap(node.right, 1)}"
1757
+ if fix == "binary_implies":
1758
+ return f"{_latex_wrap(node.left, 3)} {spec.latex} {_latex_wrap(node.right, 2)}"
1759
+ if fix == "level2":
1760
+ left = _latex_level2_child(node.left, cls, "left")
1761
+ right = _latex_level2_child(node.right, cls, "right")
1762
+ return f"{left} {spec.latex} {right}"
1763
+
1764
+ if cls == "Quantifier":
1765
+ return f"{_LATEX_QUANT[node.type]} {node.variable.name}\\, " + _latex_wrap(node.formula, 4)
1766
+ if cls == "Count":
1767
+ rel = {"ge": "\\geq", "le": "\\leq", "eq": "="}[node.op]
1768
+ return (f"\\exists^{{{rel} {node.n.value}}} {node.variable.name}\\, "
1769
+ + _latex_wrap(node.formula, 4))
1770
+ if cls == "SortedCount":
1771
+ rel = {"ge": "\\geq", "le": "\\leq", "eq": "="}[node.op]
1772
+ return (f"\\exists^{{{rel} {node.n.value}}} {node.variable.name}{{:}}\\mathrm{{{node.sort}}}\\, "
1773
+ + _latex_wrap(node.formula, 4))
1774
+ if cls == "SlashedExists":
1775
+ slashed = ", ".join(_latex_escape(n) for n in node.slashed)
1776
+ return (f"\\exists {node.variable.name} / \\{{{slashed}\\}}\\, "
1777
+ + _latex_wrap(node.formula, 4))
1778
+ if cls == "Nominal":
1779
+ return f"\\mathsf{{{_latex_escape(node.name)}}}"
1780
+ if cls == "Down":
1781
+ return (f"\\downarrow {_latex_escape(node.variable.name)}.\\, "
1782
+ + _latex_wrap(node.formula, 4))
1783
+ if cls == "One":
1784
+ return "\\mathbf{1}"
1785
+ if cls == "Top":
1786
+ return "\\top"
1787
+ if cls == "Zero":
1788
+ return "\\mathbf{0}"
1789
+ if cls in ("Announce", "AnnounceDiamond",
1790
+ "EverybodyKnows", "DistributedKnowledge", "CommonKnowledge"):
1791
+ # PAL announcements and group-epistemic prefixes render via their own
1792
+ # LaTeX methods.
1793
+ return node.to_latex()
1794
+ if cls == "Dependence":
1795
+ return "{=}(" + ", ".join(_latex_term(a) for a in node.args) + ")"
1796
+ if cls == "SortedQuantifier":
1797
+ return (f"{_LATEX_QUANT[node.type]} {node.variable.name}{{:}}\\mathrm{{{node.sort}}}\\, "
1798
+ + _latex_wrap(node.formula, 4))
1799
+ if cls == "SecondOrderQuantifier":
1800
+ # Arity is not rendered (mirrors the Unicode renderer). e.g. \forall P\, P(x).
1801
+ return f"{_LATEX_QUANT[node.type]} {node.predicate}\\, " + _latex_wrap(node.formula, 4)
1802
+
1803
+ if cls == "Lambda":
1804
+ return f"\\lambda {node.param.name}.\\, " + _latex(node.body)
1805
+ if cls == "Application":
1806
+ return f"({_latex(node.func)})({_latex(node.arg)})"
1807
+
1808
+ raise TypeError(f"to_latex: unknown node type {cls}")
1809
+
1810
+
1811
+ # =========================
1812
+ # Registry extension
1813
+ # =========================
1814
+
1815
+ NODE_CLASSES.update({
1816
+ "SortedQuantifier": SortedQuantifier,
1817
+ "SortedConstant": SortedConstant,
1818
+ "SortedCount": SortedCount,
1819
+ "SortedCardinality": SortedCardinality,
1820
+ "WeakConjunction": WeakConjunction,
1821
+ "WeakDisjunction": WeakDisjunction,
1822
+ "StrongConjunction": StrongConjunction,
1823
+ "StrongDisjunction": StrongDisjunction,
1824
+ "LukNegation": LukNegation,
1825
+ "LukImplication": LukImplication,
1826
+ "LukEquivalence": LukEquivalence,
1827
+ "LambdaVar": LambdaVar,
1828
+ "Lambda": Lambda,
1829
+ "Application": Application,
1830
+ })
1831
+
1832
+
1833
+ # =========================
1834
+ # MSFL Reductions
1835
+ # =========================
1836
+
1837
+ def _reduce_nl_nodes(n: Node) -> Node:
1838
+ """Reduce the natural-language nodes with a classical reading to plain FOL.
1839
+
1840
+ ``Contrast`` is truth-functionally conjunction (its own contract; the Z3 /
1841
+ Prover9 / TPTP exports already lower it as ``And``), and ``Count`` carries the
1842
+ standard distinct-witnesses expansion (:meth:`Count._expand`, the same one its
1843
+ first-order exports use). ``Cardinality`` is deliberately NOT reduced here —
1844
+ set cardinality has no first-order counterpart, so it must keep raising at the
1845
+ first-order boundary rather than being silently mistranslated.
1846
+ """
1847
+ n = n.map_children(_reduce_nl_nodes)
1848
+ if isinstance(n, Contrast):
1849
+ return And(n.left, n.right)
1850
+ if isinstance(n, Count):
1851
+ # The expansion is built from the already-reduced matrix, but it wraps it
1852
+ # in fresh classical structure only, so one more pass is not needed.
1853
+ return n._expand()
1854
+ return n
1855
+
1856
+
1857
+ def subsort_axioms(signature: "Signature") -> Tuple[Node, ...]:
1858
+ """Return one ``∀x (S(x) → T(x))`` sentence per DIRECT subsort edge ``S < T``.
1859
+
1860
+ The subsort counterpart of :func:`nonempty_sort_axioms`, under the SAME
1861
+ contract: a caller deciding sorted input against a
1862
+ :class:`~unicode_logic_kit.fol.signature.Signature` that declares subsorts
1863
+ adds these as their OWN extra, top-level, never-negated assumptions --
1864
+ premises for a validity/entailment check, extra conjuncts for a
1865
+ satisfiability check -- e.g.
1866
+ ``api.prove(goal, [*premises, *subsort_axioms(sig)])``. They are never
1867
+ folded into :func:`to_fol`'s per-formula translation: an axiom conjoined
1868
+ onto the formula being PROVED becomes something the prover has to prove
1869
+ too, and ``∀x (S(x) → T(x))`` is not valid, so every validity check would
1870
+ come back refuted.
1871
+
1872
+ ``S``/``T`` are the same 1-ary guard predicate names
1873
+ :meth:`SortedQuantifier._relativize` uses for sort membership, so the
1874
+ axioms constrain exactly the predicates a relativised formula reads,
1875
+ nothing else. Only DIRECT edges are emitted: a transitive edge ``S < U``
1876
+ via ``S < T < U`` follows by chaining the two implications. Deterministic
1877
+ ``(child, parent)`` order; an empty tuple for a signature without
1878
+ subsorts. Duck-typed: only ``signature.subsorts`` is read.
1879
+ """
1880
+ x = Variable("x")
1881
+ return tuple(Quantifier("∀", x, Implies(Atom(child, (x,)), Atom(parent, (x,))))
1882
+ for child in sorted(signature.subsorts)
1883
+ for parent in sorted(signature.subsorts[child]))
1884
+
1885
+
1886
+ def signature_axioms(signature: "Signature") -> Tuple[Node, ...]:
1887
+ """Return everything a :class:`~unicode_logic_kit.fol.signature.Signature` asserts, as plain FOL sentences.
1888
+
1889
+ A signature that declares sorts, constant sorts, function ranks and subsort
1890
+ edges says more than a decision procedure reads from the formulas alone. This
1891
+ function writes those declarations down, in the vocabulary of the one-universe
1892
+ reading of sorts (a sort ``S`` is the non-empty extension of the unary
1893
+ predicate ``S``; see :func:`sort_membership_axioms`), so that a caller can add
1894
+ them to the premises of ``premises ⊨ goal`` and ask the question the
1895
+ signature asks. The sentences, in this order (each family sorted by name):
1896
+
1897
+ 1. ``∃x S(x)`` for every sort the signature names: the sorts it lists, the
1898
+ sorts of its constants, the argument and result sorts of its functions
1899
+ and the argument sorts of its predicates, and both ends of a subsort edge.
1900
+ 2. :func:`subsort_axioms`: ``∀x (S(x) → T(x))`` for every direct edge
1901
+ ``S < T``.
1902
+ 3. ``S(c)`` for every constant ``c`` declared with sort ``S``.
1903
+ 4. ``∀x1 … ∀xn (S1(x1) ∧ … ∧ Sn(xn) → S(f(x1, …, xn)))`` for every function
1904
+ ``f`` declared with result sort ``S`` and argument sorts ``S1 … Sn``. An
1905
+ argument position declared without a sort contributes no guard, a function
1906
+ declared without a result sort contributes no sentence, and a nullary
1907
+ function gives ``S(f)``.
1908
+
1909
+ A predicate's declared argument sorts give NO sentence. Under the one-universe
1910
+ reading a predicate is a relation over the whole universe and may hold of
1911
+ anything, whatever sort its arguments were declared with: ``P: A`` does not
1912
+ say ``∀x (P(x) → A(x))``, and a sentence saying so would be a different
1913
+ question. The relativisation of a sorted theory to plain first-order logic
1914
+ needs non-emptiness of the sorts and closure of the declared functions, and
1915
+ nothing about predicates.
1916
+
1917
+ The contract is that of :func:`nonempty_sort_axioms` and
1918
+ :func:`subsort_axioms`: the sentences are background facts that a caller adds
1919
+ as its own top-level, never-negated assumptions (premises for an entailment,
1920
+ extra asserted conjuncts for a satisfiability question). ``api.prove`` and
1921
+ ``api.countermodel`` add them when given ``signature=``. Duck-typed: the
1922
+ ``sorts``, ``predicates``, ``functions``, ``constants`` and ``subsorts``
1923
+ attributes are read. An empty tuple for an empty signature.
1924
+
1925
+ Returns:
1926
+ The sentences, with exact repetitions removed.
1927
+ """
1928
+ named = set(signature.sorts)
1929
+ for predicate in signature.predicates.values():
1930
+ named.update(s for s in (predicate.arg_sorts or ()) if s is not None)
1931
+ for function in signature.functions.values():
1932
+ named.update(s for s in (function.arg_sorts or ()) if s is not None)
1933
+ if function.result_sort is not None:
1934
+ named.add(function.result_sort)
1935
+ for constant in signature.constants.values():
1936
+ if constant.sort is not None:
1937
+ named.add(constant.sort)
1938
+ for child, parents in signature.subsorts.items():
1939
+ named.add(child)
1940
+ named.update(parents)
1941
+
1942
+ x = Variable("x")
1943
+ sentences: List[Node] = [Quantifier("∃", x, Atom(sort, (x,))) for sort in sorted(named)]
1944
+ sentences.extend(subsort_axioms(signature))
1945
+ for name in sorted(signature.constants):
1946
+ sort = signature.constants[name].sort
1947
+ if sort is not None:
1948
+ sentences.append(Atom(sort, (Constant(name),)))
1949
+ for name in sorted(signature.functions):
1950
+ function = signature.functions[name]
1951
+ if function.result_sort is None:
1952
+ continue
1953
+ arguments = [Variable(f"x{i + 1}") for i in range(function.arity)]
1954
+ term = Function(name, tuple(arguments)) if arguments else Constant(name)
1955
+ body: Node = Atom(function.result_sort, (term,))
1956
+ guards = [Atom(sort, (argument,))
1957
+ for sort, argument in zip(function.arg_sorts or (), arguments)
1958
+ if sort is not None]
1959
+ if guards:
1960
+ guard: Node = guards[0]
1961
+ for extra in guards[1:]:
1962
+ guard = And(guard, extra)
1963
+ body = Implies(guard, body)
1964
+ for argument in reversed(arguments):
1965
+ body = Quantifier("∀", argument, body)
1966
+ sentences.append(body)
1967
+
1968
+ unique: List[Node] = []
1969
+ for sentence in sentences:
1970
+ if sentence not in unique:
1971
+ unique.append(sentence)
1972
+ return tuple(unique)
1973
+
1974
+
1975
+ def to_fol(node: Node, include_sort_facts: bool = False) -> Node:
1976
+ """Reduce an MSFL (or plain FOL) node to a purely classical FOL node.
1977
+
1978
+ Three-phase reduction:
1979
+
1980
+ 1. to_msfol() — replaces Łukasiewicz operators with classical boolean
1981
+ counterparts (And/Or/Not/Implies/Iff); sort annotations are preserved.
1982
+ 2. _relativize() — replaces SortedQuantifier with a guarded plain
1983
+ Quantifier; replaces SortedConstant with a plain Constant and collects
1984
+ sort-membership atoms as a side-effect.
1985
+ 3. _reduce_nl_nodes() — collapses ``Contrast`` to ``And`` and expands
1986
+ ``Count`` (including a relativized ``SortedCount``) via the
1987
+ distinct-witnesses encoding, so the returned node honours the
1988
+ "classical FOL constructs only" contract for those inputs too.
1989
+
1990
+ Background facts about sorts are deliberately NOT folded in here, because
1991
+ this reduction is polarity-blind: sort non-emptiness and a Signature's
1992
+ subsort edges come from :func:`nonempty_sort_axioms` and
1993
+ :func:`subsort_axioms`, which a caller adds as separate premises.
1994
+
1995
+ Args:
1996
+ node: any Node (MSFL or classical FOL).
1997
+ include_sort_facts: if True, deduplicated sort-membership atoms are
1998
+ conjoined as a prefix block at the top level. Dedup is by
1999
+ (sort-predicate, constant-name) in first-occurrence order.
2000
+
2001
+ Returns:
2002
+ A Node built from classical FOL constructs only.
2003
+ """
2004
+ msfol = node.to_msfol()
2005
+ facts: list = []
2006
+ fol = _reduce_nl_nodes(msfol._relativize(facts))
2007
+ if include_sort_facts and facts:
2008
+ seen: set = set()
2009
+ dedup = []
2010
+ for f in facts:
2011
+ key = (f.predicate, f.args[0].name)
2012
+ if key not in seen:
2013
+ seen.add(key)
2014
+ dedup.append(f)
2015
+ conj = dedup[0]
2016
+ for f in dedup[1:]:
2017
+ conj = And(conj, f)
2018
+ fol = And(conj, fol)
2019
+ return fol
2020
+
2021
+
2022
+ # =========================
2023
+ # Sort non-emptiness axioms
2024
+ # =========================
2025
+
2026
+ #: The four many-sorted node types that carry a ``.sort: str`` field — the
2027
+ #: same set :func:`~unicode_logic_kit.atp.finite_domain.lower_msfol` scans for.
2028
+ _SORTED_NODE_TYPES = (SortedQuantifier, SortedConstant, SortedCount, SortedCardinality)
2029
+
2030
+
2031
+ def nonempty_sort_axioms(*sentences: Node, avoid_names=()) -> Tuple[Node, ...]:
2032
+ """Return one ``∃x (S(x))`` sentence per distinct sort name in ``sentences``.
2033
+
2034
+ Many-sorted FOL (MSFOL), by convention, never gives a sort an EMPTY
2035
+ universe (see the classical-reasoning guide's many-sorted section, and
2036
+ :func:`~unicode_logic_kit.semantics.modelfinder._nonempty_subsets`, which
2037
+ enumerates only non-empty subsets of the domain as a sort's universe).
2038
+ ``to_fol``'s own relativisation above — ``∀x:S φ`` becomes
2039
+ ``∀x (S(x) → φ)``, ``∃x:S φ`` becomes ``∃x (S(x) ∧ φ)`` — carries NO such
2040
+ guarantee by itself: a classical decision procedure remains free to make
2041
+ ``S`` empty, which makes every ``∀x:S φ`` vacuously TRUE and can turn an
2042
+ otherwise-valid many-sorted entailment into a spurious countermodel (an
2043
+ empty-sort "model" no legal MSFOL structure would ever be), or turn a
2044
+ genuinely unsatisfiable many-sorted theory satisfiable.
2045
+
2046
+ This is the ONE place that gap is closed, factored out of
2047
+ :func:`~unicode_logic_kit.atp.finite_domain.lower_msfol` (the ASP/CP
2048
+ backends' own many-sorted front door, which builds exactly these
2049
+ sentences) so every OTHER route deciding sorted input through ``to_fol``
2050
+ — directly, or via a node's own ``to_z3``/``to_prover9``/``to_tptp``
2051
+ auto-reduction — can reuse the identical construction instead of
2052
+ drifting apart. A caller must add the returned sentences as their OWN
2053
+ extra, top-level, NEVER-negated assumptions alongside whatever it is
2054
+ deciding — on the premise side for a validity/entailment check (an extra
2055
+ assumption the goal may rely on), as an extra asserted conjunct for a
2056
+ satisfiability/model-finding check — and never fold them INSIDE the
2057
+ per-formula translation itself: ``to_fol``/``to_z3``/``to_prover9``/
2058
+ ``to_tptp`` are polarity-blind (the very same node translates the same
2059
+ way whether it sits under a positive or a negated position), so an extra
2060
+ existential planted inside one of them would land under the WRONG
2061
+ polarity whenever the sorted node is itself under a negation, and would
2062
+ be duplicated once per occurrence of the sort besides.
2063
+
2064
+ Every one of the four many-sorted node types (:class:`SortedQuantifier`,
2065
+ :class:`SortedConstant`, :class:`SortedCount`, :class:`SortedCardinality`)
2066
+ carries a ``.sort: str`` field; this walks every sentence for all four,
2067
+ collecting distinct sort names in first-occurrence order (stable, so
2068
+ re-running this on the same input returns the same sentences in the same
2069
+ order), and returns one bound sentence ``∃x (S(x))`` per name — using the
2070
+ SAME predicate name ``to_fol``'s own relativisation uses for that sort's
2071
+ guard atom, so asserting this alongside the relativised sentences
2072
+ constrains exactly the predicate they read, nothing else.
2073
+
2074
+ Returns an EMPTY tuple when no sentence contains any of the four sorted
2075
+ node types — the same no-op signal :func:`lower_msfol` uses to guarantee
2076
+ a fully unsorted caller sees byte-identical solver input.
2077
+
2078
+ The witness variable is named by
2079
+ :func:`~unicode_logic_kit.fol._identifiers.fresh_variables` (``x0``, ``x1``,
2080
+ … skipping every name the input uses, of every kind: a predicate, a
2081
+ function, a constant and a sort are skipped as well as a variable). Until
2082
+ 0.28.2 it was ``_msfol_<Sort>_witness``, which the kit's OWN parser rejects
2083
+ — VARIABLE is one letter plus digits — so an axiom this function returned
2084
+ could be printed but not read back, and ``api.parse_any`` said no to text
2085
+ the kit had just written. The sort is still visible in the axiom: the guard
2086
+ atom is the sort predicate itself (``∃x0 Human(x0)``).
2087
+
2088
+ A sort named like the witness would make ``∃x0 x0(x0)``, which a text with
2089
+ one namespace for variables and predicates (SMT-LIB, where cvc5 ends the
2090
+ process on it) cannot say, so the witness is fresh against the sorts too.
2091
+ ``avoid_names`` adds names that the CALLER knows and the sentences do not
2092
+ hold (the other formulas of the problem, when the axioms are asked for
2093
+ apart from them); the default is none.
2094
+ """
2095
+ sort_names: List[str] = []
2096
+ for s in sentences:
2097
+ for node in s.walk():
2098
+ if isinstance(node, _SORTED_NODE_TYPES) and node.sort not in sort_names:
2099
+ sort_names.append(node.sort)
2100
+ witnesses = fresh_variables(len(sort_names),
2101
+ avoid=symbol_names(*sentences) | frozenset(avoid_names))
2102
+ return tuple(
2103
+ Quantifier("∃", Variable(witness), Atom(name, (Variable(witness),)))
2104
+ for name, witness in zip(sort_names, witnesses)
2105
+ )
2106
+
2107
+
2108
+ def sort_membership_axioms(*sentences: Node) -> Tuple[Node, ...]:
2109
+ """Return the atom ``S(c)`` for every distinct sorted constant ``c:S`` in ``sentences``.
2110
+
2111
+ What a sort IS in this kit, stated once. There is ONE universe. A sort ``S``
2112
+ is the extension of the unary predicate ``S`` -- the sort and the predicate
2113
+ of that name are one symbol -- and it is never empty. Sorts may overlap. A
2114
+ sorted quantifier ``∀x:S φ`` ranges over ``S``, an unsorted one over
2115
+ everything. A sorted constant ``c:S`` denotes an element of ``S``; a constant
2116
+ written with two sorts lies in both, and ``c:S`` here and plain ``c`` there
2117
+ are one constant. An unsorted constant and the value of a function may be
2118
+ any element. That is what the finite model finder
2119
+ (:mod:`~unicode_logic_kit.semantics.modelfinder`) enumerates.
2120
+
2121
+ :func:`to_fol` writes the first half of that: ``∀x:S φ`` becomes
2122
+ ``∀x (S(x) → φ)`` and ``c:S`` becomes ``c``. :func:`nonempty_sort_axioms`
2123
+ states that no sort is empty. THIS function states the remaining fact, that
2124
+ a sorted constant is in its sort. Without it the reduction forgets the
2125
+ annotation altogether, and ``∀x:Human Mortal(x) ⊢ Mortal(socrates:Human)``
2126
+ has the countermodel in which ``socrates`` is no ``Human`` at all.
2127
+
2128
+ The contract is :func:`nonempty_sort_axioms`' own. The atoms are background
2129
+ facts: a caller adds them as its OWN top-level, never-negated assumptions --
2130
+ premises for an entailment, extra asserted conjuncts for a satisfiability
2131
+ question -- next to whatever it decides, and computes them over the premises
2132
+ AND the conclusion. They are never folded into the per-formula translation,
2133
+ which is polarity-blind: conjoined onto a conclusion, ``S(c)`` would become
2134
+ something to prove. (``to_fol(..., include_sort_facts=True)`` does conjoin
2135
+ them, and is therefore right only for a sentence that is itself asserted.)
2136
+
2137
+ One atom per distinct ``(constant, sort)`` pair, in first-occurrence order;
2138
+ an empty tuple when there is no sorted constant. The atom is built over the
2139
+ name AS WRITTEN IN ``sentences``. A writer that renames a constant on its
2140
+ way out (the TPTP, Prover9 and SMT-LIB writers do) must therefore take the
2141
+ atoms from the formulas it actually writes, or the fact would be about
2142
+ another symbol than the one its premises mention. A modal route, whose sort
2143
+ guard carries a world argument, lifts the atom itself.
2144
+ """
2145
+ pairs: List[Tuple[str, str]] = []
2146
+ for s in sentences:
2147
+ for node in s.walk():
2148
+ if isinstance(node, SortedConstant) and (node.sort, node.name) not in pairs:
2149
+ pairs.append((node.sort, node.name))
2150
+ return tuple(Atom(sort, (Constant(name),)) for sort, name in pairs)
2151
+
2152
+
2153
+ def sort_axioms(*sentences: Node, avoid_names=()) -> Tuple[Node, ...]:
2154
+ """Everything the guard reading of ``sentences`` needs as background:
2155
+ :func:`nonempty_sort_axioms` followed by :func:`sort_membership_axioms`.
2156
+ ``avoid_names`` is :func:`nonempty_sort_axioms`' own.
2157
+
2158
+ This is the premise list to hand to a decision procedure together with the
2159
+ :func:`to_fol` images (or with the sorted sentences themselves, whose
2160
+ ``to_z3`` / ``to_tptp`` / ``to_prover9`` reduce on their own)::
2161
+
2162
+ api.prove(to_fol(goal), [to_fol(p) for p in premises] + list(sort_axioms(*premises, goal)))
2163
+
2164
+ ``api.prove`` and the backends add it themselves for sorted input.
2165
+ :func:`subsort_axioms` is the third family, for a
2166
+ :class:`~unicode_logic_kit.fol.signature.Signature` that declares subsorts.
2167
+ """
2168
+ return nonempty_sort_axioms(*sentences, avoid_names=avoid_names) + sort_membership_axioms(*sentences)
2169
+
2170
+
2171
+ def lower_counting(node: Node, avoid_names=None) -> Node:
2172
+ """Replace every counting quantifier of ``node``, plain and sorted, by its expansion.
2173
+
2174
+ ``∃≥n x φ`` becomes the distinct-witnesses formula of :meth:`Count._expand`, and a
2175
+ sorted ``∃≥n x:S φ`` the same over the guarded matrix ``S(x) ∧ φ`` (what
2176
+ :meth:`SortedCount._relativize` makes of it). Nothing else changes: the other sorted
2177
+ nodes stay, so a caller that goes on to translate or rename the result sees them.
2178
+
2179
+ This is the step to take BEFORE a problem's symbols are renamed for a text with one
2180
+ namespace (the SMT-LIB writers): the witnesses are minted here, so they are names of
2181
+ the problem like any other and the renaming keeps every symbol of every kind apart.
2182
+ ``avoid_names`` is the set of every name of the whole problem (see
2183
+ :func:`~unicode_logic_kit.fol._identifiers.symbol_names`); the witnesses are fresh
2184
+ against it and are added to it, so that the expansions of one problem mint distinct
2185
+ names. ``None`` avoids only the names of ``node`` itself.
2186
+
2187
+ Raises:
2188
+ NotImplementedError: a bound beyond what :meth:`Count._expand` materialises.
2189
+ """
2190
+ node = node.map_children(lambda child: lower_counting(child, avoid_names))
2191
+ if isinstance(node, SortedCount):
2192
+ node = Count(node.op, node.n, node.variable,
2193
+ And(Atom(node.sort, (node.variable,)), node.formula))
2194
+ if isinstance(node, Count):
2195
+ return node._expand(avoid_names)
2196
+ return node