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,3586 @@
1
+ """Z3 environment, base Node class, classical FOL nodes, registry, and Lark transformer."""
2
+
3
+ import contextvars
4
+ import functools
5
+ import re
6
+ import types
7
+ from decimal import Decimal
8
+ from typing import Any, Callable, List, Optional, Tuple, TypeVar, Union, Dict, cast
9
+ from lark import Transformer
10
+ from dataclasses import dataclass, fields
11
+
12
+ import z3
13
+
14
+ from . import _identifiers
15
+ from ._tptp_symbols import check_variable_names as _check_variable_names
16
+ from ._tptp_symbols import guard_class as _guard_to_tptp
17
+ from ._tptp_symbols import is_tptp_boolean_atom as _is_tptp_boolean_atom
18
+ from ._tptp_symbols import truth_constant_word as _truth_constant_word
19
+ from .naming import ParsingError
20
+
21
+ _SORT = z3.DeclareSort("S")
22
+
23
+
24
+ def numeral_key(value) -> str:
25
+ """The text a numeral is known by: ONE text per VALUE.
26
+
27
+ ``Number(1) == Number(1.0)`` is ``True`` in the kit (the two hash alike), so ``1``, ``1.0``
28
+ and ``01`` are one numeral and a route that makes a constant of a numeral makes ONE
29
+ constant of them. An integral value is written as an integer (``1.0`` and ``1`` are
30
+ ``'1'``, ``-0.0`` is ``'0'``), any other value as ``str`` of it (``'2.5'``, ``'-1'``,
31
+ ``'1e-07'``). Two numerals have the same key exactly when they are equal.
32
+
33
+ A :class:`Number` already stores an integral float as the integer it equals, so the key of
34
+ a node's value is ``str`` of it; the function takes any value, a raw ``1.0`` too.
35
+ """
36
+ if isinstance(value, bool):
37
+ value = int(value)
38
+ elif isinstance(value, float) and value.is_integer():
39
+ value = int(value)
40
+ return str(value)
41
+
42
+
43
+ #: What a Z3 name ends in when the symbol is a VARIABLE (``x`` is written ``x!v``), and what a
44
+ #: constant whose own name already ends that way, or in this, gets appended (``x!v`` is written
45
+ #: ``x!v!c``). A variable's name always ends in the first mark and a constant's never does, so a
46
+ #: constant and a variable of one name are two symbols, and the two maps are injective.
47
+ _VARIABLE_MARK = "!v"
48
+ _ESCAPE_MARK = "!c"
49
+
50
+
51
+ def z3_constant_name(name: str) -> str:
52
+ """The name of the Z3 symbol of the constant ``name``: the name itself, except for a name
53
+ that ends in ``!v`` or ``!c``, which gets ``!c`` appended so that no constant is spelled
54
+ like a variable's symbol (see :func:`z3_variable_name`)."""
55
+ return name + _ESCAPE_MARK if name.endswith((_VARIABLE_MARK, _ESCAPE_MARK)) else name
56
+
57
+
58
+ def z3_variable_name(name: str) -> str:
59
+ """The name of the Z3 symbol of the variable ``name``: ``name`` followed by ``!v``."""
60
+ return name + _VARIABLE_MARK
61
+
62
+
63
+ def kit_name_of_z3_symbol(z3_name: str) -> Tuple[str, bool]:
64
+ """Read the name of a Z3 constant of sort ``S`` back as ``(kit name, is it a variable)``.
65
+
66
+ The inverse of :func:`z3_variable_name` and of :func:`z3_constant_name`, and of nothing
67
+ else: ``x!v`` is the variable ``x``, ``x!v!c`` the constant ``x!v``, ``x`` the constant
68
+ ``x``. A name that neither writer produces is a constant of exactly that name: ``x!c``
69
+ is not written for any constant (the constant ``x`` is written ``x``, and only a name
70
+ that already ends in a mark gets ``!c`` appended), so a text that holds the symbols
71
+ ``x`` and ``x!c`` reads them as two constants, ``x`` and ``x!c``, never as one. The
72
+ function reads ONE name, so it is no more than the inverse of the writers: a text that holds
73
+ ``a!c`` (no writer's) and ``a!c!c`` (the writer's name of the constant ``a!c``) would be read
74
+ as one constant, ``a!c``, by calling it on each; the reader of a text
75
+ (:func:`~unicode_logic_kit.atp.z3_input.from_z3`) reads the second as written, ``a!c!c``.
76
+ Only the names of the nullary symbols of sort ``S`` are written this way (a function, a
77
+ predicate and a proposition keep their names), so only those are to be read with it.
78
+ """
79
+ if z3_name.endswith(_VARIABLE_MARK):
80
+ return z3_name[:-len(_VARIABLE_MARK)], True
81
+ if z3_name.endswith(_ESCAPE_MARK):
82
+ stripped = z3_name[:-len(_ESCAPE_MARK)]
83
+ if stripped.endswith((_VARIABLE_MARK, _ESCAPE_MARK)):
84
+ return stripped, False
85
+ return z3_name, False
86
+
87
+
88
+ def check_z3_name(name: str) -> None:
89
+ """Refuse a symbol name that the Z3 C API cannot carry.
90
+
91
+ Z3 reads a name as a C string: it ends at the first NUL character, so ``a\\x00b`` and
92
+ ``a\\x00c`` would be the one symbol ``a``, and a lone surrogate (which no UTF-8 text
93
+ holds) makes the call raise ``UnicodeEncodeError``. A problem that names two things
94
+ alike is not the problem that was asked, so the name is refused before anything is
95
+ declared, by every translation into Z3 (:class:`Z3Env`, the arithmetic environment).
96
+
97
+ Raises:
98
+ NotImplementedError: the name holds a NUL character or a lone surrogate.
99
+ """
100
+ if "\x00" in name:
101
+ raise NotImplementedError(
102
+ f"to_z3: the name {name!r} holds a NUL character, which Z3 reads as the end of a name "
103
+ f"(so it would be the symbol {name.split(chr(0))[0]!r}). Rename the symbol.")
104
+ try:
105
+ name.encode("utf-8")
106
+ except UnicodeEncodeError:
107
+ raise NotImplementedError(
108
+ f"to_z3: the name {name!r} holds a lone surrogate, which is no text that Z3 can take "
109
+ f"as the name of a symbol. Rename the symbol.") from None
110
+
111
+
112
+ def numeral_constant_clash(text: str):
113
+ """Refuse a numeral and a constant that are one symbol.
114
+
115
+ A numeral is translated to the symbol of its own text (:func:`numeral_key`), so
116
+ ``Number(1)`` and a constant named ``1`` (``Constant('1')``) are the same Z3
117
+ symbol and ``P(1)`` would say what ``P('1')`` says. The problem writers for TPTP
118
+ refuse the pair for the same reason. Raised by :class:`Z3Env` and by the cvc5
119
+ sanitiser. A VARIABLE spelled like a numeral is another symbol and is not refused.
120
+
121
+ Raises:
122
+ NotImplementedError: always, naming the text and what to do instead.
123
+ """
124
+ raise NotImplementedError(
125
+ f"to_z3: the numeral {text} and a constant named {text!r} are one symbol "
126
+ f"(a numeral is the symbol of its own text), so the problem would say about one "
127
+ f"thing what it says about two. Rename the constant, or write the number as a "
128
+ f"constant of another name.")
129
+
130
+
131
+ # =========================
132
+ # Z3 Environment
133
+ # =========================
134
+
135
+ class Z3Env:
136
+ """Tracks declared Z3 symbols. Single sort for all terms.
137
+
138
+ **What is one symbol.** A constant is keyed on its name; a function and a
139
+ predicate on ``(name, arity)`` each, in a table of their own. So ``P(a)`` and
140
+ ``P(a, b)`` are two predicates, ``f(a)`` and ``f(a, b)`` two functions,
141
+ ``P(f(a))`` with a predicate ``P`` and a function ``P`` two symbols, and the
142
+ guard predicate ``Car`` of a sort (arity 1) is not the predicate ``Car`` of
143
+ ``Car(x, y)`` (arity 2). A function of no arguments is the constant of its name;
144
+ a predicate of no arguments (a proposition) is not.
145
+
146
+ **A variable is a symbol of its own.** A :class:`~unicode_logic_kit.fol.nodes.Variable`
147
+ and a :class:`~unicode_logic_kit.fol.nodes.Constant` of one name are two symbols, in
148
+ every position: the quantifier of ``∀x P(x, c)`` with ``c = Constant('x')`` binds the
149
+ variable and leaves the constant alone. The Z3 symbol of the variable ``x`` is named
150
+ ``x!v`` and a constant's is named as it is, except that a constant whose name ends in
151
+ ``!v`` or ``!c`` gets ``!c`` appended (:func:`z3_constant_name`), so no constant can be
152
+ spelled like a variable's symbol, and the naming needs no state: two environments, or
153
+ two translations with no environment at all, agree on every name. ``variables_apart=False``
154
+ names a variable as it is named, like a constant; it is for a caller that has already
155
+ given every symbol of the problem a name of its own, in ONE namespace (the SMT-LIB text
156
+ routes do: their sanitiser gives every predicate, function, constant and variable a token
157
+ that no other has, and none that ends in ``!v`` or ``!c``, and they lower every counting
158
+ quantifier before they sanitise, so that the witnesses are in that namespace too) and wants
159
+ the text to hold the names it writes. A name minted for this environment by a ``to_z3``
160
+ method (the witnesses of a counting quantifier, the variable of a sort-axiom) is a
161
+ variable, so with the default naming it is a symbol ``x0!v`` that no name of a problem can
162
+ be, and needs no avoid set.
163
+
164
+ **One exception, refused.** The numeral ``Number(1)`` is written as the symbol of its
165
+ VALUE (``Number(1.0)`` is the same constant, see :func:`numeral_key`), which is also what
166
+ a constant named ``1`` is, so the two would be ONE Z3 symbol and ``P(1)`` would say the
167
+ same as ``P('1')`` (a TPTP writer refuses the pair for the same reason). The environment
168
+ remembers which kind of node first asked for a name and raises
169
+ :class:`NotImplementedError` when a numeral and a constant meet on one name. Translate
170
+ every formula of a problem through ONE environment (``to_z3(env)``) and the refusal
171
+ covers the whole problem, not only one formula.
172
+ """
173
+
174
+ def __init__(self, variables_apart: bool = True):
175
+ """Initialise empty symbol, function, and predicate tables."""
176
+ self.variables_apart = variables_apart
177
+ self.symbols: Dict[str, z3.ExprRef] = {}
178
+ self.variables: Dict[str, z3.ExprRef] = {}
179
+ self.funcs: Dict[Tuple[str, int], z3.FuncDeclRef] = {}
180
+ self.preds: Dict[Tuple[str, int], z3.FuncDeclRef] = {}
181
+ # name -> "numeral" / "name": which kind of node asked for the symbol first
182
+ self._claims: Dict[str, str] = {}
183
+
184
+ def copy(self) -> "Z3Env":
185
+ """An independent environment that knows everything this one knows."""
186
+ other = Z3Env(self.variables_apart)
187
+ other.symbols.update(self.symbols)
188
+ other.variables.update(self.variables)
189
+ other.funcs.update(self.funcs)
190
+ other.preds.update(self.preds)
191
+ other._claims.update(self._claims)
192
+ return other
193
+
194
+ def _claim(self, name: str, kind: str) -> None:
195
+ """Record that ``kind`` (``"numeral"`` or ``"name"``) uses the symbol ``name``; refuse a mixture."""
196
+ seen = self._claims.setdefault(name, kind)
197
+ if seen != kind:
198
+ numeral_constant_clash(name)
199
+
200
+ def get_symbol(self, name: str, numeral: bool = False) -> z3.ExprRef:
201
+ """Get or create the Z3 constant of the constant (or numeral) ``name``.
202
+
203
+ ``numeral=True`` is the call of :class:`Number`, whose symbol is named by the text of
204
+ its value; it is refused (``NotImplementedError``) when a constant of the same name was
205
+ met, and the other way round. A variable is not asked for here (:meth:`get_variable`).
206
+ """
207
+ self._claim(name, "numeral" if numeral else "name")
208
+ if name not in self.symbols:
209
+ check_z3_name(name)
210
+ self.symbols[name] = z3.Const(z3_constant_name(name), _SORT)
211
+ return self.symbols[name]
212
+
213
+ def get_variable(self, name: str) -> z3.ExprRef:
214
+ """Get or create the Z3 constant that stands for the variable ``name``.
215
+
216
+ A symbol of its own, apart from the constant of the same name (see the class
217
+ docstring), so a quantifier over it never captures that constant.
218
+ """
219
+ if not self.variables_apart:
220
+ return self.get_symbol(name)
221
+ if name not in self.variables:
222
+ check_z3_name(name)
223
+ self.variables[name] = z3.Const(z3_variable_name(name), _SORT)
224
+ return self.variables[name]
225
+
226
+ def get_func(self, name: str, arity: int) -> z3.FuncDeclRef:
227
+ """Get or create an uninterpreted Z3 function of the given arity mapping S^arity -> S.
228
+
229
+ Keyed on ``(name, arity)``: one name at two arities is two functions.
230
+ """
231
+ if arity == 0:
232
+ self._claim(name, "name") # a function of no arguments is a constant
233
+ key = (name, arity)
234
+ if key not in self.funcs:
235
+ check_z3_name(name)
236
+ z3_name = z3_constant_name(name) if arity == 0 else name
237
+ self.funcs[key] = z3.Function(z3_name, *([_SORT] * arity), _SORT)
238
+ return self.funcs[key]
239
+
240
+ def get_pred(self, name: str, arity: int) -> z3.FuncDeclRef:
241
+ """Get or create an uninterpreted Z3 predicate of the given arity mapping S^arity -> Bool.
242
+
243
+ Keyed on ``(name, arity)``: one name at two arities is two predicates.
244
+ """
245
+ key = (name, arity)
246
+ if key not in self.preds:
247
+ check_z3_name(name)
248
+ self.preds[key] = z3.Function(name, *([_SORT] * arity), z3.BoolSort())
249
+ return self.preds[key]
250
+
251
+
252
+ # =========================
253
+ # Base Node
254
+ # =========================
255
+
256
+ class Node:
257
+ """Base class for all AST nodes."""
258
+
259
+ def __init_subclass__(cls, **kwargs):
260
+ """Guard the new class's ``to_tptp`` (see :meth:`to_tptp`).
261
+
262
+ Whatever ``to_tptp`` the class resolves to, its own or one inherited
263
+ from a mixin, is replaced by the single-formula collision guard of
264
+ :mod:`unicode_logic_kit.fol._tptp_symbols`. This is what covers every
265
+ node family without each one being edited, and a family added later
266
+ without anyone remembering to ask.
267
+ """
268
+ super().__init_subclass__(**kwargs)
269
+ _guard_to_tptp(cls)
270
+
271
+ def _tptp_symbol(self):
272
+ """The name this node itself writes into TPTP text, or ``None``.
273
+
274
+ ``(resolver, kit name)``: the kit name in the AST and the module-level
275
+ function (:func:`_predicate_symbol`, :func:`_function_symbol`,
276
+ :func:`_constant_symbol`) that turns it into ``(namespace, word, kind)``,
277
+ the identifier :meth:`to_tptp` writes for it. Only a node that writes a
278
+ NAME, a numeral or a variable overrides this (:class:`Atom`,
279
+ :class:`Function`, :class:`Constant`, :class:`Measure`, ``SortedConstant``,
280
+ :class:`Number`, :class:`Variable`); a node that LOWERS to others
281
+ (``SortedQuantifier`` writes the guard predicate of its sort, a ``Count``
282
+ its witnesses) names nothing itself and is seen through the nodes it is
283
+ rendered as. The single-formula guard and the
284
+ problem writers' collision check both read it, which is why they cannot
285
+ disagree about what a node writes. It only NAMES the symbol (it runs once
286
+ per rendered node); the fold runs once per distinct name, when the symbols
287
+ are checked.
288
+ """
289
+ return None
290
+
291
+ def to_dict(self) -> dict:
292
+ """Serialise this node to a JSON-compatible dictionary."""
293
+ raise NotImplementedError
294
+
295
+ def to_z3(self, env: Z3Env = None) -> z3.ExprRef:
296
+ """Translate this node into a Z3 expression using the given environment."""
297
+ raise NotImplementedError
298
+
299
+ def to_prover9(self) -> str:
300
+ """Render this node as a Prover9-syntax string.
301
+
302
+ **What it sees.** The OUTERMOST call of a node that has a binder in it sees the whole
303
+ node and writes text that means it: a binder that sits inside the scope of a binder of
304
+ its own name (the free variables of the node count: Prover9 closes a formula
305
+ universally) is renamed to a fresh variable, because LADR would rename it itself, to
306
+ ``x0``, ``x1``, ... , and a constant of that spelling would then be bound by it; the
307
+ witnesses of a counting quantifier are fresh against every name of the node, of every
308
+ kind, compared case-folded (Prover9 writes a variable in upper case, so ``x0`` and
309
+ ``X0`` are one variable there); and sorted nodes are lowered first. The problem writer
310
+ makes the same preparation of every formula of a problem, with the same functions.
311
+
312
+ **What it cannot see.** It renders ONE node and has no whole-problem view.
313
+ A variable is written as the upper-case of its name, so two variables that
314
+ differ only in case are one variable in the text unless a binder is renamed:
315
+ a binder inside the scope of another is (``∀x ∃X R(x, X)`` is written
316
+ ``(all X (exists X0 R(X, X0)))``). What no renaming of a binder repairs is
317
+ refused by name instead of written as one variable: an occurrence that a
318
+ binder of another spelling encloses (a free ``x`` inside ``∀X``), and two free
319
+ variables of one upper-case name (``P(x) ∧ Q(X)``).
320
+ :func:`unicode_logic_kit.atp.prover9_entailment
321
+ .generate_prover9_input_with_mapping` checks every formula of a problem for
322
+ every such pair, harmless ones included, and refuses it by name (the check
323
+ :meth:`to_tptp` makes on its own, from :mod:`unicode_logic_kit.fol._tptp_symbols`);
324
+ build a problem with it, never by joining ``to_prover9()`` strings. A constant
325
+ or a propositional atom that
326
+ Prover9 would read as a variable (a name that begins with an upper-case
327
+ letter or an underscore) is written in double quotes, which Prover9 never
328
+ reads as a variable (see :meth:`Constant.to_prover9`); the writer renames
329
+ such a symbol instead and records the rename. For the same reason it cannot
330
+ see that one name is used for two symbols: a predicate of two arities, or one
331
+ word as a predicate and as a constant, is ONE symbol to Prover9, which
332
+ refuses the file, and the writer gives the later symbol a name of its own.
333
+ A name that is no word Prover9 reads as one symbol (a space, a non-ASCII
334
+ letter, a ``$``-word) is refused by name; the writer renames it. A numeral
335
+ that is not a digit string (``2.5``, ``-1``) is written in double quotes.
336
+ """
337
+ raise NotImplementedError
338
+
339
+ def to_tptp(self) -> str:
340
+ """Render this node as a TPTP-syntax string.
341
+
342
+ **One formula, checked.** The OUTERMOST call refuses (``NotImplementedError``,
343
+ naming both kit names and the word they share) when two DISTINCT names
344
+ of one kind inside this one formula would be written as the same TPTP
345
+ identifier. A name is written with its first character folded to
346
+ lower-case, so ``gaseous`` and ``Gaseous`` (two constants), ``Foo`` and
347
+ ``foo`` (two predicates) or ``Bar`` and ``bar`` (two functions) would
348
+ otherwise become one symbol and ``P(gaseous) <-> P(Gaseous)`` would be
349
+ written as a tautology. The check sees every name that reaches the
350
+ text, including those a reduction introduces (the sort guard predicate
351
+ of a ``SortedQuantifier``), and is installed on every node class by
352
+ :meth:`__init_subclass__`; a nested call only records.
353
+
354
+ Three more cases are written as one word, and refused the same way: a
355
+ number and a constant spelled like it (``Number(1)`` and ``Constant('1')``
356
+ are both ``1``), an arithmetic or comparison symbol and a symbol written
357
+ like it (``+`` is ``$sum``, so a function named ``$sum`` is the same
358
+ word), and two variables that are one TPTP variable (``x`` and ``X``:
359
+ ``∀x ∃X R(x, X)`` would be written ``![X]: ?[X]: r(X,X)``). A formula that
360
+ binds ``x`` in one place and ``X`` in another, even where they never meet,
361
+ is refused too, rather than analysed for scope.
362
+
363
+ A name that is written as something that is not a TPTP word is refused as
364
+ well, never written as it is: an unquoted TPTP name is a lower-case letter
365
+ followed by letters, digits and underscores, so ``has-part``,
366
+ ``2008SummerOlympics``, ``_x`` and a non-ASCII predicate or function name
367
+ (a constant is transliterated, ``θ`` is ``theta``) have no rendering. The
368
+ problem writers rewrite such a name under a legal replacement and return
369
+ the map. A word that starts with ``$`` is one of TPTP's own, and is refused
370
+ as a RESERVED word, with ONE exception: the NULLARY atoms ``$true`` and
371
+ ``$false`` are TPTP's defined propositions (this kit's TPTP reader produces
372
+ them), they are written verbatim and are no symbol of the user's, and
373
+ ``to_z3`` reads them as true and false. A VARIABLE that is written as no
374
+ TPTP variable (``ä`` is written ``Ä``, ``x-1``, ``1x``) is refused by name
375
+ too; the problem writers rename a variable, which is bound, without
376
+ recording anything.
377
+
378
+ **What it cannot see, and what is not refused.** It sees ONE formula. A
379
+ problem assembled from several ``to_tptp()`` strings can still merge
380
+ ``gaseous`` in one premise with ``Gaseous`` in another, so build a
381
+ problem with :func:`unicode_logic_kit.atp.generate_tptp_problem_with_mapping`
382
+ (or the TF0 / TFA writers), which check every premise and the
383
+ conclusion together. A predicate and a function/constant that share a
384
+ word (the class ``Agent`` and the role function ``agent``) are NOT
385
+ refused here: the text is unambiguous by position and this kit's reader
386
+ reads it back, but a prover may not, so the writers rename the term side
387
+ and return the map.
388
+
389
+ **The asymmetry is deliberate, for this release.** A name TPTP cannot
390
+ spell, or a predicate/term clash, is renamed and recorded in a
391
+ ``TptpNameMap`` by the writers; two LEGAL names of one kind that fold
392
+ together are refused by name, never renamed, here and in the writers
393
+ alike. The same-kind refusal predates the name map and stays so that no
394
+ existing caller silently receives a symbol renamed behind its back.
395
+
396
+ Raises:
397
+ NotImplementedError: two symbols written as one word, or a name
398
+ that is not a TPTP word (above), or a construct outside the
399
+ classical first-order fragment (modal, second-order,
400
+ Łukasiewicz, lambda, ...), which names itself.
401
+ """
402
+ raise NotImplementedError
403
+
404
+ @staticmethod
405
+ def from_dict(d: dict) -> "Node":
406
+ """Deserialise a node from a dictionary produced by to_dict."""
407
+ t = d["_type"]
408
+ if t not in NODE_CLASSES:
409
+ raise ValueError(f"Unknown type: {t}")
410
+ return NODE_CLASSES[t].from_dict(d)
411
+
412
+ _TREE_LABELS = {
413
+ "And": "∧", "Or": "∨", "Xor": "⊕",
414
+ "Implies": "→", "Iff": "↔", "Not": "¬",
415
+ "Contrast": "Ⓒ",
416
+ }
417
+
418
+ def _tree_parts(self):
419
+ """Return (label, children) for tree rendering.
420
+
421
+ Leaf terms render their value in the label and have no children.
422
+ Atom and Function render the symbol in the label and expose their
423
+ argument nodes. Quantifier shows its type and bound variable.
424
+ Everything else falls back to its dataclass fields, treating any
425
+ Node-valued field as a child.
426
+ """
427
+ cls = type(self).__name__
428
+ if cls in ("Variable", "Constant"):
429
+ return f"{cls}: {self.name}", []
430
+ if cls == "Number":
431
+ return f"Number: {self.value}", []
432
+ if cls == "Atom":
433
+ return f"Atom: {self.predicate}", list(self.args)
434
+ if cls == "Function":
435
+ return f"Function: {self.name}", list(self.args)
436
+ if cls == "Quantifier":
437
+ return f"{self.type} {self.variable.name}", [self.formula]
438
+
439
+ label = self._TREE_LABELS.get(cls, cls)
440
+ children = []
441
+ for f in fields(self):
442
+ value = getattr(self, f.name)
443
+ if isinstance(value, Node):
444
+ children.append(value)
445
+ elif isinstance(value, (list, tuple)):
446
+ children.extend(c for c in value if isinstance(c, Node))
447
+ return label, children
448
+
449
+ def to_unicode_str(self) -> str:
450
+ """Render this node back to a Unicode formula string.
451
+
452
+ The result, re-parsed in the matching MSFLParser mode, yields a
453
+ structurally equal AST (parser round-trip): ``parse(n.to_unicode_str())
454
+ == n`` -- for every node that has a text form (see the last paragraph:
455
+ a node that mixes sorted and unsorted occurrences has none). For the classical FOL fragment (``∀ ∃ ¬ ∧ ∨ → ↔ ⊕`` and
456
+ predicates over constants/variables — no lambda, no modal, no
457
+ second-order) this is the B2 roundtrip guarantee, exercised
458
+ example-by-example in ``tests/test_to_unicode_str.py`` and, starting
459
+ from arbitrary hand-built nodes rather than parser output, in
460
+ ``tests/test_fol_fragment_roundtrip_b2.py`` (hand-picked
461
+ parenthesisation edge cases plus a seeded randomized property
462
+ search). This is part of this method's STABLE PUBLIC API contract —
463
+ see the module docstring of ``fol/nodes.py``. The renderer lives in
464
+ _msfl_nodes.py (imported lazily to avoid a circular import) because it
465
+ dispatches over both the FOL nodes here and the MSFL/lambda nodes there.
466
+
467
+ **A node that mixes sorted and unsorted occurrences has no text form.**
468
+ The many-sorted text grammar is all-sorted or all-unsorted: a sorted
469
+ quantifier over an unsorted one (``∀x:A ∃y P(x, y)``), a constant written
470
+ ``carl:A`` in one place and plain ``carl`` in another, or an unsorted
471
+ quantifier around a sorted constant prints text that every parser refuses
472
+ (``NamingError``), in every mode. The refusal is loud: such text is never
473
+ read back as a different formula. The node itself is a legitimate formula
474
+ (``c:S`` and plain ``c`` are one constant in the kit's semantics, and an
475
+ unsorted variable ranges over the whole universe), so decide, translate
476
+ and export it as a node, or write the sort on every occurrence (or on none)
477
+ before printing it.
478
+
479
+ **A constant is written bare or in quotes.** ``Constant(name)`` is written
480
+ as the bare name when that text reads back as this constant
481
+ (``socrates``, ``c_k2``) and in single quotes otherwise (``'k2'``,
482
+ ``'Alice'``, ``'G-910'``, ``'John Doe'``), with ``'`` written ``\\'`` and
483
+ ``\\`` written ``\\\\``; a sorted constant is the same text of its name,
484
+ ``:``, and the sort (``'k2':Mountain``). So the text reads back as the node
485
+ for every constant that has a text. A name that has none is refused: the
486
+ empty name, a name with a control character, U+007F, U+0085, U+2028,
487
+ U+2029 or a surrogate (``ValueError``), and a name that is not a string
488
+ (``TypeError``). The names of functions, predicates, variables, sorts,
489
+ the subscript of a modal operator (``K_a``) and nominals have no quoted
490
+ form and are written as they are.
491
+
492
+ A route that uses the printed text of an atom as a KEY (a valuation, a
493
+ table of a model, an order, an identifier of a target) does not use this
494
+ text: it writes every constant by its bare name, as before the quoted
495
+ form existed (``key_text`` in ``_msfl_nodes.py``).
496
+
497
+ Raises:
498
+ ValueError: a constant of the node has a name that has no text.
499
+ TypeError: a constant of the node has a name that is not a string.
500
+ """
501
+ from ._msfl_nodes import _uni
502
+ return _uni(self)
503
+
504
+ def to_latex(self) -> str:
505
+ """Render this node as a LaTeX math-mode string (no surrounding $…$).
506
+
507
+ Uses the same precedence-driven parenthesisation as to_unicode_str.
508
+ Symbol/function/predicate names are emitted verbatim (no \\mathrm
509
+ wrapping). The renderer lives in _msfl_nodes.py (imported lazily) so it
510
+ can dispatch over both FOL and MSFL/lambda nodes.
511
+
512
+ A constant is written by its name here, never in quotes: the LaTeX text
513
+ of a formula that holds a constant whose name does not read back bare
514
+ (``k2``, ``Alice``, ``G-910``) does not read back as that constant,
515
+ and ``parse_latex`` does not read a quoted one. Use ``to_unicode_str``
516
+ for text that reads back.
517
+ """
518
+ from ._msfl_nodes import _latex
519
+ return _latex(self)
520
+
521
+ def to_smtlib(self) -> str:
522
+ """Render this node as a standalone SMT-LIB2 problem (one ``(assert ...)``).
523
+
524
+ A one-line delegation to :func:`unicode_logic_kit.atp.z3_input.to_smtlib`
525
+ with no premises — the general, multi-premise/sanitisation-correct
526
+ exporter promoted from :class:`~unicode_logic_kit.atp.cvc5_backend
527
+ .Cvc5Backend`'s own already-proven translation; see that function's
528
+ docstring for what "sanitisation-correct" buys over a naive
529
+ ``to_z3()`` + ``z3.Solver.to_smt2()`` combination. Imported lazily
530
+ (like :meth:`to_latex`) because ``atp.z3_input`` imports from this
531
+ module's own package at load time — mirrors how :meth:`to_z3`
532
+ already crosses the fol/atp module boundary, just one hop further.
533
+
534
+ Raises:
535
+ NotImplementedError: this node (or a descendant) uses a construct
536
+ with no first-order SMT-LIB2 encoding — the same refusal
537
+ :meth:`to_z3` raises for it.
538
+ """
539
+ from ..atp.z3_input import to_smtlib as _to_smtlib
540
+ return _to_smtlib(self)
541
+
542
+ def _repr_latex_(self) -> Optional[str]:
543
+ """Jupyter/IPython rich-display hook: LaTeX math-mode rendering.
544
+
545
+ Wraps :meth:`to_latex` in ``$$...$$`` (display math). MUST NOT raise —
546
+ IPython's formatter machinery treats an exception from a ``_repr_*_``
547
+ method as a hard failure of that cell's output, not as "fall back to
548
+ the next formatter". :meth:`to_latex` refuses loudly (``TypeError`` /
549
+ ``NotImplementedError``) for a node it cannot render, e.g. a
550
+ third-party ``Node`` subclass the LaTeX dispatcher has never heard of;
551
+ here that refusal is swallowed and reported as "no LaTeX
552
+ representation" (``None``) instead, so IPython falls back to the
553
+ plain ``repr()`` of the node rather than showing a traceback in a
554
+ notebook cell.
555
+ """
556
+ try:
557
+ return f"$${self.to_latex()}$$"
558
+ except Exception:
559
+ return None
560
+
561
+ def tree_str(self) -> str:
562
+ """Render the AST as a multi-line ASCII tree using ├──/└── connectors."""
563
+ label, children = self._tree_parts()
564
+ lines = [label]
565
+ for i, child in enumerate(children):
566
+ last = i == len(children) - 1
567
+ branch = "└── " if last else "├── "
568
+ prefix = " " if last else "│ "
569
+ sub = child.tree_str().split("\n")
570
+ lines.append(branch + sub[0])
571
+ lines.extend(prefix + s for s in sub[1:])
572
+ return "\n".join(lines)
573
+
574
+ def to_msfol(self) -> "Node":
575
+ """Lower Łukasiewicz operators to classical counterparts; recurse into children.
576
+
577
+ Classical and sort-annotated nodes return a structurally equal copy with
578
+ children recursed. Fuzzy operator subclasses override this to substitute
579
+ the corresponding classical node type.
580
+ """
581
+ return self.map_children(lambda c: c.to_msfol())
582
+
583
+ def _relativize(self, facts: list) -> "Node":
584
+ """Replace sorted nodes with plain FOL constructs; collect sort-membership atoms.
585
+
586
+ Classical nodes return a structurally equal copy with children recursed.
587
+ SortedQuantifier and SortedConstant override this with their specific rules.
588
+ Fuzzy operator subclasses override to raise RuntimeError — they must be
589
+ eliminated by to_msfol() before _relativize() is called.
590
+ """
591
+ return self.map_children(lambda c: c._relativize(facts))
592
+
593
+ # ---------------------------------------------------------------
594
+ # Traversal / inspection API
595
+ # ---------------------------------------------------------------
596
+
597
+ def _child_nodes(self) -> List["Node"]:
598
+ """Return the immediate Node-valued children, in declaration order.
599
+
600
+ Covers both single Node fields and lists of Nodes. Quantifier exposes
601
+ its bound variable here (it is a Node); for a rendering-oriented child
602
+ view see _tree_parts.
603
+ """
604
+ result: List["Node"] = []
605
+ for f in fields(self):
606
+ val = getattr(self, f.name)
607
+ if isinstance(val, Node):
608
+ result.append(val)
609
+ elif isinstance(val, (list, tuple)):
610
+ result.extend(c for c in val if isinstance(c, Node))
611
+ return result
612
+
613
+ def map_children(self, fn) -> "Node":
614
+ """Rebuild this node with ``fn`` applied to each immediate Node child.
615
+
616
+ The single point of structural recursion. Each dataclass field is
617
+ handled by kind: a Node field becomes ``fn(value)``; a list/tuple field
618
+ has ``fn`` mapped over its Node elements (non-Node elements pass
619
+ through, container kind preserved); any other field is copied verbatim.
620
+ The node type and field order are preserved.
621
+
622
+ Binders (Lambda, Quantifier, SortedQuantifier) carry their bound
623
+ variable as a plain Node field, so ``fn`` is applied to it too; callers
624
+ that must treat a binder's scope specially should handle that case
625
+ explicitly before delegating here. This is the shared engine behind the
626
+ purely structural recursions (to_msfol, _relativize, beta/eta reduction,
627
+ scope resolution, …), so a new structural node type is handled
628
+ automatically without touching each traversal.
629
+ """
630
+ new_kwargs = {}
631
+ for f in fields(self):
632
+ val = getattr(self, f.name)
633
+ if isinstance(val, Node):
634
+ new_kwargs[f.name] = fn(val)
635
+ elif isinstance(val, (list, tuple)):
636
+ new_kwargs[f.name] = type(val)(
637
+ fn(c) if isinstance(c, Node) else c for c in val
638
+ )
639
+ else:
640
+ new_kwargs[f.name] = val
641
+ return type(self)(**new_kwargs)
642
+
643
+ def walk(self):
644
+ """Yield this node and every descendant in pre-order (depth-first).
645
+
646
+ A node comes before its children and the children come left to right, in the
647
+ order of ``Node._child_nodes``. The traversal keeps its own stack, so a formula
648
+ nested thousands of levels deep is walked as readily as a shallow one: it does
649
+ not depend on the interpreter's recursion limit.
650
+ """
651
+ stack = [self]
652
+ while stack:
653
+ node = stack.pop()
654
+ yield node
655
+ stack.extend(reversed(node._child_nodes()))
656
+
657
+ def subformulas(self):
658
+ """Yield every sub-node that is a formula (i.e. not an atomic term).
659
+
660
+ Terms (Variable, Constant, Number, Function, SortedConstant, LambdaVar)
661
+ are excluded; everything else reachable is returned in pre-order.
662
+ """
663
+ return [n for n in self.walk() if type(n).__name__ not in _TERM_NAMES]
664
+
665
+ def atoms(self):
666
+ """Return all Atom nodes in pre-order (duplicates kept; comparisons included)."""
667
+ return [n for n in self.walk() if isinstance(n, Atom)]
668
+
669
+ def variables(self):
670
+ """Return the set of logical Variable nodes occurring anywhere (free and bound)."""
671
+ return {n for n in self.walk() if isinstance(n, Variable)}
672
+
673
+ def count(self, cls=None) -> int:
674
+ """Count nodes in the tree; if cls is given, only nodes of that type."""
675
+ return sum(1 for n in self.walk() if cls is None or isinstance(n, cls))
676
+
677
+ def depth(self) -> int:
678
+ """Return the height of the tree; a leaf node has depth 1."""
679
+ children = self._child_nodes()
680
+ return 1 + max((c.depth() for c in children), default=0)
681
+
682
+ def to_dot(self) -> str:
683
+ """Render the AST as a Graphviz DOT digraph string.
684
+
685
+ Uses the same label/child view as tree_str (the bound variable of a
686
+ quantifier is folded into its node label, not shown as a child), so the
687
+ graph mirrors the ASCII tree. No external dependency: returns the source.
688
+ """
689
+ lines = ["digraph AST {", " node [shape=box];"]
690
+ counter = [0]
691
+
692
+ def emit(node: "Node") -> int:
693
+ my_id = counter[0]
694
+ counter[0] += 1
695
+ label, children = node._tree_parts()
696
+ safe = label.replace("\\", "\\\\").replace('"', '\\"')
697
+ lines.append(f' n{my_id} [label="{safe}"];')
698
+ for child in children:
699
+ child_id = emit(child)
700
+ lines.append(f" n{my_id} -> n{child_id};")
701
+ return my_id
702
+
703
+ emit(self)
704
+ lines.append("}")
705
+ return "\n".join(lines)
706
+
707
+
708
+ # ``__init_subclass__`` guards every SUBCLASS; the base's own ``to_tptp`` (it
709
+ # raises, and a subclass without one inherits it) is guarded here so that every
710
+ # node class, ``Node`` included, answers ``is_guarded`` the same way.
711
+ _guard_to_tptp(Node)
712
+
713
+
714
+ # =========================
715
+ # Public tree editing (path-addressed replacement) and PATH CONVENTION
716
+ # =========================
717
+ #
718
+ # replace_at is the PUBLIC, node-type-generic counterpart to the private
719
+ # atp.resolution._replace_at (a term-only helper restricted to Atom/Function
720
+ # argument positions — see replace_at's own docstring for the exact
721
+ # difference). It is built on the same structural machinery Node.map_children
722
+ # already uses for every other whole-tree rewrite in this codebase (to_msfol,
723
+ # _relativize, beta/eta reduction, scope resolution, …).
724
+ #
725
+ # PATH CONVENTION — the one thing traversal (fol.spans.traverse), span lookup
726
+ # (fol.spans.SpanMap) and replace_at/node_at must all agree on (spec item
727
+ # A2). A path is a tuple of non-negative ints, addressing a node relative to
728
+ # some root: () addresses the root itself; (i, *rest) addresses rest inside
729
+ # the root's i-th PATH CHILD. _path_children(node) IS Node._child_nodes()
730
+ # (the SAME child order Node.walk/.map_children/.count/.depth already use)
731
+ # for every node type EXCEPT Quantifier, whose bound `variable` is excluded:
732
+ # a Quantifier's ONLY path child is its `formula`, at index 0.
733
+ #
734
+ # The exclusion is deliberate and spec-driven (fol.spans's module docstring,
735
+ # "WHY THE BOUND VARIABLE IS EXCLUDED"): a quantifier's HEAD span already
736
+ # covers its symbol together with its bound variable as one occurrence
737
+ # ("∀ x"), so exposing the variable AGAIN as a separately path-addressable
738
+ # child would double-count that one piece of source text. It mirrors how
739
+ # Node._tree_parts()/to_dot already fold the bound variable into the node's
740
+ # *label* rather than its *children* — here it is folded into the node's
741
+ # *head span* rather than its *path children*, same idea, different API.
742
+ # Scoped to Quantifier alone (not every binder — Count, Cardinality,
743
+ # SortedQuantifier, Lambda, … keep the default, unexcluded view) because
744
+ # Quantifier is the one binder the span layer's target FOL fragment covers;
745
+ # widening the exclusion to every binder is a separate decision left to
746
+ # whichever future change extends spans past that fragment.
747
+
748
+ def _path_children(node: "Node") -> List["Node"]:
749
+ """The path-addressable children of ``node``, in path-index order — see
750
+ the PATH CONVENTION comment above."""
751
+ if isinstance(node, Quantifier):
752
+ return [node.formula]
753
+ return node._child_nodes()
754
+
755
+
756
+ def node_at(root: "Node", path: Tuple[int, ...]) -> "Node":
757
+ """Return the node ``path`` addresses in ``root``'s tree (see the PATH
758
+ CONVENTION comment above ``_path_children``).
759
+
760
+ Raises ``IndexError`` if ``path`` does not address a node in this tree —
761
+ an index out of range at some prefix of ``path`` — never returns a guess.
762
+ """
763
+ node = root
764
+ for depth, idx in enumerate(path):
765
+ children = _path_children(node)
766
+ if not isinstance(idx, int) or not (0 <= idx < len(children)):
767
+ raise IndexError(
768
+ f"node_at: path {path!r} is invalid at position {depth} "
769
+ f"(index {idx!r}) — a {type(node).__name__} node has "
770
+ f"{len(children)} path child(ren) there."
771
+ )
772
+ node = children[idx]
773
+ return node
774
+
775
+
776
+ def _replace_child_at(node: "Node", index: int, new_child: "Node") -> "Node":
777
+ """Rebuild ``node`` with its ``index``-th PATH child (see
778
+ ``_path_children``) replaced by ``new_child``; every other field is
779
+ copied verbatim and every other Node child is passed through BY
780
+ REFERENCE — the exact same object, not a copy.
781
+
782
+ A :class:`Quantifier` (whose one path child is its ``formula``, NOT
783
+ ``_child_nodes()``'s ``[variable, formula]``) is rebuilt directly via
784
+ ``Quantifier(node.type, node.variable, new_child)`` rather than through
785
+ ``map_children`` — ``map_children`` is defined over ``_child_nodes()``
786
+ and applies its function to the bound variable too (see its own
787
+ docstring), which is exactly the field this path convention excludes.
788
+ Every other node type's path children ARE ``_child_nodes()``, so
789
+ ``map_children`` (with a counting closure that substitutes only the
790
+ targeted slot; every other child call returns its argument unchanged,
791
+ passed through by reference) is both correct and guaranteed consistent
792
+ with ``_path_children``'s own indexing — same field walk, not a separate
793
+ reimplementation that could drift out of sync with it.
794
+ """
795
+ if isinstance(node, Quantifier):
796
+ if index != 0:
797
+ raise IndexError(
798
+ f"replace_at: Quantifier has exactly one path child (its "
799
+ f"formula, index 0); got index {index}")
800
+ return Quantifier(node.type, node.variable, new_child)
801
+
802
+ seen = 0
803
+
804
+ def fn(child):
805
+ nonlocal seen
806
+ this_index = seen
807
+ seen += 1
808
+ return new_child if this_index == index else child
809
+
810
+ return node.map_children(fn)
811
+
812
+
813
+ def replace_at(root: "Node", path: Tuple[int, ...], new_node: "Node") -> "Node":
814
+ """Return a new tree: ``root`` with the subtree addressed by ``path``
815
+ replaced by ``new_node``. ``root`` and every node reachable from it are
816
+ left untouched — nodes are frozen dataclasses, so in-place mutation is
817
+ not even possible; ``replace_at`` only ever builds new node instances
818
+ along the spine from the root down to the replaced subtree.
819
+
820
+ ``path`` uses the PATH CONVENTION documented above ``_path_children``
821
+ (the same one :func:`node_at` and
822
+ :func:`unicode_logic_kit.fol.spans.traverse`/
823
+ :class:`~unicode_logic_kit.fol.spans.SpanMap` use) — in particular, a
824
+ :class:`Quantifier`'s bound variable is NOT reachable via any
825
+ ``replace_at`` path; its only path child is its ``formula``, at index 0.
826
+
827
+ STABILITY GUARANTEE (B1). For any path ``q`` that does not run through
828
+ the replaced subtree — ``q`` is not ``path``, not a prefix of ``path``
829
+ (an ancestor), and not extended by ``path`` (a descendant) — the node
830
+ reachable via ``q`` in the result is the SAME object (``is``) as the
831
+ node reachable via ``q`` in the original tree, because every node off
832
+ the root-to-``path`` spine is passed through by reference at each
833
+ rebuilt ancestor (see :func:`_replace_child_at`). Only the spine itself
834
+ — ``root`` and every proper prefix of ``path`` — is rebuilt (new
835
+ objects, since each now contains the replacement somewhere below it);
836
+ everything else in the tree is untouched.
837
+
838
+ Raises ``IndexError`` if any prefix of ``path`` runs off the tree — an
839
+ index that is out of range for the node's path children at that depth
840
+ (this also covers handing a non-empty path to a leaf node, whose path
841
+ children are always empty).
842
+ """
843
+ path = tuple(path)
844
+
845
+ def rec(node: "Node", depth: int) -> "Node":
846
+ if depth == len(path):
847
+ return new_node
848
+ idx = path[depth]
849
+ children = _path_children(node)
850
+ if not isinstance(idx, int) or not (0 <= idx < len(children)):
851
+ raise IndexError(
852
+ f"replace_at: path {path!r} is invalid at position {depth} "
853
+ f"(index {idx!r}) — a {type(node).__name__} node has "
854
+ f"{len(children)} path child(ren) there."
855
+ )
856
+ new_child = rec(children[idx], depth + 1)
857
+ return _replace_child_at(node, idx, new_child)
858
+
859
+ return rec(root, 0)
860
+
861
+
862
+ # Term node class names — used by Node.subformulas to exclude atomic terms.
863
+ # Measure and Cardinality are term-valued (they occur in argument position and
864
+ # are compared with </>); Cardinality additionally carries a formula child, which
865
+ # Node.walk still descends into, so its φ is still reported among subformulas.
866
+ _TERM_NAMES = frozenset({
867
+ "Variable", "Constant", "Number", "Function", "SortedConstant", "LambdaVar",
868
+ "Measure", "Cardinality",
869
+ })
870
+
871
+
872
+ # =========================
873
+ # Term Nodes
874
+ # =========================
875
+
876
+ @dataclass(frozen=True)
877
+ class Variable(Node):
878
+ """A logical variable, represented by a single lowercase letter in the grammar."""
879
+
880
+ name: str
881
+
882
+ def to_dict(self):
883
+ """Serialise to dict with type tag and variable name."""
884
+ return {"_type": "Variable", "name": self.name}
885
+
886
+ @staticmethod
887
+ def from_dict(d):
888
+ """Deserialise a Variable from a dict produced by to_dict."""
889
+ return Variable(d["name"])
890
+
891
+ def to_z3(self, env: Z3Env = None):
892
+ """Translate to a Z3 constant in the uninterpreted sort S.
893
+
894
+ A variable is a symbol of its own, apart from a constant of the same name (see
895
+ :class:`Z3Env`): the quantifier that binds ``x`` binds no constant named ``x``.
896
+ """
897
+ return (env or Z3Env()).get_variable(self.name)
898
+
899
+ def to_prover9(self) -> str:
900
+ """Render the variable name in uppercase.
901
+
902
+ The Prover9 driver enables ``set(prolog_style_variables)``, under which a
903
+ symbol that no quantifier binds is a variable only if it begins with an
904
+ uppercase letter (an underscore does not make one: measured on Prover9
905
+ 2026-8A, ``_x`` is a constant with the flag and without it). Grammar variable
906
+ names are always lowercase, so they are uppercased here; constants and
907
+ predicate/function names stay as-is.
908
+
909
+ A name with a non-ASCII character is refused: Prover9 reads ASCII only,
910
+ and upper-casing can give the SAME text for two different variables
911
+ (``ı`` and ``i`` both print as ``I``, ``ſ`` and ``s`` as ``S``, the
912
+ ligatures ``ſt`` and ``st`` as ``ST``), which would silently merge them. A name that
913
+ is no word either (``x-1``, ``1x``, ``x'``, an empty name) is refused as well: written
914
+ bare, Prover9 reads an operator or another symbol in it, and the kit's own reader
915
+ could not read the text back.
916
+ """
917
+ if not self.name.isascii():
918
+ raise NotImplementedError(
919
+ f"to_prover9: variable {self.name!r} has a non-ASCII character. "
920
+ f"Prover9 reads ASCII only, and upper-casing {self.name!r} gives "
921
+ f"{self.name.upper()!r}, which is either text Prover9 cannot read "
922
+ f"or the same text as another variable (ı and i both print as 'I'). "
923
+ f"Rename the variable to an ASCII letter followed by digits "
924
+ f"(x, y1) before exporting.")
925
+ if not _PROVER9_IDENTIFIER_RE.fullmatch(self.name):
926
+ raise NotImplementedError(
927
+ f"to_prover9: variable {self.name!r} is no word Prover9 reads as one symbol: "
928
+ f"it is written as {self.name.upper()!r}, and only letters, digits and "
929
+ f"underscores, not beginning with a digit, make a word (a '-', a '.', a quote "
930
+ f"or a leading digit is read as an operator or as another symbol). "
931
+ f"Rename the variable to an ASCII letter followed by digits "
932
+ f"(x, y1) before exporting.")
933
+ return self.name.upper()
934
+
935
+ def to_tptp(self) -> str:
936
+ """Render variable in TPTP syntax. TPTP requires variables to be uppercase; single lowercase letters are capitalized.
937
+
938
+ The outermost :meth:`Node.to_tptp` call refuses a variable whose upper-case
939
+ is no TPTP variable (``ä``, ``x-1``, ``1x``); the problem writers rename it."""
940
+ return self.name.upper()
941
+
942
+ def _tptp_symbol(self):
943
+ """The TPTP variable :meth:`to_tptp` writes: the upper-case of the name, so ``x`` and ``X`` are one."""
944
+ return (_variable_symbol, self.name)
945
+
946
+
947
+ # --------------------------------------------------------------------------- #
948
+ # Reversible ASCII transliteration for non-ASCII constant names.
949
+ #
950
+ # A constant may carry a non-ASCII (Greek) letter — e.g. a threshold ``θ`` — which
951
+ # the Kripke evaluator and Z3 handle directly (Z3 symbol names are arbitrary
952
+ # strings). The *text*-based first-order back-ends (Prover9 / TPTP) accept only
953
+ # ASCII identifiers, so on export each Greek letter (except the reserved operator
954
+ # glyphs λ / μ) maps to its conventional ASCII name and any other non-ASCII
955
+ # character uses a reversible ``uXXXX`` codepoint escape — a name is never emitted
956
+ # raw. Deterministic; invertible via :func:`constant_name_from_ascii` (exactly for a
957
+ # single-symbol constant, the realistic case).
958
+ # --------------------------------------------------------------------------- #
959
+
960
+ _GREEK_CONST_TO_ASCII = {
961
+ "α": "alpha", "β": "beta", "γ": "gamma", "δ": "delta", "ε": "epsilon",
962
+ "ζ": "zeta", "η": "eta", "θ": "theta", "ι": "iota", "κ": "kappa",
963
+ "ν": "nu", "ξ": "xi", "ο": "omicron", "π": "pi", "ρ": "rho",
964
+ "σ": "sigma", "τ": "tau", "υ": "upsilon", "φ": "phi", "χ": "chi",
965
+ "ψ": "psi", "ω": "omega",
966
+ }
967
+ _ASCII_TO_GREEK_CONST = {v: k for k, v in _GREEK_CONST_TO_ASCII.items()}
968
+ _UESC_RE = re.compile(r"u([0-9a-f]{4})")
969
+
970
+
971
+ def constant_name_to_ascii(name: str) -> str:
972
+ """Transliterate a (possibly non-ASCII) constant name to a valid ASCII identifier.
973
+
974
+ ASCII characters pass through; a Greek letter maps to its conventional name
975
+ (``θ`` → ``theta``); any other non-ASCII character becomes a reversible ``uXXXX``
976
+ codepoint escape. Deterministic; inverse is :func:`constant_name_from_ascii`.
977
+ """
978
+ out = []
979
+ for ch in name:
980
+ if ch.isascii():
981
+ out.append(ch)
982
+ elif ch in _GREEK_CONST_TO_ASCII:
983
+ out.append(_GREEK_CONST_TO_ASCII[ch])
984
+ else:
985
+ out.append("u%04x" % ord(ch))
986
+ return "".join(out)
987
+
988
+
989
+ def constant_name_from_ascii(s: str) -> str:
990
+ """Inverse of :func:`constant_name_to_ascii` for a single transliterated symbol.
991
+
992
+ Recovers the original character when ``s`` is exactly one Greek name (``theta`` →
993
+ ``θ``) or a ``uXXXX`` escape; otherwise returns ``s`` unchanged. Multi-symbol
994
+ concatenations are deterministic forward but not uniquely decodable, so only
995
+ single-symbol constants (the realistic case) are guaranteed to round-trip.
996
+ """
997
+ if s in _ASCII_TO_GREEK_CONST:
998
+ return _ASCII_TO_GREEK_CONST[s]
999
+ m = _UESC_RE.fullmatch(s)
1000
+ if m:
1001
+ return chr(int(m.group(1), 16))
1002
+ return s
1003
+
1004
+
1005
+ def _prover9_reads_as_variable(token: str) -> bool:
1006
+ """Whether Prover9, under ``set(prolog_style_variables)``, reads the symbol
1007
+ ``token`` in TERM position as a VARIABLE rather than as a constant.
1008
+
1009
+ Prover9's own rule (LADR ``ladr/symbols.c``, ``variable_name``): with that
1010
+ flag set, a symbol is a variable iff its first character is ``A``..``Z``
1011
+ (the manual: "If this flag is set, variables in clauses start with (upper
1012
+ case) 'A' through 'Z'"). It applies to every ARITY-0 symbol in term
1013
+ position (``set_vars_recurse`` converts a ``CONSTANT`` term), so a constant
1014
+ is affected; a function or predicate WITH arguments is not (the symbol is
1015
+ not a constant term, only its arguments are examined). The LADR source reads
1016
+ only ``A``..``Z``: measured on Prover9 2026-8A, ``_x`` is a constant with the
1017
+ flag and without it, and this kit's own Prover9 reader
1018
+ (:mod:`~unicode_logic_kit.fol.prover9_input`) reads it as one too. A leading
1019
+ underscore is nevertheless treated like a capital here, because the Prolog
1020
+ convention reads it as a variable and a text that another reader may take
1021
+ for a variable must not carry the name bare; the cost is a rename or a pair of
1022
+ quotes that Prover9 did not need.
1023
+ """
1024
+ first = token[:1]
1025
+ return first == "_" or ("A" <= first <= "Z")
1026
+
1027
+
1028
+ _PROVER9_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
1029
+
1030
+ #: A name Prover9 reads as ONE symbol when it is written bare: ASCII letters,
1031
+ #: digits and underscores (a digit-leading word is one symbol too: ``2nd(X)`` is
1032
+ #: read, measured on Prover9 2026-8A).
1033
+ _PROVER9_WORD_RE = re.compile(r"[A-Za-z0-9_]+")
1034
+
1035
+ #: The two words LADR reads as quantifiers. As a predicate or a function applied to a
1036
+ #: variable at the start of an operand, ``exists(X) & ...`` is read as ``exists X``
1037
+ #: followed by a stray ``&`` (measured on Prover9 2026-8A: Prover9 echoes
1038
+ #: ``(exists W exists W &(Q(c) & R(c)))`` and ends with ``symbols used with multiple
1039
+ #: arities: &/1, &/2``). A double-quoted symbol is never a keyword, so these are written
1040
+ #: in double quotes at every arity; the problem writer renames them instead.
1041
+ _PROVER9_QUANTIFIERS = frozenset({"all", "exists"})
1042
+
1043
+ #: Symbols, by name AND arity, that Prover9 or this kit's own reader of Prover9 files reads as
1044
+ #: something other than an ordinary symbol (measured on Prover9 2026-8A):
1045
+ #:
1046
+ #: * ``if`` at three arguments: LADR reads the first argument as a FORMULA, so
1047
+ #: ``(all W if(W, a, a))`` is refused ("cannot be used as atomic formulas, because they are
1048
+ #: variables: W") and ``if(a, b, c)`` makes ``a`` a relation symbol, which a constant ``a``
1049
+ #: elsewhere in the file contradicts;
1050
+ #: * ``end_of_list`` with no argument: inside a ``formulas(...)`` list the bare word ends the
1051
+ #: list, so a proposition of that name is "Unrecognized command or list";
1052
+ #: * ``formulas`` at one argument: Prover9 reads ``formulas(alpha)`` in a list as an atom, but a
1053
+ #: file reader that takes it for the header of a nested list cannot read the text back.
1054
+ #:
1055
+ #: A double-quoted symbol is a symbol of its own that none of this applies to, so a single
1056
+ #: renderer writes these in double quotes; the problem writer renames them instead.
1057
+ _PROVER9_RESERVED_SYMBOLS = frozenset({("if", 3), ("end_of_list", 0), ("formulas", 1)})
1058
+
1059
+
1060
+ def _prover9_arity_zero_symbol(token: str) -> Optional[str]:
1061
+ """The text that makes Prover9 read the arity-0 symbol ``token`` as itself: a
1062
+ constant in term position, a proposition in formula position.
1063
+
1064
+ A name that Prover9 would not read as a variable
1065
+ (:func:`_prover9_reads_as_variable`) is written as it is. One that it would
1066
+ is written in double quotes. LADR stores a double-quoted symbol WITH its
1067
+ quote characters, so the first character it tests for the variable rule is
1068
+ the quote, in every variable style; ``"Gaseous"`` is a constant and
1069
+ ``"Rain"`` a proposition, each distinct from the bare word of the same
1070
+ letters (measured on Prover9 2026-8A, with and without
1071
+ ``prolog_style_variables``). The quantifier words ``all`` and ``exists`` are
1072
+ written in double quotes too (:data:`_PROVER9_QUANTIFIERS`), and so is a word that is
1073
+ reserved at no argument (:data:`_PROVER9_RESERVED_SYMBOLS`). LADR has no escape
1074
+ inside quotes, so a name can be quoted only when it is a plain word (ASCII
1075
+ letters, digits and underscore, which excludes the quote itself). ``None`` says
1076
+ the name is no such word (it holds a space, a punctuation mark, a non-ASCII
1077
+ letter, or nothing at all): it can be written neither bare nor quoted, and the
1078
+ caller refuses it by name (:func:`_prover9_name_refusal`).
1079
+ """
1080
+ if _PROVER9_WORD_RE.fullmatch(token) is None:
1081
+ return None
1082
+ if (token in _PROVER9_QUANTIFIERS or (token, 0) in _PROVER9_RESERVED_SYMBOLS
1083
+ or _prover9_reads_as_variable(token)):
1084
+ return '"' + token + '"'
1085
+ return token
1086
+
1087
+
1088
+ def _prover9_name_refusal(what: str, name: str, written: Optional[str] = None
1089
+ ) -> NotImplementedError:
1090
+ """The refusal for a symbol name that can be written neither bare nor in quotes.
1091
+
1092
+ ``what`` says which symbol (``"the predicate"``, ``"the constant"``), ``name``
1093
+ is the kit name and ``written`` the text it was reduced to when that differs
1094
+ (a constant is transliterated first). A name that begins with ``$`` gets the
1095
+ reason that is its own: Prover9 keeps those words for itself.
1096
+ """
1097
+ if name.startswith("$"):
1098
+ return NotImplementedError(
1099
+ f"to_prover9: {what} {name!r} is a '$'-word. Prover9 keeps the words that begin "
1100
+ f"with '$' for itself (its truth constants are $T and $F), so a symbol spelled "
1101
+ f"like that is not a name of the user's, and the text would say something else; "
1102
+ f"the kit writes only the nullary atoms $true and $false, as $T and $F. Rename "
1103
+ f"the symbol before exporting it.")
1104
+ shown = f" (written {written!r})" if written is not None and written != name else ""
1105
+ return NotImplementedError(
1106
+ f"to_prover9: {what} {name!r}{shown} cannot be written for Prover9. Prover9 reads a "
1107
+ f"bare word of ASCII letters, digits and underscores as one symbol and a "
1108
+ f"double-quoted word as another, and this name is neither: it holds a space, a "
1109
+ f"punctuation mark or a non-ASCII letter, or it is empty, so any text for it would "
1110
+ f"be read as several symbols or refused. Build the problem with "
1111
+ f"unicode_logic_kit.atp.prover9_entailment.generate_prover9_input_with_mapping, which "
1112
+ f"writes such a name under an ASCII replacement and returns the map, or rename it.")
1113
+
1114
+
1115
+ def _prover9_word(name: str, what: str, arity: Optional[int] = None) -> str:
1116
+ """``name`` as the word of a predicate or function that has ``arity`` arguments, or a
1117
+ refusal by name. Such a symbol is never read as a variable, whatever its first
1118
+ letter, so only its shape matters; the quantifier words ``all`` and ``exists``, and a
1119
+ word reserved at this number of arguments (:data:`_PROVER9_RESERVED_SYMBOLS`), are the
1120
+ exception, which are written in double quotes (:data:`_PROVER9_QUANTIFIERS`)."""
1121
+ if _PROVER9_WORD_RE.fullmatch(name) is None:
1122
+ raise _prover9_name_refusal(what, name)
1123
+ if name in _PROVER9_QUANTIFIERS or (name, arity) in _PROVER9_RESERVED_SYMBOLS:
1124
+ return '"' + name + '"'
1125
+ return name
1126
+
1127
+
1128
+ #: True while the OUTERMOST ``to_prover9`` of a node writes the tree :func:`_prover9_prepared` made of
1129
+ #: it, and in every call nested below it: such a call writes its node as it is.
1130
+ _PROVER9_PREPARED: "contextvars.ContextVar[bool]" = contextvars.ContextVar(
1131
+ "unicode_logic_kit_prover9_prepared", default=False)
1132
+
1133
+
1134
+ def _prover9_scope_scan(node: "Node") -> Tuple[bool, frozenset]:
1135
+ """Whether a binder of ``node`` is read by Prover9 as one that re-binds a name, and the upper-case
1136
+ names of the free variables of ``node``. Iterative: it costs no recursion depth.
1137
+
1138
+ A binder re-binds when it sits inside the scope of a binder of its own name, or when a variable of
1139
+ its name is free somewhere in the node (Prover9 closes a formula universally, so the closure is a
1140
+ binder that every other one sits in). Names are compared as Prover9 reads them, in upper case.
1141
+ """
1142
+ free: set = set()
1143
+ binders: list = []
1144
+ rebound = False
1145
+ stack: list = [(node, frozenset())]
1146
+ while stack:
1147
+ current, bound = stack.pop()
1148
+ if isinstance(current, Variable):
1149
+ if current.name.upper() not in bound:
1150
+ free.add(current.name.upper())
1151
+ elif isinstance(current, Quantifier):
1152
+ name = current.variable.name.upper()
1153
+ rebound = rebound or name in bound
1154
+ binders.append(name)
1155
+ stack.append((current.formula, bound | {name}))
1156
+ else:
1157
+ stack.extend((child, bound) for child in current._child_nodes())
1158
+ return rebound or any(name in free for name in binders), frozenset(free)
1159
+
1160
+
1161
+ def _prover9_merged_variables(node: "Node") -> Optional[Tuple[str, str]]:
1162
+ """Two variables of ``node`` that its text would read as ONE, or ``None``. Iterative.
1163
+
1164
+ Prover9 reads a variable by the upper-case of its name, so ``x`` and ``X`` are one variable in the
1165
+ text. That is harmless where the two are bound apart (``(all X P(X)) & (all X Q(X))``), and a binder
1166
+ inside the scope of another of the same upper-case name is renamed (:func:`_prover9_prepared`). What
1167
+ is left, and what no renaming of a binder can repair, is an occurrence that the binder of ITS name
1168
+ does not enclose but a binder of the same upper-case name does (``∀X P(x)`` with a free ``x``: the
1169
+ text binds it), and two free variables of one upper-case name (``P(x) ∧ Q(X)``: two parameters
1170
+ become one). Call it on a tree whose re-bound binders are already renamed.
1171
+ """
1172
+ free: dict = {}
1173
+ stack: list = [(node, {})]
1174
+ while stack:
1175
+ current, binders = stack.pop()
1176
+ if isinstance(current, Variable):
1177
+ upper = current.name.upper()
1178
+ binder = binders.get(upper)
1179
+ if binder is None:
1180
+ first = free.setdefault(upper, current.name)
1181
+ if first != current.name:
1182
+ return first, current.name
1183
+ elif binder != current.name:
1184
+ return binder, current.name
1185
+ elif isinstance(current, Quantifier):
1186
+ stack.append((current.formula, {**binders, current.variable.name.upper(): current.variable.name}))
1187
+ else:
1188
+ stack.extend((child, binders) for child in current._child_nodes())
1189
+ return None
1190
+
1191
+
1192
+ def _prover9_refuse_merged_variables(node: "Node") -> None:
1193
+ """Refuse ``node`` by name when two of its variables would be read as one (see
1194
+ :func:`_prover9_merged_variables`); the refusal is the one the problem writer makes."""
1195
+ pair = _prover9_merged_variables(node)
1196
+ if pair is not None:
1197
+ _check_variable_names(Atom("variables", tuple(Variable(name) for name in pair)),
1198
+ where="Node.to_prover9", subject="formula", dialect="prover9")
1199
+
1200
+
1201
+ def _prover9_prepared(node: "Node") -> "Node":
1202
+ """The tree whose text means what ``node`` means: ``node`` with its binders made safe for Prover9.
1203
+
1204
+ Prover9 reads the names of a text, not the tree, and two things make it read another formula than
1205
+ the node whose text it is. LADR renames a variable that a quantifier binds inside the scope of a
1206
+ quantifier of the same name, to ``x0``, ``x1``, ... (the first that is no variable in scope), and
1207
+ takes a constant of that spelling for it: ``(all W (all W P(W, x0)))`` is clausified to
1208
+ ``P(A, A)``. And a counting witness is a name minted for the text: Prover9 writes a variable in
1209
+ upper case, so a witness ``x0`` next to a variable ``X0`` is ONE variable.
1210
+
1211
+ This is the preparation the problem writer makes of every formula of a problem, with the same two
1212
+ functions (``_lower_for_prover9`` and ``_rename_rebound_binders`` of
1213
+ ``unicode_logic_kit.atp.prover9_entailment``): sorted nodes are lowered, the witnesses of a counting
1214
+ quantifier are fresh against every name of the whole node, of every kind, compared case-folded, and
1215
+ a binder that re-binds a name (see :func:`_prover9_scope_scan`) is renamed to a fresh variable. A
1216
+ node that needs none of this is returned as it is, so a formula that holds no re-bound binder and
1217
+ no counting quantifier is written exactly as deep as it always was, and so is a node that holds a
1218
+ Lukasiewicz connective (the connective refuses itself when it is written).
1219
+
1220
+ Two variables that differ only in case and that no renaming of a binder can tell apart are refused by
1221
+ name (:func:`_prover9_merged_variables`), as the problem writer refuses them.
1222
+
1223
+ Raises:
1224
+ NotImplementedError: two variables of ``node`` would be written as one.
1225
+ """
1226
+ from ..atp.prover9_entailment import _LUKASIEWICZ_NODES, _lower_for_prover9, _rename_rebound_binders
1227
+ nodes = list(node.walk())
1228
+ names = {n.name for n in nodes if isinstance(n, Variable)}
1229
+ merged = len({name.upper() for name in names}) < len(names)
1230
+ if not any(getattr(n, "variable", None) is not None for n in nodes):
1231
+ if merged:
1232
+ _prover9_refuse_merged_variables(node)
1233
+ return node
1234
+ if any(isinstance(n, _LUKASIEWICZ_NODES) for n in nodes):
1235
+ return node
1236
+ if any(isinstance(n, Variable) and not n.name.isascii() for n in nodes):
1237
+ return node # a variable that Prover9 cannot read is refused by name when it is written
1238
+ lowering = any(isinstance(n, Count) or type(n).__name__ in ("SortedQuantifier", "SortedCount")
1239
+ for n in nodes)
1240
+ if not lowering and not _prover9_scope_scan(node)[0]:
1241
+ if merged:
1242
+ _prover9_refuse_merged_variables(node)
1243
+ return node
1244
+ avoid = set(_identifiers.symbol_names(node, fold=str.casefold))
1245
+ lowered = node
1246
+ if lowering:
1247
+ try:
1248
+ lowered = _lower_for_prover9(node, avoid)
1249
+ except NotImplementedError:
1250
+ return node # the node that cannot be written says so itself, when it is written
1251
+ rebinds, free = _prover9_scope_scan(lowered)
1252
+ prepared = _rename_rebound_binders(lowered, avoid, free) if rebinds else lowered
1253
+ if merged:
1254
+ _prover9_refuse_merged_variables(prepared)
1255
+ return prepared
1256
+
1257
+
1258
+ def _prover9_write_outermost(node: "Node") -> str:
1259
+ """Write the text of ``node`` from the tree :func:`_prover9_prepared` makes of it."""
1260
+ prepared = _prover9_prepared(node)
1261
+ token = _PROVER9_PREPARED.set(True)
1262
+ try:
1263
+ return prepared.to_prover9()
1264
+ finally:
1265
+ _PROVER9_PREPARED.reset(token)
1266
+
1267
+
1268
+ class _Prover9Entry:
1269
+ """The descriptor :func:`_prover9_outermost` puts in place of a ``to_prover9`` method.
1270
+
1271
+ ``Class.to_prover9`` is a function of the node. ``node.to_prover9`` is the method that writes the
1272
+ prepared tree (:func:`_prover9_prepared`) when no ``to_prover9`` is in progress in this context, and
1273
+ the ORIGINAL bound method when one is: a nested call costs no stack frame of its own, so a deep
1274
+ formula is written as deep as it was before. ``__wrapped__`` is the original function.
1275
+ """
1276
+
1277
+ def __init__(self, function: Callable[..., str]) -> None:
1278
+ for attribute in ("__module__", "__name__", "__qualname__", "__doc__"):
1279
+ setattr(self, attribute, getattr(function, attribute))
1280
+ self.__wrapped__ = function
1281
+ self._function = function
1282
+
1283
+ @functools.wraps(function)
1284
+ def outermost(node: Any) -> str:
1285
+ return function(node) if _PROVER9_PREPARED.get() else _prover9_write_outermost(node)
1286
+
1287
+ self._outermost = outermost
1288
+
1289
+ def __get__(self, node: Any, owner: Any = None) -> Any:
1290
+ write = self._function if _PROVER9_PREPARED.get() else self._outermost
1291
+ return write if node is None else types.MethodType(write, node)
1292
+
1293
+
1294
+ _F = TypeVar("_F", bound=Callable[..., str])
1295
+
1296
+
1297
+ def _prover9_outermost(function: _F) -> _F:
1298
+ """Mark the ``to_prover9`` of a node class whose text can hold a binder, or stand around one:
1299
+ the outermost call prepares the whole node once (:func:`_prover9_prepared`), the calls nested in
1300
+ it write their nodes as they are."""
1301
+ return cast(_F, _Prover9Entry(function))
1302
+
1303
+
1304
+ # --------------------------------------------------------------------------- #
1305
+ # TPTP name folding — the exact mirror of tptp_input.py's ``_cap()``.
1306
+ #
1307
+ # TPTP requires an unquoted identifier to start with a lower-case letter
1308
+ # (``lower_word: [a-z][A-Za-z0-9_]*``), while this kit's own Atom-predicate
1309
+ # convention requires an upper-case first letter (grammar token
1310
+ # ``PREDICATE: /[A-Z][a-zA-Z0-9]*/``). On import, ``tptp_input.py``'s
1311
+ # ``_cap()`` bridges that gap by capitalising ONLY the first character of a
1312
+ # parsed predicate name (``hasBond`` → ``HasBond``) and leaving every other
1313
+ # character untouched — never a whole-string case fold. Exporting therefore
1314
+ # has to invert exactly that: fold only the first character back to
1315
+ # lower-case, not `.lower()` the entire name. An earlier version of
1316
+ # Atom/Function/Constant.to_tptp did the latter, which is wrong two ways:
1317
+ #
1318
+ # 1. **Not the true inverse of `_cap()`.** ``_cap()`` only ever touches
1319
+ # position 0, so re-exporting a mixed-case name via whole-string
1320
+ # `.lower()` does not reproduce the original: a chemistry predicate like
1321
+ # ``BDouble`` would round-trip as ``bdouble`` → (re-imported, `_cap()`
1322
+ # applied) → ``Bdouble`` — a DIFFERENT symbol, silently.
1323
+ # 2. **Loses information `_cap()` never touched at all for Function/Constant
1324
+ # names.** Those are never case-folded on import (`_functor_name` only
1325
+ # strips quotes), so a mixed-case function/constant name such as
1326
+ # ``hasBond`` needs NO folding whatsoever — the first character is
1327
+ # already lower-case per this kit's own NAME-token convention — yet the
1328
+ # old whole-string `.lower()` mangled it to ``hasbond`` anyway.
1329
+ #
1330
+ # Folding only the first character does NOT by itself make the export
1331
+ # injective: ``Foo`` and ``foo`` still both fold to ``foo``. A collision is a
1332
+ # property of the whole SET of symbols a text contains, never of one node, so
1333
+ # it is caught where a set of symbols is known:
1334
+ #
1335
+ # * for ONE formula, by the OUTERMOST ``to_tptp()`` call — every node class's
1336
+ # ``to_tptp`` is guarded (``Node.__init_subclass__``, see
1337
+ # :mod:`unicode_logic_kit.fol._tptp_symbols`), the guard records each name the
1338
+ # render actually writes and, when the outermost call returns, refuses with
1339
+ # ``NotImplementedError`` if two DISTINCT names of one kind (predicates; or
1340
+ # functions and constants together) were written as one word. It reads what
1341
+ # is rendered, not what is in the source tree, so a name a reduction
1342
+ # introduces (the sort guard predicate of a ``SortedQuantifier``) is seen;
1343
+ # * for a PROBLEM made of several formulas, by the checked writers —
1344
+ # :func:`unicode_logic_kit.atp._tptp_problem.generate_tptp_problem` (the
1345
+ # external-prover backends), the TF0/TFA writers, and
1346
+ # :func:`unicode_logic_kit.atp.tptp_ncl.to_tptp_ncl` (the NXF modal export) —
1347
+ # which check every premise and the conclusion TOGETHER. A caller that
1348
+ # joins ``to_tptp()`` strings itself is outside every check: ``gaseous`` in
1349
+ # one premise and ``Gaseous`` in another reach the prover as one symbol, and
1350
+ # no per-formula check can see it.
1351
+ #
1352
+ # Both use the SAME check (``_tptp_symbols.check_symbols``), reading the same
1353
+ # per-node hook (``Node._tptp_symbol``), so they cannot disagree about what a
1354
+ # node writes.
1355
+ #
1356
+ # What is refused and what is renamed differ, deliberately, for this release.
1357
+ # Two LEGAL names of one kind that fold together (``Foo``/``foo``) are refused
1358
+ # by name, by both the guard and the writers: the refusal predates the writers'
1359
+ # name map, and stays so that no existing caller silently receives a symbol
1360
+ # renamed behind its back. A name TPTP cannot spell, and a predicate that
1361
+ # shares its word with a function/constant (the class ``Agent`` and the role
1362
+ # function ``agent``), are renamed by the WRITERS and recorded in the returned
1363
+ # ``TptpNameMap``. The guard does not refuse the cross-kind case: the text of
1364
+ # one formula is unambiguous by position and this kit's reader reads it back,
1365
+ # and only a writer has a map to hand back. A name TPTP cannot spell is another
1366
+ # matter for ONE formula: the guard cannot rename it and will not write it, so
1367
+ # it refuses it by name (an illegal word is not a rendering).
1368
+ # --------------------------------------------------------------------------- #
1369
+
1370
+ def tptp_fold_first_letter(name: str) -> str:
1371
+ """Fold ``name``'s first character to lower-case for TPTP export; leave the rest untouched.
1372
+
1373
+ The exact mirror of :func:`tptp_input._cap`, which capitalises only the
1374
+ first character of a parsed predicate name on import — see the module
1375
+ comment above this function for why a whole-string ``.lower()`` is wrong.
1376
+ Used by :meth:`Atom.to_tptp`, :meth:`Function.to_tptp`, and
1377
+ :meth:`Constant.to_tptp` for their predicate/function/constant name.
1378
+ """
1379
+ return (name[:1].lower() + name[1:]) if name else name
1380
+
1381
+
1382
+ # The resolvers behind ``Node._tptp_symbol``: kit name -> (namespace, word, kind),
1383
+ # exactly the word the matching ``to_tptp`` writes for it; ``None`` for a token
1384
+ # that is not an identifier. Predicates are one namespace; functions and
1385
+ # constants share the other.
1386
+
1387
+ def _predicate_symbol(name: str):
1388
+ # Equality and the comparisons are written with a token of their own
1389
+ # (``=``, ``!=``, ``$less`` ...). Such a token is not a name, but a user's
1390
+ # predicate named ``$less`` would be written as the same word.
1391
+ if name in Atom.INFIX_PREDS_TPTP:
1392
+ return ("predicate", Atom.INFIX_PREDS_TPTP[name], "reserved predicate")
1393
+ if name in Atom.PREFIX_PREDS_TPTP:
1394
+ return ("predicate", Atom.PREFIX_PREDS_TPTP[name], "reserved predicate")
1395
+ return ("predicate", tptp_fold_first_letter(name), "predicate")
1396
+
1397
+
1398
+ def _function_symbol(name: str):
1399
+ if name in Function.TPTP_ARITH_OPS:
1400
+ return ("term", Function.TPTP_ARITH_OPS[name], "reserved function")
1401
+ return ("term", tptp_fold_first_letter(name), "function")
1402
+
1403
+
1404
+ def _constant_symbol(name: str):
1405
+ return ("term", tptp_fold_first_letter(constant_name_to_ascii(name)), "constant/function")
1406
+
1407
+
1408
+ def _measure_symbol(name: str):
1409
+ return ("term", name, "function")
1410
+
1411
+
1412
+ def _variable_symbol(name: str):
1413
+ return ("variable", name.upper(), "variable")
1414
+
1415
+
1416
+ def _numeral_symbol(value):
1417
+ # The word is the text the number is written as, which is also what a number
1418
+ # is called in a refusal. A value has one spelling (``Number(1.0)`` is
1419
+ # ``Number(1)``), so the value alone keys the entry of the render log.
1420
+ text = _number_text(value)
1421
+ return ("term", text, "numeral", text)
1422
+
1423
+
1424
+ @dataclass(frozen=True)
1425
+ class Constant(Node):
1426
+ """A ground constant, produced by a bare NAME, a ``c_``-prefixed CONSTANT, a
1427
+ non-ASCII (Greek, e.g. ``θ``) CONSTANT token, or a quoted name (``'k2'``).
1428
+
1429
+ The name may contain non-ASCII letters; the Kripke evaluator and Z3 use them
1430
+ directly, while the ASCII-only Prover9 / TPTP exporters transliterate them via
1431
+ :func:`constant_name_to_ascii` (``θ`` → ``theta``).
1432
+
1433
+ Every constant has a text of its own in the kit's syntax, whatever its name
1434
+ (the empty name and a name with a control character excepted: those are refused
1435
+ when printed). ``to_unicode_str`` writes the name bare when the bare word reads
1436
+ back as this constant (``socrates``, ``c_k2``, ``θ``) and in single quotes when
1437
+ it does not: ``'k2'`` (a bare ``k2`` is a variable), ``'Alice'``, ``'G-910'``,
1438
+ ``'John Doe'``, ``'1'`` (a bare ``1`` is a number). Inside the quotes ``'`` is
1439
+ written ``\\'`` and ``\\`` is written ``\\\\``. So the text of a formula reads
1440
+ back as the formula, for a hand-built constant as for a parsed one. The text a
1441
+ route uses as a KEY (a valuation, a model table) writes the bare name instead,
1442
+ as it always did. In the text of a formula the constant ``'a'`` and the
1443
+ variable ``a`` are therefore told apart."""
1444
+
1445
+ name: str
1446
+
1447
+ def to_dict(self):
1448
+ """Serialise to dict with type tag and constant name."""
1449
+ return {"_type": "Constant", "name": self.name}
1450
+
1451
+ @staticmethod
1452
+ def from_dict(d):
1453
+ """Deserialise a Constant from a dict produced by to_dict."""
1454
+ return Constant(d["name"])
1455
+
1456
+ def to_z3(self, env: Z3Env = None):
1457
+ """Translate to a Z3 constant in the uninterpreted sort S (Z3 accepts the raw name)."""
1458
+ return (env or Z3Env()).get_symbol(self.name)
1459
+
1460
+ def to_prover9(self) -> str:
1461
+ """Render the constant name, transliterating any non-ASCII to ASCII (Prover9 is ASCII-only).
1462
+
1463
+ A name that begins with an upper-case letter or an underscore
1464
+ (:func:`_prover9_reads_as_variable`) is written in double quotes:
1465
+ ``Gaseous`` is written ``"Gaseous"``. Every Prover9 file this kit writes
1466
+ sets ``prolog_style_variables``, under which the bare word in term
1467
+ position is a VARIABLE when it begins with an upper-case letter, so
1468
+ ``P(Gaseous)`` would read as ``∀X P(X)`` and the text would denote a
1469
+ different formula (an underscore-initial name is a constant to Prover9
1470
+ itself, and is quoted all the same, because the Prolog convention reads
1471
+ it as a variable); a double-quoted symbol is
1472
+ never a variable and is a symbol of its own, distinct from the bare word
1473
+ of the same letters. The kit's own Prover9 reader reads it back as this
1474
+ constant.
1475
+
1476
+ Raises:
1477
+ NotImplementedError: the name would be read as a variable and cannot
1478
+ be quoted, because it holds a character other than a letter, a
1479
+ digit or an underscore (LADR has no escape for a double quote
1480
+ inside quotes). A single node cannot rename (a rename must be the
1481
+ same in every formula of the problem and stay injective), so the
1482
+ refusal points at the problem writer, which does. A name that is
1483
+ no word at all (a space, a dot, a ``$``-word, empty) is refused the
1484
+ same way, whatever its first letter: written bare it would be read
1485
+ as several symbols or as one of Prover9's own.
1486
+ """
1487
+ text = constant_name_to_ascii(self.name)
1488
+ quoted = _prover9_arity_zero_symbol(text)
1489
+ if quoted is not None:
1490
+ return quoted
1491
+ if _prover9_reads_as_variable(text):
1492
+ raise NotImplementedError(
1493
+ f"to_prover9: constant {self.name!r} cannot be written for Prover9 on its "
1494
+ f"own: it would be read as a variable, and it cannot be put in double "
1495
+ f"quotes (which Prover9 never reads as a variable) because it holds a "
1496
+ f"character other than a letter, a digit or an underscore. Every "
1497
+ f"Prover9 file this kit writes sets prolog_style_variables, "
1498
+ f"under which a term-position symbol that begins with an upper-case "
1499
+ f"letter or an underscore is a VARIABLE: the text {text!r} would read "
1500
+ f"as a variable, and the formula around it would say something else "
1501
+ f"('P({text})' reads as 'for all X, P(X)'). Build the problem with "
1502
+ f"unicode_logic_kit.atp.prover9_entailment.generate_prover9_input_with_mapping "
1503
+ f"(check_logical_entailment and the Prover9 backend use it), which renames "
1504
+ f"such a constant to a lower-case token and returns the mapping, or name "
1505
+ f"the constant with a lower-case first letter.")
1506
+ raise _prover9_name_refusal("the constant", self.name, text)
1507
+
1508
+ def to_tptp(self) -> str:
1509
+ """Render constant in TPTP syntax (ASCII, lowercase-initial): transliterate, then fold the first letter.
1510
+
1511
+ Only the first character is folded to lower-case (see
1512
+ :func:`tptp_fold_first_letter`) — everything from the second character
1513
+ on is emitted verbatim, so a mixed-case constant name (e.g. a
1514
+ chemistry identifier like ``hasBond``) survives export unmangled.
1515
+ """
1516
+ return tptp_fold_first_letter(constant_name_to_ascii(self.name))
1517
+
1518
+ def _tptp_symbol(self):
1519
+ """The constant word :meth:`to_tptp` writes (shares the term namespace with functions)."""
1520
+ return (_constant_symbol, self.name)
1521
+
1522
+
1523
+ def _number_text(value) -> str:
1524
+ """The text a :class:`Number` prints as in every textual syntax of the kit
1525
+ (unicode, LaTeX, TPTP, Prover9), and that the kit's own readers read back as
1526
+ the SAME value.
1527
+
1528
+ The NUMBER terminal of every reader is ``-?[0-9]+(\\.[0-9]+)?``: digits, an
1529
+ optional fractional part, no exponent. Python's ``str(1e-07)`` is ``'1e-07'``,
1530
+ which the unicode reader reads as the subtraction ``1e - 07`` and
1531
+ ``str(1.5e-05)`` is not text it can read at all. So a float whose ``repr`` is
1532
+ in exponent form is written in plain positional notation instead, from the
1533
+ digits of that same ``repr`` (the shortest string that round-trips) with
1534
+ decimal arithmetic, never a rounding format: ``float(text) == value`` exactly.
1535
+ A float always keeps a ``.`` so it reads back as a float, not an int
1536
+ (``1e16`` is ``10000000000000000.0``). An int, and every float whose ``repr``
1537
+ is already positional (``2.5``, ``12345.678``, ``0.0001``), prints exactly as
1538
+ before. A :class:`Number` never holds a float with a whole value (it stores the
1539
+ integer it equals, so ``Number(1e16)`` prints ``10000000000000000``); the point
1540
+ of such a raw float is kept only for a caller that hands a bare float to this
1541
+ function.
1542
+
1543
+ Raises:
1544
+ ValueError: ``value`` is ``inf``, ``-inf`` or ``nan``. No syntax of the
1545
+ kit has a literal for it, and the word ``inf`` would read back as a
1546
+ CONSTANT of that name, a different formula.
1547
+ """
1548
+ if isinstance(value, float):
1549
+ if value != value or value in (float("inf"), float("-inf")):
1550
+ raise ValueError(
1551
+ f"Number({value!r}) has no literal: a non-finite float cannot be "
1552
+ f"written in the unicode, LaTeX, TPTP or Prover9 syntax, and the "
1553
+ f"word {str(value)!r} would read back as a constant of that name, "
1554
+ f"not as a number. Use a finite value (or a constant such as "
1555
+ f"'infinity' for a symbolic bound).")
1556
+ text = repr(float(value))
1557
+ if "e" in text or "E" in text:
1558
+ text = format(Decimal(text), "f")
1559
+ if "." not in text:
1560
+ text += ".0"
1561
+ return text
1562
+ return str(value)
1563
+
1564
+
1565
+ #: The most significant digits a decimal text may have and still be read as the float it spells.
1566
+ _DECIMAL_DIGITS_READ_EXACTLY = 15
1567
+
1568
+ #: The smallest positive normal double (``sys.float_info.min``): below it the doubles carry fewer
1569
+ #: than 53 significant bits.
1570
+ _SMALLEST_NORMAL_DOUBLE = 2.2250738585072014e-308
1571
+
1572
+
1573
+ def _numeral_from_text(text: str) -> Union[int, float]:
1574
+ """The value of a decimal numeral as it is written, ``-?[0-9]+(\\.[0-9]+)?``: read exactly or refused.
1575
+
1576
+ This is the ONE reading every text reader of the kit gives a NUMBER token, the inverse of
1577
+ :func:`_number_text`. A text without a point is the ``int`` of its digits, and so is a text
1578
+ with a point whose fractional digits are all zero, whatever its size
1579
+ (``100000000000000000000000.0`` is 10**23, where ``float`` would give the nearest double,
1580
+ ``99999999999999991611392``). Any other decimal is the ``float`` it spells when it has at most
1581
+ 15 significant digits, counted after the sign, the leading zeros and the trailing zeros of the
1582
+ fraction are dropped (``0.1`` and ``0.10`` are one numeral, ``3.14159265358979`` is read), and
1583
+ is refused when it has more (``3.141592653589793``).
1584
+
1585
+ Fifteen is the bound because two different decimals of at most 15 significant digits differ by
1586
+ at least 1e-15 of the larger one, while two numbers that are one double differ by at most
1587
+ 2**-52 (about 2.2e-16) of it, so no two such decimals are one float. With 16 digits the gap can
1588
+ be 1e-16 of the number, below the spacing of the doubles, and two decimals can be one float
1589
+ (``8.000000000000001`` and ``8.000000000000002``, ``0.30000000000000004`` and
1590
+ ``0.30000000000000005``).
1591
+
1592
+ Raises:
1593
+ ValueError: the decimal has more than 15 significant digits (two different decimals of that
1594
+ length can be one float, and a numeral is identified by its value, so reading it as a
1595
+ float could make two numerals one), or is nearer to zero than the smallest normal
1596
+ double (2.2250738585072014e-308), where a double holds fewer than 15 digits and a
1597
+ different decimal can be the same double, or zero. The message names the numeral and
1598
+ says why; a numeral is never read as another one.
1599
+ """
1600
+ whole, point, fraction = text.partition(".")
1601
+ if not point:
1602
+ return int(text)
1603
+ if not fraction.strip("0"):
1604
+ return int(whole)
1605
+ digits = len((whole.lstrip("+-") + fraction.rstrip("0")).lstrip("0"))
1606
+ if digits > _DECIMAL_DIGITS_READ_EXACTLY:
1607
+ raise ValueError(
1608
+ f"the numeral {text} has {digits} significant digits, more than the "
1609
+ f"{_DECIMAL_DIGITS_READ_EXACTLY} that a floating-point number tells apart: two different "
1610
+ f"decimals of that length can be one float (0.30000000000000004 and 0.30000000000000005 "
1611
+ f"are), and a numeral is identified by its value, so reading it as a float could make "
1612
+ f"two numerals one. Write it with at most {_DECIMAL_DIGITS_READ_EXACTLY} significant "
1613
+ f"digits, or as an integer")
1614
+ value = float(text)
1615
+ if abs(value) < _SMALLEST_NORMAL_DOUBLE:
1616
+ raise ValueError(
1617
+ f"the numeral {text} is so close to zero that a floating-point number cannot hold "
1618
+ f"{_DECIMAL_DIGITS_READ_EXACTLY} digits of it (the nearest float is {value!r}), and "
1619
+ f"reading it as that number could make two different numerals one. Write it as 0, or "
1620
+ f"with a larger magnitude")
1621
+ return value
1622
+
1623
+
1624
+ class NumeralTextError(ParsingError):
1625
+ """A numeral the unicode reader cannot read as the number it was written as.
1626
+
1627
+ A :class:`~unicode_logic_kit.fol.naming.ParsingError`, so the CLI, ``api.parse_any`` and every
1628
+ caller that catches the parser's error type report it as the one-line SYNTAX_ERROR it is. It
1629
+ is constructed directly from the message of :func:`_numeral_from_text`, not from a Lark
1630
+ exception, so it sets its own message.
1631
+ """
1632
+
1633
+ def __init__(self, message: str):
1634
+ self.args = (f"SYNTAX_ERROR: {message}",)
1635
+
1636
+ def __str__(self):
1637
+ return self.args[0]
1638
+
1639
+
1640
+ @dataclass(frozen=True)
1641
+ class Number(Node):
1642
+ """A numeral, produced by the NUMBER terminal of the grammar: a constant identified by its VALUE.
1643
+
1644
+ On every route that was not asked for arithmetic by name a numeral is an ordinary constant
1645
+ and nothing else is known about it: two numerals of different value may denote the same
1646
+ element (``1 ≠ 2`` is not valid), ``+ - * /`` are uninterpreted function symbols and
1647
+ ``< > ≤ ≥`` uninterpreted predicates. The arithmetic reading is asked for by name (the
1648
+ ``*_arith`` functions and ``sort="int"`` / ``sort="real"``); there ``Number(3)`` is the
1649
+ integer 3, or the real 3.0 under ``sort="real"``.
1650
+
1651
+ There is ONE constant per value and ONE spelling per value. A float whose value is a whole
1652
+ number is stored as the ``int`` it equals: ``Number(1.0)`` IS ``Number(1)``, with the same
1653
+ ``value``, the same ``repr``, the same ``to_dict`` and the same printed text (``1``) in every
1654
+ syntax, and ``Number(-0.0)`` is ``Number(0)``. A value that is no whole number keeps its type
1655
+ (``Number(2.5)`` is a float). A float too large to have a fractional part is the integer it
1656
+ exactly is (the double nearest ``1e23`` is ``99999999999999991611392``).
1657
+
1658
+ The readers read a decimal text exactly or refuse it. One whose fractional digits are all zero
1659
+ is the integer it spells (``100000000000000000000000.0`` is ``10**23``, not that double), any
1660
+ other is the float it spells when it has at most 15 significant digits (``0.1`` and ``0.10``
1661
+ are one numeral), and one with more is refused by name, because two different decimals of 16
1662
+ digits or more can be one float (``0.30000000000000004`` and ``0.30000000000000005`` are) and
1663
+ a numeral is identified by its value.
1664
+
1665
+ Numerals of equal value are equal nodes and print alike, so a route that keys an atom by its
1666
+ printed text reads ``P(1)`` and ``P(1.0)`` as the one atom they are. A ``bool`` is no number:
1667
+ it is kept as it is, not read as ``1`` or ``0``, and the routes that need an integer refuse
1668
+ it by name.
1669
+
1670
+ Fields:
1671
+
1672
+ * ``value`` -- the number: an ``int``, or a ``float`` that is not a whole number (a
1673
+ non-finite float is stored as it is; no syntax of the kit has a literal for it).
1674
+ """
1675
+
1676
+ value: Union[int, float]
1677
+
1678
+ def __post_init__(self):
1679
+ """Store a float with a whole value as the ``int`` it equals: one numeral, one spelling."""
1680
+ value = self.value
1681
+ if isinstance(value, float) and value.is_integer():
1682
+ object.__setattr__(self, "value", int(value))
1683
+
1684
+ def to_dict(self):
1685
+ """Serialise to dict with type tag and numeric value (an integral value is an ``int``)."""
1686
+ return {"_type": "Number", "value": self.value}
1687
+
1688
+ @staticmethod
1689
+ def from_dict(d):
1690
+ """Deserialise a Number from a dict produced by to_dict."""
1691
+ return Number(d["value"])
1692
+
1693
+ def to_z3(self, env: Z3Env = None):
1694
+ """Encode the number as a named constant in the uninterpreted sort S.
1695
+
1696
+ The symbol is named by the VALUE of the number (:func:`numeral_key`), so
1697
+ ``Number(1)`` and ``Number(1.0)`` are one constant, and a constant of that very
1698
+ name would be the same symbol; the environment refuses the pair (see
1699
+ :class:`Z3Env`) with a ``NotImplementedError``. Nothing else is known about a
1700
+ numeral: ``1`` and ``2`` may denote the same element.
1701
+ """
1702
+ return (env or Z3Env()).get_symbol(numeral_key(self.value), numeral=True)
1703
+
1704
+ def to_prover9(self) -> str:
1705
+ """Render the numeral as ONE Prover9 constant: its value in double quotes.
1706
+
1707
+ A numeral is a constant identified by its VALUE, so ``Number(1)`` and
1708
+ ``Number(1.0)`` -- equal nodes -- are one symbol, ``"1"``; ``2.5`` is
1709
+ ``"2.5"`` and ``-1`` is ``"-1"``, the positional text of the number (see
1710
+ :func:`~unicode_logic_kit.fol._numeral_symbols.numeral_name`: an integral
1711
+ float is spelled as the integer, nothing is written in exponent form).
1712
+ The kit's Prover9 reader reads that quoted text back as the
1713
+ :class:`Number`.
1714
+
1715
+ Every numeral is quoted, also a non-negative integer, for three reasons
1716
+ measured on Prover9 and Mace4 2026-8A. Bare, ``2.5`` ends the statement
1717
+ (Prover9 refuses the file) and ``-1`` is the function ``-`` applied to the
1718
+ constant ``1``. And Mace4 reads a bare integer as a domain element of its own,
1719
+ all of them pairwise distinct (``1 != 2`` has no countermodel at any size it
1720
+ searched, and the smallest model of ``P(1) & P(2)`` has three elements), whereas
1721
+ the kit's numerals are ordinary constants that may denote one thing (``⊢ 1 ≠ 2``
1722
+ is not valid); a quoted symbol is a plain constant to both tools. Prover9 has no arithmetic: ``+ - * /`` and
1723
+ ``< > ≤ ≥`` are uninterpreted symbols, as they are for the Z3 route.
1724
+ """
1725
+ from ._numeral_symbols import numeral_name
1726
+ return '"' + numeral_name(self.value) + '"'
1727
+
1728
+ def to_tptp(self) -> str:
1729
+ """Render number in TPTP syntax as an integer or rational literal, a float in positional notation (see :func:`_number_text`).
1730
+
1731
+ This is the ARITHMETIC spelling: a bare TPTP number is a literal of the prover's own
1732
+ arithmetic (Vampire and E type ``1`` as ``$int``, so ``p(1)`` is a type error for a
1733
+ predicate over individuals, and ``1 != 2`` is a theorem). On every route that was not
1734
+ asked for arithmetic the kit reads a numeral as a CONSTANT identified by its value, and a
1735
+ PROBLEM for a prover is written by the checked writers
1736
+ (:func:`~unicode_logic_kit.atp._tptp_problem.generate_tptp_problem_with_mapping`,
1737
+ :func:`~unicode_logic_kit.atp.tptp_tff.generate_tff_problem_with_mapping`), which write the
1738
+ numeral as an ordinary constant of a word of their own and record it in the name map.
1739
+ Use this method for the text of ONE formula, never to assemble a problem.
1740
+ """
1741
+ return _number_text(self.value)
1742
+
1743
+ def _tptp_symbol(self):
1744
+ """The numeral :meth:`to_tptp` writes, which is also the word of a constant spelled like it."""
1745
+ return (_numeral_symbol, self.value)
1746
+
1747
+
1748
+ @dataclass(frozen=True)
1749
+ class Function(Node):
1750
+ """A function application node, covering both named functions and arithmetic operators."""
1751
+
1752
+ name: str
1753
+ args: Tuple[Node, ...]
1754
+
1755
+ def __post_init__(self):
1756
+ """Coerce args to a tuple so this frozen node is hashable."""
1757
+ if not isinstance(self.args, tuple):
1758
+ object.__setattr__(self, "args", tuple(self.args))
1759
+
1760
+ INFIX_OPS = {"+", "-", "*", "/"}
1761
+
1762
+ def to_dict(self):
1763
+ """Serialise to dict with type tag, function name, and recursively serialised arguments."""
1764
+ return {
1765
+ "_type": "Function",
1766
+ "name": self.name,
1767
+ "args": [a.to_dict() for a in self.args]
1768
+ }
1769
+
1770
+ @staticmethod
1771
+ def from_dict(d):
1772
+ """Deserialise a Function from a dict produced by to_dict."""
1773
+ return Function(d["name"], [Node.from_dict(a) for a in d["args"]])
1774
+
1775
+ def to_z3(self, env: Z3Env = None):
1776
+ """Translate to an uninterpreted Z3 function application in sort S."""
1777
+ env = env or Z3Env()
1778
+ z3_args = [a.to_z3(env) for a in self.args]
1779
+ func = env.get_func(self.name, len(self.args))
1780
+ return func(*z3_args)
1781
+
1782
+ def to_prover9(self) -> str:
1783
+ """Render in Prover9 syntax, using infix notation for ``+``, ``*`` and ``/``.
1784
+
1785
+ Prover9 has no infix minus: ``(a - b)`` is a syntax error there (measured on
1786
+ 2026-8A), so a binary ``-`` is written in functional notation, ``-(a, b)``,
1787
+ the same symbol that ``-(a)`` is at one argument. None of these symbols is
1788
+ interpreted by Prover9, as none is by the Z3 route: they are uninterpreted
1789
+ functions. A function with no arguments is a constant (see
1790
+ :meth:`Constant.to_prover9`).
1791
+
1792
+ Raises:
1793
+ NotImplementedError: the name is no word Prover9 reads as one symbol (a
1794
+ space, a dot, a non-ASCII letter, a ``$``-word, an arithmetic
1795
+ symbol at a number of arguments it has no notation for); the problem
1796
+ writer renames such a name.
1797
+ """
1798
+ if self.name in self.INFIX_OPS and len(self.args) == 2:
1799
+ left = self.args[0].to_prover9()
1800
+ right = self.args[1].to_prover9()
1801
+ if self.name == "-":
1802
+ return f"-({left}, {right})"
1803
+ return f"({left} {self.name} {right})"
1804
+ if self.name == "-" and len(self.args) == 1:
1805
+ return f"-({self.args[0].to_prover9()})"
1806
+ if not self.args:
1807
+ return Constant(self.name).to_prover9()
1808
+
1809
+ name = _prover9_word(self.name, "the function", len(self.args))
1810
+ args_str = ", ".join(a.to_prover9() for a in self.args)
1811
+ return f"{name}({args_str})"
1812
+
1813
+ TPTP_ARITH_OPS = {
1814
+ "+": "$sum",
1815
+ "-": "$difference",
1816
+ "*": "$product",
1817
+ "/": "$quotient",
1818
+ }
1819
+
1820
+ def to_tptp(self) -> str:
1821
+ """Render function application in TPTP syntax.
1822
+
1823
+ Arithmetic operators (``+``, ``-``, ``*``, ``/``) are mapped to their
1824
+ TPTP dollar-word equivalents (``$sum``, ``$difference``, ``$product``,
1825
+ ``$quotient``) and emitted in
1826
+ prefix notation. All other functions are emitted as identifiers with
1827
+ a parenthesised argument list, with only the first character folded
1828
+ to lower-case (see :func:`tptp_fold_first_letter`) — a mixed-case
1829
+ function name is otherwise preserved verbatim.
1830
+
1831
+ The dollar-words are the ARITHMETIC spelling: a prover reads ``$sum(1,1) = 2`` as a
1832
+ theorem of its own arithmetic. On every route that was not asked for arithmetic the kit
1833
+ reads ``+ - * /`` as uninterpreted function symbols, and a PROBLEM for a prover is
1834
+ written by the checked writers
1835
+ (:func:`~unicode_logic_kit.atp._tptp_problem.generate_tptp_problem_with_mapping`,
1836
+ :func:`~unicode_logic_kit.atp.tptp_tff.generate_tff_problem_with_mapping`), which write
1837
+ the operator as an ordinary function of a word of their own and record it in the name
1838
+ map; the typed arithmetic writer
1839
+ (:func:`~unicode_logic_kit.atp._tff_problem.generate_tff_arith_problem`) keeps the
1840
+ dollar-words, typed ``$int`` or ``$real``. Use this method for the text of ONE formula,
1841
+ never to assemble a problem.
1842
+
1843
+ A function with no arguments is the constant of its name, and is written as that
1844
+ constant (:meth:`Constant.to_tptp`): TPTP has no empty argument list, ``f()`` is no
1845
+ term, and a prover stops at it with a parse error. The refusals of the constant apply to
1846
+ it, so an arithmetic symbol with no argument (``+``) is refused by name rather than
1847
+ written as ``$sum()``.
1848
+ """
1849
+ if not self.args:
1850
+ return Constant(self.name).to_tptp()
1851
+ args_str = ",".join(a.to_tptp() for a in self.args)
1852
+ tptp_name = self.TPTP_ARITH_OPS.get(self.name, tptp_fold_first_letter(self.name))
1853
+ return f"{tptp_name}({args_str})"
1854
+
1855
+ def _tptp_symbol(self):
1856
+ """The word :meth:`to_tptp` writes: the function word, or for a function with no arguments the word of the constant of its name (an arithmetic operator is a fixed ``$``-word)."""
1857
+ if not self.args:
1858
+ return (_constant_symbol, self.name)
1859
+ return (_function_symbol, self.name)
1860
+
1861
+
1862
+ # =========================
1863
+ # Formula Nodes
1864
+ # =========================
1865
+
1866
+ @dataclass(frozen=True)
1867
+ class Atom(Node):
1868
+ """An atomic formula: either a named predicate application or an infix comparison."""
1869
+
1870
+ predicate: str
1871
+ args: Tuple[Node, ...]
1872
+
1873
+ def __post_init__(self):
1874
+ """Coerce args to a tuple so this frozen node is hashable."""
1875
+ if not isinstance(self.args, tuple):
1876
+ object.__setattr__(self, "args", tuple(self.args))
1877
+
1878
+ INFIX_PREDS_P9 = {
1879
+ "=": "=", "<": "<", ">": ">",
1880
+ "≤": "<=", "≥": ">=", "≠": "!=",
1881
+ }
1882
+
1883
+ def to_dict(self):
1884
+ """Serialise to dict with type tag, predicate name, and recursively serialised arguments."""
1885
+ return {
1886
+ "_type": "Atom",
1887
+ "predicate": self.predicate,
1888
+ "args": [a.to_dict() for a in self.args]
1889
+ }
1890
+
1891
+ @staticmethod
1892
+ def from_dict(d):
1893
+ """Deserialise an Atom from a dict produced by to_dict."""
1894
+ return Atom(d["predicate"], [Node.from_dict(a) for a in d["args"]])
1895
+
1896
+ def to_z3(self, env: Z3Env = None):
1897
+ """Translate to a Z3 boolean expression.
1898
+
1899
+ Equality and disequality map to native Z3 operators; all other
1900
+ predicates become uninterpreted Z3 functions returning Bool. The nullary
1901
+ atoms ``$true`` and ``$false`` (TPTP's defined propositions, which this
1902
+ kit's TPTP reader produces) are the constants true and false, so z3 and a
1903
+ TPTP prover answer the question the TPTP text asks; the nullary atoms named
1904
+ ``⊤`` and ``⊥`` are the same two constants.
1905
+ """
1906
+ env = env or Z3Env()
1907
+ if _is_tptp_boolean_atom(self):
1908
+ return z3.BoolVal(_truth_constant_word(self) == "$true")
1909
+ z3_args = [a.to_z3(env) for a in self.args]
1910
+
1911
+ if self.predicate == "=" and len(self.args) == 2:
1912
+ return z3_args[0] == z3_args[1]
1913
+ if self.predicate == "≠" and len(self.args) == 2:
1914
+ return z3_args[0] != z3_args[1]
1915
+
1916
+ pred = env.get_pred(self.predicate, len(self.args))
1917
+ return pred(*z3_args)
1918
+
1919
+ def to_prover9(self) -> str:
1920
+ """Render in Prover9 syntax, using infix notation for comparison predicates.
1921
+
1922
+ A nullary predicate renders as a propositional atom without an argument
1923
+ list; Prover9 rejects an empty one (``P()``). The nullary atoms ``$true``
1924
+ and ``$false`` (TPTP's defined propositions, see :meth:`to_z3`) are
1925
+ Prover9's constants ``$T`` and ``$F``.
1926
+
1927
+ A predicate whose name is no word (a space, a dot, a non-ASCII letter, a
1928
+ ``$``-word, empty) is refused by name, at every arity: written bare it would
1929
+ be read as several symbols or as one of Prover9's own, and it cannot be
1930
+ quoted. A comparison symbol at a number of arguments other than two is
1931
+ refused the same way.
1932
+
1933
+ A nullary predicate that begins with an upper-case letter or an underscore
1934
+ — ``Rain``, the usual spelling of a proposition in this kit — is written in
1935
+ double quotes, ``"Rain"``. Every Prover9 file this kit writes sets
1936
+ ``prolog_style_variables``, under which an arity-0 symbol that begins with
1937
+ an upper-case letter is a VARIABLE, an atom with no arguments included
1938
+ (measured on Prover9 2026-8A: the bare ``Rain`` is refused as "cannot be
1939
+ used as atomic formulas, because they are variables"). A double-quoted
1940
+ symbol is never a variable and is a symbol of its own, distinct from the
1941
+ bare word of the same letters; the kit's own Prover9 reader reads it back
1942
+ as this atom. A lower-case nullary predicate is written bare. The same
1943
+ word used both as a proposition and as a constant, or as a predicate of
1944
+ two arities, is one symbol to Prover9,
1945
+ which refuses the file; this method has no view of the other formulas and
1946
+ cannot see that. The problem writer (:func:`unicode_logic_kit.atp
1947
+ .prover9_entailment.generate_prover9_input_with_mapping`) renames such
1948
+ symbols to lower-case tokens, one per role, and records the renaming; text
1949
+ for Prover9 is built with the writer, not by joining ``to_prover9()``
1950
+ strings.
1951
+ """
1952
+ if self.predicate in self.INFIX_PREDS_P9 and len(self.args) == 2:
1953
+ left = self.args[0].to_prover9()
1954
+ right = self.args[1].to_prover9()
1955
+ op = self.INFIX_PREDS_P9[self.predicate]
1956
+ return f"({left} {op} {right})"
1957
+
1958
+ if _is_tptp_boolean_atom(self):
1959
+ return "$T" if _truth_constant_word(self) == "$true" else "$F"
1960
+
1961
+ if not self.args:
1962
+ quoted = _prover9_arity_zero_symbol(self.predicate)
1963
+ if quoted is None:
1964
+ raise _prover9_name_refusal("the proposition", self.predicate)
1965
+ return quoted
1966
+
1967
+ name = _prover9_word(self.predicate, "the predicate", len(self.args))
1968
+ args_str = ", ".join(a.to_prover9() for a in self.args)
1969
+ return f"{name}({args_str})"
1970
+
1971
+ # The only genuine infix predicates in TPTP are equality and disequality.
1972
+ INFIX_PREDS_TPTP = {
1973
+ "=": "=",
1974
+ "≠": "!=",
1975
+ }
1976
+
1977
+ # Arithmetic comparisons are TPTP dollar-word predicates, applied in
1978
+ # prefix/functor form ($less(a, b)) — they are NOT infix operators.
1979
+ PREFIX_PREDS_TPTP = {
1980
+ "<": "$less",
1981
+ ">": "$greater",
1982
+ "≤": "$lesseq",
1983
+ "≥": "$greatereq",
1984
+ }
1985
+
1986
+ def to_tptp(self) -> str:
1987
+ """Render an atom in TPTP syntax.
1988
+
1989
+ Equality (=) and disequality (!=) are emitted infix — the only genuine
1990
+ infix predicates in TPTP. The arithmetic comparisons (<, >, ≤, ≥) are
1991
+ TPTP dollar-word predicates and are emitted in prefix/functor form
1992
+ ($less(a, b), $greater(a, b), $lesseq(a, b), $greatereq(a, b)). All
1993
+ other predicates are emitted as identifiers with a parenthesised
1994
+ argument list, with only the first character folded to lower-case
1995
+ (see :func:`tptp_fold_first_letter`) — the exact mirror of
1996
+ ``tptp_input.py``'s ``_cap()``, which capitalises only the first
1997
+ character of a parsed predicate name on import. A nullary predicate
1998
+ becomes a bare propositional atom.
1999
+
2000
+ The four comparisons are the ARITHMETIC spelling: a prover proves
2001
+ ``$less(1,2)`` from its own arithmetic. On every route that was not asked for
2002
+ arithmetic the kit reads ``< > ≤ ≥`` as uninterpreted binary predicates, and a
2003
+ PROBLEM for a prover is written by the checked writers
2004
+ (:func:`~unicode_logic_kit.atp._tptp_problem.generate_tptp_problem_with_mapping`,
2005
+ :func:`~unicode_logic_kit.atp.tptp_tff.generate_tff_problem_with_mapping`), which write a
2006
+ comparison as an ordinary predicate of a word of its own and record it in the name
2007
+ map; the typed arithmetic writer
2008
+ (:func:`~unicode_logic_kit.atp._tff_problem.generate_tff_arith_problem`) keeps the
2009
+ dollar-words. Use this method for the text of ONE formula, never to assemble a
2010
+ problem.
2011
+
2012
+ This first-letter fold is NOT injective on its own — ``Foo`` and
2013
+ ``foo`` both render as ``foo`` — so two distinct predicates that
2014
+ differ only in their first letter's case collide. Inside ONE formula
2015
+ the outermost ``to_tptp()`` call refuses that (see
2016
+ :meth:`Node.to_tptp`); across several formulas only the checked
2017
+ problem writers can, since a single formula has no visibility into its
2018
+ siblings elsewhere in the problem.
2019
+
2020
+ The two truth constants are TPTP's own words: the nullary atoms ``$true``
2021
+ and ``⊤`` are written ``$true``, ``$false`` and ``⊥`` are written ``$false``.
2022
+ """
2023
+ truth_word = _truth_constant_word(self)
2024
+ if truth_word is not None:
2025
+ return truth_word
2026
+
2027
+ if self.predicate in self.INFIX_PREDS_TPTP and len(self.args) == 2:
2028
+ left = self.args[0].to_tptp()
2029
+ right = self.args[1].to_tptp()
2030
+ op = self.INFIX_PREDS_TPTP[self.predicate]
2031
+ return f"({left} {op} {right})"
2032
+
2033
+ if self.predicate in self.PREFIX_PREDS_TPTP and len(self.args) == 2:
2034
+ left = self.args[0].to_tptp()
2035
+ right = self.args[1].to_tptp()
2036
+ op = self.PREFIX_PREDS_TPTP[self.predicate]
2037
+ return f"{op}({left},{right})"
2038
+
2039
+ if not self.args:
2040
+ return tptp_fold_first_letter(self.predicate)
2041
+
2042
+ args_str = ",".join(a.to_tptp() for a in self.args)
2043
+ return f"{tptp_fold_first_letter(self.predicate)}({args_str})"
2044
+
2045
+ def _tptp_symbol(self):
2046
+ """The predicate word :meth:`to_tptp` writes (none for equality and the arithmetic comparisons: fixed tokens).
2047
+
2048
+ None for the nullary atoms ``$true`` / ``$false`` either: they are TPTP's own
2049
+ propositions, written verbatim, and no symbol of the user's."""
2050
+ if _is_tptp_boolean_atom(self):
2051
+ return None
2052
+ return (_predicate_symbol, self.predicate)
2053
+
2054
+
2055
+ @dataclass(frozen=True)
2056
+ class Not(Node):
2057
+ """Logical negation of a formula."""
2058
+
2059
+ formula: Node
2060
+
2061
+ def to_dict(self):
2062
+ """Serialise to dict with type tag and recursively serialised subformula."""
2063
+ return {"_type": "Not", "formula": self.formula.to_dict()}
2064
+
2065
+ @staticmethod
2066
+ def from_dict(d):
2067
+ """Deserialise a Not from a dict produced by to_dict."""
2068
+ return Not(Node.from_dict(d["formula"]))
2069
+
2070
+ def to_z3(self, env: Z3Env = None):
2071
+ """Translate to a Z3 Not expression."""
2072
+ return z3.Not(self.formula.to_z3(env or Z3Env()))
2073
+
2074
+ @_prover9_outermost
2075
+ def to_prover9(self) -> str:
2076
+ """Render negation in Prover9 syntax using the dash operator."""
2077
+ return f"-({self.formula.to_prover9()})"
2078
+
2079
+ def to_tptp(self) -> str:
2080
+ """Render negation in TPTP syntax using the tilde operator."""
2081
+ return f"~({self.formula.to_tptp()})"
2082
+
2083
+
2084
+ @dataclass(frozen=True)
2085
+ class And(Node):
2086
+ """Conjunction of two formulas."""
2087
+
2088
+ left: Node
2089
+ right: Node
2090
+
2091
+ def to_dict(self):
2092
+ """Serialise to dict with type tag and recursively serialised operands."""
2093
+ return {"_type": "And", "left": self.left.to_dict(), "right": self.right.to_dict()}
2094
+
2095
+ @staticmethod
2096
+ def from_dict(d):
2097
+ """Deserialise an And from a dict produced by to_dict."""
2098
+ return And(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
2099
+
2100
+ def to_z3(self, env: Z3Env = None):
2101
+ """Translate to a Z3 And expression."""
2102
+ env = env or Z3Env()
2103
+ return z3.And(self.left.to_z3(env), self.right.to_z3(env))
2104
+
2105
+ @_prover9_outermost
2106
+ def to_prover9(self) -> str:
2107
+ """Render conjunction in Prover9 syntax using the ampersand operator."""
2108
+ return f"({self.left.to_prover9()} & {self.right.to_prover9()})"
2109
+
2110
+ def to_tptp(self) -> str:
2111
+ """Render conjunction in TPTP syntax using the ampersand operator."""
2112
+ return f"({self.left.to_tptp()} & {self.right.to_tptp()})"
2113
+
2114
+
2115
+ @dataclass(frozen=True)
2116
+ class Or(Node):
2117
+ """Disjunction of two formulas."""
2118
+
2119
+ left: Node
2120
+ right: Node
2121
+
2122
+ def to_dict(self):
2123
+ """Serialise to dict with type tag and recursively serialised operands."""
2124
+ return {"_type": "Or", "left": self.left.to_dict(), "right": self.right.to_dict()}
2125
+
2126
+ @staticmethod
2127
+ def from_dict(d):
2128
+ """Deserialise an Or from a dict produced by to_dict."""
2129
+ return Or(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
2130
+
2131
+ def to_z3(self, env: Z3Env = None):
2132
+ """Translate to a Z3 Or expression."""
2133
+ env = env or Z3Env()
2134
+ return z3.Or(self.left.to_z3(env), self.right.to_z3(env))
2135
+
2136
+ @_prover9_outermost
2137
+ def to_prover9(self) -> str:
2138
+ """Render disjunction in Prover9 syntax using the pipe operator."""
2139
+ return f"({self.left.to_prover9()} | {self.right.to_prover9()})"
2140
+
2141
+ def to_tptp(self) -> str:
2142
+ """Render disjunction in TPTP syntax using the pipe operator."""
2143
+ return f"({self.left.to_tptp()} | {self.right.to_tptp()})"
2144
+
2145
+
2146
+ @dataclass(frozen=True)
2147
+ class Xor(Node):
2148
+ """Exclusive disjunction of two formulas."""
2149
+
2150
+ left: Node
2151
+ right: Node
2152
+
2153
+ def to_dict(self):
2154
+ """Serialise to dict with type tag and recursively serialised operands."""
2155
+ return {"_type": "Xor", "left": self.left.to_dict(), "right": self.right.to_dict()}
2156
+
2157
+ @staticmethod
2158
+ def from_dict(d):
2159
+ """Deserialise an Xor from a dict produced by to_dict."""
2160
+ return Xor(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
2161
+
2162
+ def to_z3(self, env: Z3Env = None):
2163
+ """Translate to a Z3 Xor expression."""
2164
+ env = env or Z3Env()
2165
+ return z3.Xor(self.left.to_z3(env), self.right.to_z3(env))
2166
+
2167
+ @_prover9_outermost
2168
+ def to_prover9(self) -> str:
2169
+ """Render exclusive or in Prover9 syntax by expanding to (l | r) & -(l & r)."""
2170
+ l = self.left.to_prover9()
2171
+ r = self.right.to_prover9()
2172
+ return f"(({l} | {r}) & -(({l}) & ({r})))"
2173
+
2174
+ def to_tptp(self) -> str:
2175
+ """Render exclusive or in TPTP syntax using the non-equivalence operator (<~>).
2176
+
2177
+ In TPTP ``~|`` is NOR, not XOR; ``<~>`` (non-equivalence) is the operator
2178
+ truth-functionally equal to exclusive or.
2179
+ """
2180
+ return f"({self.left.to_tptp()} <~> {self.right.to_tptp()})"
2181
+
2182
+
2183
+ @dataclass(frozen=True)
2184
+ class Implies(Node):
2185
+ """Material implication from left to right."""
2186
+
2187
+ left: Node
2188
+ right: Node
2189
+
2190
+ def to_dict(self):
2191
+ """Serialise to dict with type tag and recursively serialised operands."""
2192
+ return {"_type": "Implies", "left": self.left.to_dict(), "right": self.right.to_dict()}
2193
+
2194
+ @staticmethod
2195
+ def from_dict(d):
2196
+ """Deserialise an Implies from a dict produced by to_dict."""
2197
+ return Implies(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
2198
+
2199
+ def to_z3(self, env: Z3Env = None):
2200
+ """Translate to a Z3 Implies expression."""
2201
+ env = env or Z3Env()
2202
+ return z3.Implies(self.left.to_z3(env), self.right.to_z3(env))
2203
+
2204
+ @_prover9_outermost
2205
+ def to_prover9(self) -> str:
2206
+ """Render implication in Prover9 syntax using the -> operator."""
2207
+ return f"({self.left.to_prover9()} -> {self.right.to_prover9()})"
2208
+
2209
+ def to_tptp(self) -> str:
2210
+ """Render implication in TPTP syntax using the => operator."""
2211
+ return f"({self.left.to_tptp()} => {self.right.to_tptp()})"
2212
+
2213
+
2214
+ @dataclass(frozen=True)
2215
+ class Iff(Node):
2216
+ """Biconditional (if and only if) between two formulas."""
2217
+
2218
+ left: Node
2219
+ right: Node
2220
+
2221
+ def to_dict(self):
2222
+ """Serialise to dict with type tag and recursively serialised operands."""
2223
+ return {"_type": "Iff", "left": self.left.to_dict(), "right": self.right.to_dict()}
2224
+
2225
+ @staticmethod
2226
+ def from_dict(d):
2227
+ """Deserialise an Iff from a dict produced by to_dict."""
2228
+ return Iff(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
2229
+
2230
+ def to_z3(self, env: Z3Env = None):
2231
+ """Translate to Z3 equality of the two boolean subexpressions."""
2232
+ env = env or Z3Env()
2233
+ return self.left.to_z3(env) == self.right.to_z3(env)
2234
+
2235
+ @_prover9_outermost
2236
+ def to_prover9(self) -> str:
2237
+ """Render biconditional in Prover9 syntax using the <-> operator."""
2238
+ return f"({self.left.to_prover9()} <-> {self.right.to_prover9()})"
2239
+
2240
+ def to_tptp(self) -> str:
2241
+ """Render biconditional in TPTP syntax using the <=> operator."""
2242
+ return f"({self.left.to_tptp()} <=> {self.right.to_tptp()})"
2243
+
2244
+
2245
+ @dataclass(frozen=True)
2246
+ class Quantifier(Node):
2247
+ """A universally or existentially quantified formula over a single variable."""
2248
+
2249
+ type: str
2250
+ variable: Variable
2251
+ formula: Node
2252
+
2253
+ def to_dict(self):
2254
+ """Serialise to dict with type tag, quantifier type, variable, and recursively serialised body."""
2255
+ return {
2256
+ "_type": "Quantifier",
2257
+ "type": self.type,
2258
+ "variable": self.variable.to_dict(),
2259
+ "formula": self.formula.to_dict()
2260
+ }
2261
+
2262
+ @staticmethod
2263
+ def from_dict(d):
2264
+ """Deserialise a Quantifier from a dict produced by to_dict."""
2265
+ return Quantifier(d["type"], Node.from_dict(d["variable"]), Node.from_dict(d["formula"]))
2266
+
2267
+ def to_z3(self, env: Z3Env = None):
2268
+ """Translate to a Z3 ForAll or Exists expression over the bound variable."""
2269
+ env = env or Z3Env()
2270
+ z3_var = self.variable.to_z3(env)
2271
+ body = self.formula.to_z3(env)
2272
+
2273
+ if self.type in ("forall", "∀"):
2274
+ return z3.ForAll([z3_var], body)
2275
+ elif self.type in ("exists", "∃"):
2276
+ return z3.Exists([z3_var], body)
2277
+ raise ValueError(f"Unknown quantifier: {self.type}")
2278
+
2279
+ @_prover9_outermost
2280
+ def to_prover9(self) -> str:
2281
+ """Render the quantified formula in Prover9 syntax using all/exists keywords.
2282
+
2283
+ A binder that sits inside the scope of a binder of its own name is written under a fresh
2284
+ variable, so that Prover9 has nothing to rename (see :meth:`Node.to_prover9`).
2285
+ """
2286
+ var = self.variable.to_prover9()
2287
+ body = self.formula.to_prover9()
2288
+
2289
+ if self.type in ("forall", "∀"):
2290
+ return f"(all {var} {body})"
2291
+ elif self.type in ("exists", "∃"):
2292
+ return f"(exists {var} {body})"
2293
+ raise ValueError(f"Unknown quantifier: {self.type}")
2294
+
2295
+ def to_tptp(self) -> str:
2296
+ """Render a quantified formula in TPTP syntax.
2297
+
2298
+ Universal quantification uses ! and existential uses ?,
2299
+ with the bound variable listed in brackets: ![X]: body or ?[X]: body.
2300
+ """
2301
+ var = self.variable.to_tptp()
2302
+ body = self.formula.to_tptp()
2303
+
2304
+ if self.type in ("forall", "∀"):
2305
+ return f"(![{var}]: {body})"
2306
+ elif self.type in ("exists", "∃"):
2307
+ return f"(?[{var}]: {body})"
2308
+ raise ValueError(f"Unknown quantifier: {self.type}")
2309
+
2310
+
2311
+ # =========================
2312
+ # Counting quantifier, measure / cardinality terms, concessive connective
2313
+ # =========================
2314
+ #
2315
+ # Classical, non-modal extensions used by natural-language → logic front-ends
2316
+ # (e.g. CCG pipelines) to translate cardinal determiners, degree comparatives,
2317
+ # counting comparisons, and concessive coordination:
2318
+ #
2319
+ # * Count — a cardinality quantifier ∃≥n / ∃≤n / ∃=n carrying its bound n
2320
+ # SYMBOLICALLY (a Number, not expanded into single-letter
2321
+ # variables), so an arbitrarily large n is represented exactly.
2322
+ # It is first-order expressible; the exports lower it to the
2323
+ # standard distinct-witnesses encoding via _expand().
2324
+ # * Measure — a degree/measure term μ(entity, dimension) for bare quantity
2325
+ # comparatives (μ(x, height) > μ(y, height)); an uninterpreted
2326
+ # binary function on export.
2327
+ # * Cardinality — a set-cardinality term |{v : φ}| for counting comparisons
2328
+ # (|{v : Votes(x, v)}| > |{v : Votes(y, v)}|). Set cardinality is
2329
+ # a second-order notion, so it has NO first-order export.
2330
+ # * Contrast — a concessive connective (whereas / although / but) that is
2331
+ # truth-functionally ∧, but kept as a distinct node so the
2332
+ # concession survives translation instead of flattening to ∧.
2333
+ #
2334
+ # Count and Cardinality BIND their variable, so the binder-aware passes in
2335
+ # _msfl_nodes.py (free_variables / substitute / resolve_lambda_scope) special-case
2336
+ # them alongside Quantifier, and the renderers special-case Count / Measure /
2337
+ # Cardinality (binders/terms are not regular operators). Contrast IS a regular
2338
+ # level2 operator and is driven entirely by the operator registry — it needs no
2339
+ # renderer branch and no binder handling.
2340
+
2341
+ # op code -> the ∃-prefixed surface glyph (and its inverse, used by the parser).
2342
+ _COUNT_OPS = {"ge": "∃≥", "le": "∃≤", "eq": "∃="}
2343
+ _COUNT_TOKEN_TO_OP = {glyph: op for op, glyph in _COUNT_OPS.items()}
2344
+
2345
+ # Largest witness count Count._expand() will materialise. The distinct-witnesses
2346
+ # encoding is O(n²) constraints under n nested quantifiers, so a large n produces a
2347
+ # tree that is both huge and deeper than Python's recursion limit; the Count node
2348
+ # itself keeps n symbolically and round-trips for ANY n, so only the to_z3 / to_prover9
2349
+ # / to_tptp *expansion* is bounded.
2350
+ _COUNT_EXPAND_MAX = 500
2351
+ _COUNT_TOO_LARGE = (
2352
+ "Count.to_z3/to_prover9/to_tptp: n={n} is too large to expand to first-order "
2353
+ "(the distinct-witnesses encoding materialises O(n²) constraints under n nested "
2354
+ "quantifiers; the limit is n<={limit}). The Count node keeps n symbolically and "
2355
+ "round-trips via to_unicode_str / to_dict for any n — only this first-order "
2356
+ "lowering is bounded."
2357
+ )
2358
+
2359
+
2360
+ def _balanced_and(parts):
2361
+ """Fold a non-empty list of formulas into a *balanced* And tree (shallow depth).
2362
+
2363
+ A left-associative fold would make an n-element conjunction n deep, so an
2364
+ O(n²)-conjunct counting expansion overflows Python's recursion limit on
2365
+ traversal; a balanced tree is only O(log n) deep.
2366
+ """
2367
+ while len(parts) > 1:
2368
+ merged = [And(parts[i], parts[i + 1]) for i in range(0, len(parts) - 1, 2)]
2369
+ if len(parts) % 2:
2370
+ merged.append(parts[-1])
2371
+ parts = merged
2372
+ return parts[0]
2373
+
2374
+
2375
+ @dataclass(frozen=True)
2376
+ class Count(Node):
2377
+ """A counting (cardinality) quantifier ∃≥n / ∃≤n / ∃=n over a single variable.
2378
+
2379
+ ``op`` is ``"ge"`` / ``"le"`` / ``"eq"`` (at least / at most / exactly); ``n``
2380
+ is a :class:`Number` wrapping a non-negative integer bound — kept SYMBOLIC, not
2381
+ expanded into single-letter variables, so an arbitrarily large bound (e.g. 500)
2382
+ is represented exactly. ``variable`` is the bound counting variable and
2383
+ ``formula`` its matrix. Semantics: ``∃≥n x φ`` is true iff at least ``n``
2384
+ DISTINCT individuals satisfy ``φ`` (``∃≤n`` at most, ``∃=n`` exactly). The
2385
+ counting quantifier is first-order expressible; :meth:`to_z3` / :meth:`to_prover9`
2386
+ / :meth:`to_tptp` lower it to the standard distinct-witnesses encoding (see
2387
+ :meth:`_expand`).
2388
+ """
2389
+
2390
+ op: str
2391
+ n: Number
2392
+ variable: Variable
2393
+ formula: Node
2394
+
2395
+ def __post_init__(self):
2396
+ """Validate the op code and that n is a non-negative integer Number."""
2397
+ if self.op not in _COUNT_OPS:
2398
+ raise ValueError(
2399
+ f"Count: unknown op {self.op!r}; expected one of 'ge', 'le', 'eq'.")
2400
+ if not (isinstance(self.n, Number) and isinstance(self.n.value, int)
2401
+ and self.n.value >= 0):
2402
+ raise ValueError(
2403
+ "Count: n must be a Number wrapping a non-negative integer.")
2404
+
2405
+ def _tree_parts(self):
2406
+ """Return the ∃≥n / ∃≤n / ∃=n label (with the bound variable) and the matrix."""
2407
+ return (f"{_COUNT_OPS[self.op]}{self.n.value} {self.variable.name}",
2408
+ [self.formula])
2409
+
2410
+ def to_dict(self):
2411
+ """Serialise to dict with op, n, bound variable, and serialised matrix."""
2412
+ return {"_type": "Count", "op": self.op, "n": self.n.to_dict(),
2413
+ "variable": self.variable.to_dict(),
2414
+ "formula": self.formula.to_dict()}
2415
+
2416
+ @staticmethod
2417
+ def from_dict(d):
2418
+ """Deserialise a Count from a dict produced by to_dict."""
2419
+ return Count(d["op"], Node.from_dict(d["n"]),
2420
+ Node.from_dict(d["variable"]), Node.from_dict(d["formula"]))
2421
+
2422
+ def _expand(self, avoid_names=None) -> "Node":
2423
+ """Lower to plain FOL via the standard distinct-witnesses counting encoding.
2424
+
2425
+ ``avoid_names`` is a set the caller owns: every name in it is avoided as a
2426
+ witness too (the problem writers pass every variable name of the whole
2427
+ problem), and every witness minted is added to it, so that a second
2428
+ expansion with the same set mints other names.
2429
+
2430
+ ``∃≥m x φ`` becomes ``∃x0 … ∃x{m-1} (⋀ φ[x_i] ∧ ⋀_{i<j} x_i ≠ x_j)``;
2431
+ ``∃≤n`` is ``¬(∃≥n+1)``; ``∃=n`` is ``∃≥n ∧ ¬(∃≥n+1)``. The witnesses are
2432
+ named like the counting variable, one letter and digits (``x0``, ``x1``, …:
2433
+ the shape the VARIABLE terminal reads back, so the printed expansion parses),
2434
+ and they avoid EVERY name in the matrix, bound ones included, so the
2435
+ substitution below never has to rename an inner binder. The bound variable
2436
+ is substituted out capture-avoidingly, so the result is a closed,
2437
+ meaning-preserving classical formula.
2438
+
2439
+ "Every name" means a name of EVERY kind that the matrix holds
2440
+ (:func:`~unicode_logic_kit.fol._identifiers.symbol_names`): the constants,
2441
+ and also the functions (the nullary one is a constant), the predicates and
2442
+ the sorts. A constant may be spelled like a variable — the grammar writes
2443
+ one in quotes (``'y0'``), a caller who builds nodes can make one, and so
2444
+ do the description-logic image (an individual named ``y0``) and the TPTP
2445
+ reader (``p(x0)``). ``Variable("y0")`` and ``Constant("y0")`` are two
2446
+ nodes, and the Unicode text (``y0`` against ``'y0'``) and Z3 keep them
2447
+ apart; a target that writes both as one symbol does not, and there a
2448
+ witness named ``y0`` would CAPTURE that constant: ``∃≥1 y1 r(y0, y1)``
2449
+ used to expand to an ``∃y0`` over ``r`` of the constant ``y0`` and the
2450
+ variable ``y0``, which such a target reads as ``∃y0 r(y0, y0)`` and an
2451
+ irreflexive ``r`` contradicts — a consistent knowledge base came out
2452
+ inconsistent. So a witness avoids every constant, for every target. A
2453
+ witness named like a predicate or
2454
+ a function is the same defect in SMT-LIB text, where a bound variable and
2455
+ the symbol it shadows are one identifier (``(exists ((x0 S)) (x0 x0))``).
2456
+ What the matrix does not hold is the caller's to pass: the other formulas
2457
+ of the problem, in ``avoid_names``.
2458
+ """
2459
+ from ._msfl_nodes import substitute # lazy: avoid import cycle
2460
+ if self.n.value > _COUNT_EXPAND_MAX:
2461
+ raise NotImplementedError(
2462
+ _COUNT_TOO_LARGE.format(n=self.n.value, limit=_COUNT_EXPAND_MAX))
2463
+ var, phi = self.variable, self.formula
2464
+ avoid = set(_identifiers.symbol_names(phi)) | {var.name}
2465
+ if avoid_names is not None:
2466
+ avoid |= avoid_names
2467
+
2468
+ def fresh(k):
2469
+ """Return k fresh Variables not clashing with the matrix or each other."""
2470
+ out = []
2471
+ for _ in range(k):
2472
+ name = _identifiers.fresh_variable_like(var.name, avoid)
2473
+ avoid.add(name)
2474
+ if avoid_names is not None:
2475
+ avoid_names.add(name)
2476
+ out.append(Variable(name))
2477
+ return out
2478
+
2479
+ def at_least(m):
2480
+ """Build the plain-FOL 'at least m distinct φ' formula."""
2481
+ if m <= 0:
2482
+ # '≥ 0' is vacuously true: ∃x (φ ∨ ¬φ), valid on a non-empty domain.
2483
+ w = fresh(1)[0]
2484
+ g = substitute(phi, var, w)
2485
+ return Quantifier("∃", w, Or(g, Not(g)))
2486
+ ws = fresh(m)
2487
+ conjuncts = [substitute(phi, var, w) for w in ws]
2488
+ conjuncts += [Atom("≠", [ws[i], ws[j]])
2489
+ for i in range(m) for j in range(i + 1, m)]
2490
+ body = _balanced_and(conjuncts) # balanced ⇒ shallow recursion
2491
+ for w in reversed(ws):
2492
+ body = Quantifier("∃", w, body)
2493
+ return body
2494
+
2495
+ if self.op == "ge":
2496
+ return at_least(self.n.value)
2497
+ if self.op == "le":
2498
+ return Not(at_least(self.n.value + 1))
2499
+ return And(at_least(self.n.value), Not(at_least(self.n.value + 1)))
2500
+
2501
+ def to_z3(self, env: Z3Env = None):
2502
+ """Lower to the distinct-witnesses encoding, then translate to Z3."""
2503
+ return self._expand().to_z3(env)
2504
+
2505
+ @_prover9_outermost
2506
+ def to_prover9(self) -> str:
2507
+ """Lower to the distinct-witnesses encoding, then render Prover9 syntax.
2508
+
2509
+ The witnesses are fresh against every name of the whole node that is written, of every
2510
+ kind, compared case-folded, because Prover9 writes a variable in upper case: ``x0`` and
2511
+ ``X0`` are one variable there (see :meth:`Node.to_prover9`).
2512
+ """
2513
+ return self._expand().to_prover9()
2514
+
2515
+ def to_tptp(self) -> str:
2516
+ """Lower to the distinct-witnesses encoding, then render TPTP syntax."""
2517
+ return self._expand().to_tptp()
2518
+
2519
+
2520
+ @dataclass(frozen=True)
2521
+ class Measure(Node):
2522
+ """A degree/measure term μ(entity, dimension): the degree to which ``entity`` has
2523
+ the gradable dimension ``dimension``.
2524
+
2525
+ A first-class measure-function term, the clean argument for bare (standard-less)
2526
+ quantity comparatives — ``more water`` / ``taller`` become ``μ(x, dim) > μ(y, dim)``
2527
+ rather than a thin relational ``More(x, c)``. Both children are terms. On export it
2528
+ is the uninterpreted binary function ``measure(entity, dimension)`` (the ``μ`` glyph
2529
+ is ASCII-folded to ``measure`` so the first-order back-ends accept it), and ``>`` / ``<``
2530
+ over the resulting degrees use the back-end's ordering.
2531
+ """
2532
+
2533
+ entity: Node
2534
+ dimension: Node
2535
+
2536
+ def _tree_parts(self):
2537
+ """Return the μ label and the entity/dimension children."""
2538
+ return "μ", [self.entity, self.dimension]
2539
+
2540
+ def to_dict(self):
2541
+ """Serialise to dict with serialised entity and dimension terms."""
2542
+ return {"_type": "Measure", "entity": self.entity.to_dict(),
2543
+ "dimension": self.dimension.to_dict()}
2544
+
2545
+ @staticmethod
2546
+ def from_dict(d):
2547
+ """Deserialise a Measure from a dict produced by to_dict."""
2548
+ return Measure(Node.from_dict(d["entity"]), Node.from_dict(d["dimension"]))
2549
+
2550
+ def to_z3(self, env: Z3Env = None):
2551
+ """Translate to an uninterpreted binary Z3 function ``measure`` in sort S."""
2552
+ env = env or Z3Env()
2553
+ return env.get_func("measure", 2)(self.entity.to_z3(env), self.dimension.to_z3(env))
2554
+
2555
+ def to_prover9(self) -> str:
2556
+ """Render as the Prover9 function ``measure(entity, dimension)``."""
2557
+ return f"measure({self.entity.to_prover9()}, {self.dimension.to_prover9()})"
2558
+
2559
+ def to_tptp(self) -> str:
2560
+ """Render as the TPTP function ``measure(entity, dimension)``."""
2561
+ return f"measure({self.entity.to_tptp()},{self.dimension.to_tptp()})"
2562
+
2563
+ def _tptp_symbol(self):
2564
+ """The function ``measure`` this node writes — the very symbol ``Function('measure', ...)`` is,
2565
+ so it collides with a differently-spelled ``Function('Measure', ...)`` and with nothing else."""
2566
+ return (_measure_symbol, "measure")
2567
+
2568
+
2569
+ # Shared rejection message: a set-cardinality term is not first-order definable.
2570
+ _NO_CARDINALITY_EXPORT = (
2571
+ "Cardinality terms (|{v : φ}|) denote set cardinality, a second-order notion "
2572
+ "with no first-order counterpart — counting comparisons such as 'more …​ than …' "
2573
+ "are not first-order definable. Keep the term at the AST level, or express a "
2574
+ "fixed-bound count with the Count quantifier (∃≥n / ∃≤n / ∃=n)."
2575
+ )
2576
+
2577
+
2578
+ @dataclass(frozen=True)
2579
+ class Cardinality(Node):
2580
+ """A set-cardinality term ``|{v : φ}|``: how many ``v`` satisfy ``φ``.
2581
+
2582
+ The first-class ``|S|`` term behind faithful counting comparisons — ``more votes
2583
+ than`` becomes ``|{v : Votes(x, v)}| > |{v : Votes(y, v)}|``. It BINDS ``variable``
2584
+ over the matrix ``formula``. Set cardinality is genuinely second-order, so it has
2585
+ no first-order export: :meth:`to_z3` / :meth:`to_prover9` / :meth:`to_tptp` reject.
2586
+ """
2587
+
2588
+ variable: Variable
2589
+ formula: Node
2590
+
2591
+ def _tree_parts(self):
2592
+ """Return the |v| cardinality label (with the bound variable) and the matrix."""
2593
+ return f"|{self.variable.name}|", [self.formula]
2594
+
2595
+ def to_dict(self):
2596
+ """Serialise to dict with the bound variable and serialised matrix."""
2597
+ return {"_type": "Cardinality", "variable": self.variable.to_dict(),
2598
+ "formula": self.formula.to_dict()}
2599
+
2600
+ @staticmethod
2601
+ def from_dict(d):
2602
+ """Deserialise a Cardinality from a dict produced by to_dict."""
2603
+ return Cardinality(Node.from_dict(d["variable"]), Node.from_dict(d["formula"]))
2604
+
2605
+ def to_z3(self, env: Z3Env = None):
2606
+ """Reject Z3 export: set cardinality has no first-order counterpart."""
2607
+ raise NotImplementedError(_NO_CARDINALITY_EXPORT)
2608
+
2609
+ def to_prover9(self) -> str:
2610
+ """Reject Prover9 export: set cardinality has no first-order counterpart."""
2611
+ raise NotImplementedError(_NO_CARDINALITY_EXPORT)
2612
+
2613
+ def to_tptp(self) -> str:
2614
+ """Reject TPTP export: set cardinality has no first-order counterpart."""
2615
+ raise NotImplementedError(_NO_CARDINALITY_EXPORT)
2616
+
2617
+
2618
+ @dataclass(frozen=True)
2619
+ class Contrast(Node):
2620
+ """A concessive (contrastive) connective ``P Ⓒ Q`` — whereas / although / but.
2621
+
2622
+ Truth-functionally identical to classical conjunction (concession is a discourse
2623
+ relation, not a truth-functional one), but kept as a distinct node so a front-end
2624
+ can preserve the contrast rather than flattening it to ∧. Exports therefore behave
2625
+ exactly like :class:`And`.
2626
+ """
2627
+
2628
+ left: Node
2629
+ right: Node
2630
+
2631
+ def to_dict(self):
2632
+ """Serialise to dict with type tag and recursively serialised operands."""
2633
+ return {"_type": "Contrast", "left": self.left.to_dict(), "right": self.right.to_dict()}
2634
+
2635
+ @staticmethod
2636
+ def from_dict(d):
2637
+ """Deserialise a Contrast from a dict produced by to_dict."""
2638
+ return Contrast(Node.from_dict(d["left"]), Node.from_dict(d["right"]))
2639
+
2640
+ def to_z3(self, env: Z3Env = None):
2641
+ """Translate like And (concession is truth-functionally conjunction)."""
2642
+ env = env or Z3Env()
2643
+ return z3.And(self.left.to_z3(env), self.right.to_z3(env))
2644
+
2645
+ @_prover9_outermost
2646
+ def to_prover9(self) -> str:
2647
+ """Render like And, using the Prover9 ampersand operator."""
2648
+ return f"({self.left.to_prover9()} & {self.right.to_prover9()})"
2649
+
2650
+ def to_tptp(self) -> str:
2651
+ """Render like And, using the TPTP ampersand operator."""
2652
+ return f"({self.left.to_tptp()} & {self.right.to_tptp()})"
2653
+
2654
+
2655
+ # =========================
2656
+ # Registry
2657
+ # =========================
2658
+
2659
+ NODE_CLASSES = {
2660
+ "Variable": Variable, "Constant": Constant, "Number": Number,
2661
+ "Function": Function, "Atom": Atom, "Not": Not, "And": And,
2662
+ "Or": Or, "Xor": Xor, "Implies": Implies, "Iff": Iff,
2663
+ "Quantifier": Quantifier,
2664
+ "Count": Count, "Measure": Measure, "Cardinality": Cardinality,
2665
+ }
2666
+
2667
+
2668
+ # =========================
2669
+ # Operator registry (self-registration)
2670
+ # =========================
2671
+ #
2672
+ # A formula operator (a connective/modal that the precedence-driven renderers in
2673
+ # _msfl_nodes.py format) registers ONE OperatorSpec here next to its class
2674
+ # definition. The renderers then drive every regular operator from this registry,
2675
+ # so adding an operator no longer means editing the central rendering tables.
2676
+ #
2677
+ # Each spec records, byte-for-byte, what the renderer emits:
2678
+ # - unicode: the glyph or prefix string for to_unicode_str (e.g. '¬', '□', 'K_').
2679
+ # - latex: the LaTeX markup for to_latex, including any trailing space that
2680
+ # the current renderer emits (e.g. '\\lnot ', '\\Box ', 'K').
2681
+ # - fixity: how the renderer arranges the operand(s) and the glyph.
2682
+ # - precedence: the formula precedence (higher binds tighter): 4 for prefix /
2683
+ # agent_prefix, 1 for binary_iff, 2 for binary_implies, 2.5 for
2684
+ # binary_until, 3 for level2.
2685
+ #
2686
+ # The "binders" (Quantifier, SortedQuantifier, SecondOrderQuantifier), Lambda and
2687
+ # Application keep their explicit handling in the renderers and a small static
2688
+ # precedence table — they are NOT regular operators and do NOT register here.
2689
+
2690
+ _VALID_FIXITIES = frozenset({
2691
+ "prefix", "agent_prefix",
2692
+ "binary_iff", "binary_implies", "binary_until",
2693
+ "level2",
2694
+ })
2695
+
2696
+
2697
+ @dataclass(frozen=True)
2698
+ class OperatorSpec:
2699
+ """A renderer-facing description of one formula operator.
2700
+
2701
+ name is the node class ``__name__`` (the renderers dispatch by class name).
2702
+ fixity is one of 'prefix', 'agent_prefix', 'binary_iff', 'binary_implies',
2703
+ 'binary_until', 'level2'. unicode/latex are the EXACT strings the
2704
+ to_unicode_str / to_latex renderers emit for the operator's glyph or prefix
2705
+ (latex includes any trailing space). precedence is the formula precedence
2706
+ used for parenthesisation (a float; .5 values let an operator sit between two
2707
+ integer levels, as Until does at 2.5).
2708
+ """
2709
+
2710
+ name: str
2711
+ fixity: str
2712
+ unicode: str
2713
+ latex: str
2714
+ precedence: float
2715
+
2716
+
2717
+ # name -> OperatorSpec. Populated by register_operator as each node module is
2718
+ # imported. The renderers in _msfl_nodes.py read this dict directly.
2719
+ OPERATORS: Dict[str, OperatorSpec] = {}
2720
+
2721
+
2722
+ def register_operator(node_class, fixity: str, unicode: str, latex: str,
2723
+ precedence: float) -> OperatorSpec:
2724
+ """Register ``node_class`` as a renderable formula operator.
2725
+
2726
+ Records an OperatorSpec under ``node_class.__name__`` in OPERATORS and adds
2727
+ the class to NODE_CLASSES (so from_dict/serialisation see it too). Safe to
2728
+ call more than once for the same class — the latest call overwrites the
2729
+ previous spec (idempotent / overwrite-safe). Returns the stored OperatorSpec.
2730
+
2731
+ A node registered here is driven entirely by the central renderers via its
2732
+ spec, so no edit to _msfl_nodes.py is needed to render a new operator.
2733
+ """
2734
+ if fixity not in _VALID_FIXITIES:
2735
+ raise ValueError(
2736
+ f"register_operator: unknown fixity {fixity!r}; "
2737
+ f"expected one of {sorted(_VALID_FIXITIES)}"
2738
+ )
2739
+ name = node_class.__name__
2740
+ spec = OperatorSpec(name, fixity, unicode, latex, float(precedence))
2741
+ OPERATORS[name] = spec
2742
+ NODE_CLASSES[name] = node_class
2743
+ return spec
2744
+
2745
+
2746
+ # Register the classical operators next to their class definitions above.
2747
+ register_operator(Not, "prefix", "¬", "\\lnot ", 4)
2748
+ register_operator(And, "level2", "∧", "\\land", 3)
2749
+ register_operator(Or, "level2", "∨", "\\lor", 3)
2750
+ register_operator(Xor, "level2", "⊕", "\\oplus", 3)
2751
+ register_operator(Implies, "binary_implies", "→", "\\rightarrow", 2)
2752
+ register_operator(Iff, "binary_iff", "↔", "\\leftrightarrow", 1)
2753
+
2754
+
2755
+ # =========================
2756
+ # Parser registry (self-assembling grammar)
2757
+ # =========================
2758
+ #
2759
+ # A SECOND, parser-facing registry sits alongside the renderer registry above.
2760
+ # Where OperatorSpec records how a node is *rendered*, ParserOp records how an
2761
+ # operator is *parsed*: which grammar mode it belongs to, which precedence level
2762
+ # it slots into, the grammar fragment it contributes, and the transform that
2763
+ # turns the matched tokens into a Node. MSFLParser reads this registry per mode
2764
+ # to build BOTH the Lark grammar string and the Transformer — so adding an
2765
+ # operator is a registry entry in the operator's own module, with no edit to
2766
+ # msflparser.py or the grammar skeleton.
2767
+ #
2768
+ # The same glyph maps to different nodes in different modes (∧ → And in FOL but
2769
+ # WeakConjunction in MSFL), so registration is PER (mode, operator): a node may
2770
+ # register several ParserOps, one per mode it appears in.
2771
+ #
2772
+ # Levels mirror the grammar's precedence layering (loosest first):
2773
+ # biimplication the right-assoc ↔ rule (one op per mode)
2774
+ # implication the right-assoc → rule (one op per mode)
2775
+ # until the right-assoc Ⓤ rule (modal only) (one op per mode)
2776
+ # level2 the no-mixing same-level group ∧∨⊕⊗ (one only_X rule each)
2777
+ # prefix ¬ and the prefix modal/temporal ops (the prefix rule's alts)
2778
+ # quantifier ∀/∃ over a variable or predicate (the quantifier alts)
2779
+ #
2780
+ # The shared term/atom/lambda/application layer is NOT registry-driven; it lives
2781
+ # verbatim in the base template, identical across every mode (the only term-layer
2782
+ # variation, sorted vs. plain constants, is selected by the SORTED flag below).
2783
+
2784
+ _VALID_PARSE_LEVELS = frozenset({
2785
+ "prefix", "level2", "implication", "biimplication", "until", "quantifier",
2786
+ })
2787
+
2788
+ _VALID_MODES = frozenset({"fol", "msfol", "msfl", "fl", "modal", "second_order",
2789
+ "higher_order",
2790
+ "dependence", "linear", "lambek"})
2791
+
2792
+
2793
+ @dataclass(frozen=True)
2794
+ class ParserOp:
2795
+ """A parser-facing description of how one operator is parsed in one mode.
2796
+
2797
+ mode : the grammar mode this binding applies to (one of _VALID_MODES).
2798
+ level : the precedence level it slots into (one of _VALID_PARSE_LEVELS).
2799
+ terminal_name : name of a named terminal to declare, or "" if the operator
2800
+ uses an inline string literal (the common case for the glyph
2801
+ connectives — kept inline so the error-path terminal patterns
2802
+ match the legacy grammars byte-for-byte).
2803
+ terminal_def : the full terminal declaration line (e.g. 'BOX: "□"' or
2804
+ 'KNOWS.5: /K_.../'), or "" when terminal_name is "".
2805
+ grammar : the right-hand side of the grammar alternative this op contributes,
2806
+ already referencing the shared rule names (e.g. '"¬" prefix',
2807
+ 'BOX prefix', or '(FORALL | EXISTS) VARIABLE prefix'). For a
2808
+ level2 op this is instead the glyph literal (e.g. '"∧"'), since
2809
+ level2 ops are spliced into the generated only_X / same_level_ops
2810
+ rules rather than contributing a free-standing alternative.
2811
+ rule_alias : the Lark rule alias (-> rule_alias) that names the parse node;
2812
+ the matching transform is attached to the assembled Transformer
2813
+ under this same name.
2814
+ transform : function(items) -> Node implementing the alias's handler.
2815
+ node_class : the Node subclass produced (recorded for introspection/tests).
2816
+ only_name : for level2 ops only, the generated only_X rule name (e.g.
2817
+ "only_and"); "" for every other level.
2818
+ """
2819
+
2820
+ mode: str
2821
+ level: str
2822
+ terminal_name: str
2823
+ terminal_def: str
2824
+ grammar: str
2825
+ rule_alias: str
2826
+ transform: object
2827
+ node_class: object
2828
+ only_name: str = ""
2829
+
2830
+
2831
+ # Append-only list of every parser binding, populated by register_parser_op as
2832
+ # each node module imports. MSFLParser filters it by mode at construction time.
2833
+ PARSER_OPS: List[ParserOp] = []
2834
+
2835
+
2836
+ def register_parser_op(node_class, mode: str, level: str, rule_alias: str,
2837
+ grammar: str, transform, *,
2838
+ terminal_name: str = "", terminal_def: str = "",
2839
+ only_name: str = "") -> ParserOp:
2840
+ """Register one parser binding for ``node_class`` in grammar mode ``mode``.
2841
+
2842
+ Appends a ParserOp to PARSER_OPS. ``transform(items) -> Node`` is the handler
2843
+ Lark calls for the ``rule_alias`` reduction; ``grammar`` is the alternative's
2844
+ right-hand side (or, for a level2 op, the bare glyph literal). Named terminals
2845
+ are declared via ``terminal_name``/``terminal_def``; inline string operators
2846
+ leave both empty. Returns the stored ParserOp.
2847
+
2848
+ This is additive to register_operator (which handles rendering): a fully
2849
+ self-describing operator calls both — register_operator for the renderers,
2850
+ register_parser_op (once per mode) for the parser.
2851
+ """
2852
+ if mode not in _VALID_MODES:
2853
+ raise ValueError(
2854
+ f"register_parser_op: unknown mode {mode!r}; "
2855
+ f"expected one of {sorted(_VALID_MODES)}"
2856
+ )
2857
+ if level not in _VALID_PARSE_LEVELS:
2858
+ raise ValueError(
2859
+ f"register_parser_op: unknown level {level!r}; "
2860
+ f"expected one of {sorted(_VALID_PARSE_LEVELS)}"
2861
+ )
2862
+ op = ParserOp(mode, level, terminal_name, terminal_def, grammar,
2863
+ rule_alias, transform, node_class, only_name)
2864
+ PARSER_OPS.append(op)
2865
+ return op
2866
+
2867
+
2868
+ def parser_ops_for_mode(mode: str) -> List[ParserOp]:
2869
+ """Return every registered ParserOp whose mode matches ``mode`` (in order)."""
2870
+ return [op for op in PARSER_OPS if op.mode == mode]
2871
+
2872
+
2873
+ # ---------------------------------------------------------------------------
2874
+ # Base grammar template
2875
+ # ---------------------------------------------------------------------------
2876
+ #
2877
+ # ONE skeleton shared by every mode. The %%MARKERS%% are filled by
2878
+ # build_grammar() from the mode's ParserOps. The structure: right-assoc ↔ and →,
2879
+ # the optional Until sub-level, the no-mixing only_X same_level_ops group, the
2880
+ # prefix level
2881
+ # (¬ plus any prefix modal ops, then quantifier / atom / grouping), the tight
2882
+ # quantifier binding (body is the prefix level), and the verbatim
2883
+ # term/atom/lambda/application layer.
2884
+ #
2885
+ # Normalised internal rule names: the legacy grammars used negation /
2886
+ # luk_negation / modal for the prefix level and biimplication / luk_biimplication
2887
+ # (etc.) for the binary levels; because Lark inlines ?-rules and only the ->
2888
+ # aliases name tree nodes, these internal names are irrelevant to the produced
2889
+ # AST, so the template uses single uniform names (prefix, biimplication,
2890
+ # implication, until, same_level_ops). The %%...%% markers:
2891
+ # %%TERMINAL_IMPORTS%% the (...) list imported from .terminals (NUMBER,
2892
+ # FORALL, EXISTS, LAMBDA — the identifier terminals
2893
+ # PREDICATE/CONSTANT/NAME/VARIABLE/SORT are generated
2894
+ # by fol/_identifiers.py and land in TERMINAL_DEFS
2895
+ # instead; see that module's docstring for why)
2896
+ # %%TERMINAL_DEFS%% the generated identifier terminals, followed by any
2897
+ # extra named-terminal declarations (modal ops)
2898
+ # %%BIIMPL_OPS%% the ↔ alternative(s)
2899
+ # %%IMPL_OPS%% the → alternative(s)
2900
+ # %%IMPL_BODY%% rule the → level reduces to: "until" or "same_level_ops"
2901
+ # %%UNTIL_BLOCK%% the whole until rule (modal) or empty
2902
+ # %%LEVEL2_ALTS%% the same_level_ops alternation members (only_X | … )
2903
+ # %%ONLY_RULES%% the only_X rule definitions
2904
+ # %%PREFIX_OPS%% the prefix alternatives contributed by ops (¬, modal …)
2905
+ # %%QUANT_OPS%% the quantifier alternative(s)
2906
+ # %%CONST_ALTS%% the atom_term constant rules (plain vs. sorted)
2907
+
2908
+ _BASE_GRAMMAR_TEMPLATE = '''\
2909
+ %import .terminals (%%TERMINAL_IMPORTS%%)
2910
+ %import common.WS
2911
+ %%TERMINAL_DEFS%%
2912
+ ?start: formula
2913
+
2914
+ ?formula: biimplication
2915
+ | lambda_
2916
+ | application_
2917
+
2918
+ ?biimplication: implication
2919
+ %%BIIMPL_OPS%%
2920
+
2921
+ ?implication: %%IMPL_BODY%%
2922
+ %%IMPL_OPS%%
2923
+ %%UNTIL_BLOCK%%
2924
+ ?same_level_ops: %%LEVEL2_ALTS%%
2925
+ %%ONLY_RULES%%
2926
+ ?prefix: %%PREFIX_OPS%%
2927
+ | quantifier
2928
+ | atom
2929
+ | "(" formula ")"
2930
+ | "[" formula "]"
2931
+
2932
+ ?quantifier: %%QUANT_OPS%%
2933
+
2934
+ ?atom: infix_predicate
2935
+ | PREDICATE "(" %%ATOM_ARGS%% ")" -> atom_
2936
+ | PREDICATE -> atom0_
2937
+ %%TRUTH_ATOMS%%
2938
+ %%ATOM_EXTRA%%
2939
+
2940
+ ?infix_predicate: term "<" term -> lt_
2941
+ | term ">" term -> gt_
2942
+ | term "=" term -> eq_
2943
+ | term "≤" term -> le_
2944
+ | term "≥" term -> ge_
2945
+ | term "≠" term -> ne_
2946
+
2947
+ ?termlist: term ("," term)*
2948
+
2949
+ ?term: sum
2950
+
2951
+ ?sum: product
2952
+ | sum "+" product -> add_
2953
+ | sum "-" product -> sub_
2954
+
2955
+ ?product: atom_term
2956
+ | product "*" atom_term -> mul_
2957
+ | product "/" atom_term -> div_
2958
+
2959
+ ?atom_term: VARIABLE
2960
+ | NAME "(" termlist ")" -> function_
2961
+ %%CONST_ALTS%%
2962
+ | NUMBER -> number_
2963
+ %%TERM_EXTRA%%
2964
+ | "(" term ")"
2965
+
2966
+ lambda_: LAMBDA (VARIABLE | NAME | PREDICATE) "." formula
2967
+ ?app_arg: formula | atom_term
2968
+ application_: "(" formula ")" "(" app_arg ")"
2969
+
2970
+ %ignore WS
2971
+ '''
2972
+
2973
+
2974
+ # The two constant-handling variants for the atom_term layer. Plain (FOL / FL /
2975
+ # modal / second-order) treats a bare NAME as a Constant, c_-constants via
2976
+ # const_, and a quoted name (QUOTED_NAME, whose token handler builds the
2977
+ # Constant) as the constant of exactly that name; sorted (MSFOL / MSFL) requires a
2978
+ # SORT annotation on each, so a quoted constant there is ``'k2':Mountain`` and
2979
+ # never bare. The head line ``?atom_term: VARIABLE`` stays where it is:
2980
+ # msflparser patches the assembled grammar by that exact line.
2981
+ _CONST_ALTS_PLAIN = (
2982
+ " | NAME\n"
2983
+ " | CONSTANT -> const_\n"
2984
+ " | QUOTED_NAME"
2985
+ )
2986
+ _CONST_ALTS_SORTED = (
2987
+ " | NAME SORT -> sorted_const_\n"
2988
+ " | CONSTANT SORT -> sorted_const_\n"
2989
+ " | QUOTED_NAME SORT -> sorted_const_"
2990
+ )
2991
+
2992
+
2993
+ # Per-mode grammar configuration that is NOT operator-specific: the terminal
2994
+ # import list and whether constants are sorted. (The operators themselves come
2995
+ # from the registry.)
2996
+ #
2997
+ # PREDICATE, CONSTANT, NAME, VARIABLE, and (for the sorted modes) SORT used to
2998
+ # be listed here for %import, like NUMBER/FORALL/EXISTS/LAMBDA still are —
2999
+ # they are generated instead now (see fol/_identifiers.py's module docstring
3000
+ # for why) and spliced into %%TERMINAL_DEFS%% by build_grammar below, so this
3001
+ # import list carries only the terminals that are still declared verbatim in
3002
+ # fol/grammars/terminals.lark.
3003
+ _MODE_TERMINAL_IMPORTS = {
3004
+ "fol": "NUMBER, FORALL, EXISTS, LAMBDA",
3005
+ "msfol": "NUMBER, FORALL, EXISTS, LAMBDA",
3006
+ "msfl": "NUMBER, FORALL, EXISTS, LAMBDA",
3007
+ "fl": "NUMBER, FORALL, EXISTS, LAMBDA",
3008
+ "modal": "NUMBER, FORALL, EXISTS, LAMBDA",
3009
+ "second_order": "NUMBER, FORALL, EXISTS, LAMBDA",
3010
+ "third_order": "NUMBER, FORALL, EXISTS, LAMBDA",
3011
+ "third_order_modal": "NUMBER, FORALL, EXISTS, LAMBDA",
3012
+ "dependence": "NUMBER, FORALL, EXISTS, LAMBDA",
3013
+ "linear": "NUMBER, FORALL, EXISTS, LAMBDA",
3014
+ "lambek": "NUMBER, FORALL, EXISTS, LAMBDA",
3015
+ }
3016
+
3017
+ _SORTED_MODES = frozenset({"msfol", "msfl"})
3018
+
3019
+
3020
+ # Per-mode atom_term extensions that are NOT registry-driven (the term layer is the
3021
+ # one hand-written part of the template). The classical unsorted modes (fol, modal,
3022
+ # second_order) gain the measure term μ(entity, dimension) and the set-cardinality
3023
+ # term |{v : φ}|; the matching FOLTransformer.measure_ / .cardinality_ handlers
3024
+ # (base-class methods, so available in every mode) turn them into Measure /
3025
+ # Cardinality nodes. All three share the plain (unsorted) term layer, so the same
3026
+ # fragment applies verbatim; modal / second-order are included so a measure or
3027
+ # cardinality term can appear under their operators (e.g. ◇(μ(x, height) > μ(y,
3028
+ # height)) or ∃P (|{v : P(v)}| > c)). The SORTED modes msfol/msfl are absent because
3029
+ # the |{v : φ}| binder would need a sort annotation; a mode absent from this map
3030
+ # gets no extra term form.
3031
+ _TERM_EXTRA_CLASSICAL = (
3032
+ ' | "μ" "(" termlist ")" -> measure_\n'
3033
+ ' | "|" "{" VARIABLE ":" formula "}" "|" -> cardinality_'
3034
+ )
3035
+ # Sorted variant (MSFOL): the measure term is unchanged (its args are the mode's
3036
+ # termlist), but the cardinality binder carries a sort annotation on the bound
3037
+ # variable — |{v:Sort : φ}| → SortedCardinality — to stay consistent with MSFOL's
3038
+ # rule that every binder is sorted.
3039
+ _TERM_EXTRA_SORTED = (
3040
+ ' | "μ" "(" termlist ")" -> measure_\n'
3041
+ ' | "|" "{" VARIABLE SORT ":" formula "}" "|" -> sorted_cardinality_'
3042
+ )
3043
+ _MODE_TERM_EXTRA = {
3044
+ "fol": _TERM_EXTRA_CLASSICAL,
3045
+ "modal": _TERM_EXTRA_CLASSICAL,
3046
+ "second_order": _TERM_EXTRA_CLASSICAL,
3047
+ "third_order": _TERM_EXTRA_CLASSICAL,
3048
+ "third_order_modal": _TERM_EXTRA_CLASSICAL,
3049
+ "msfol": _TERM_EXTRA_SORTED,
3050
+ }
3051
+
3052
+
3053
+ # Per-mode ARGUMENT layer for a predicate application. Every mode but the
3054
+ # third-order ones takes an ordinary ``termlist``: a predicate's arguments
3055
+ # are INDIVIDUALS, so ``P(x)`` is the whole story and a predicate name in
3056
+ # argument position is a syntax error — which is exactly right for first-
3057
+ # and second-order syntax, where ``∀P φ`` binds P as the HEAD of an
3058
+ # application and never as an argument of one.
3059
+ #
3060
+ # The third-order modes widen that one position, and only that one: an
3061
+ # argument may also be a PREDICATE name or a λ-abstraction, i.e. a PROPERTY.
3062
+ # That is the whole syntactic content of "third order" — a predicate whose
3063
+ # argument is itself a predicate (``Positive(G)``, ``Essence(G, x)``,
3064
+ # ``Positive(λx. ¬G(x))``) — and it is why the level is not reachable by
3065
+ # adding another quantifier to second-order syntax. The matching handlers are
3066
+ # ``FOLTransformer.hoarglist`` (returns the argument list, exactly like
3067
+ # ``termlist``) and ``LambdaTransformer.pred_arg_`` (builds the PredicateTerm;
3068
+ # that one lives in msflparser.py because PredicateTerm is defined downstream
3069
+ # of this module).
3070
+ _MODE_ATOM_ARGS = {
3071
+ "third_order": "hoarglist",
3072
+ "third_order_modal": "hoarglist",
3073
+ }
3074
+ _ATOM_EXTRA_THIRD_ORDER = (
3075
+ 'hoarglist: hoarg ("," hoarg)*\n'
3076
+ '\n'
3077
+ '?hoarg: term\n'
3078
+ ' | PREDICATE -> pred_arg_\n'
3079
+ ' | lambda_'
3080
+ )
3081
+ _MODE_ATOM_EXTRA = {
3082
+ "third_order": _ATOM_EXTRA_THIRD_ORDER,
3083
+ "third_order_modal": _ATOM_EXTRA_THIRD_ORDER,
3084
+ }
3085
+
3086
+
3087
+ # The two truth constants as atoms: ``⊤`` is the nullary atom ``$true`` and ``⊥`` the
3088
+ # nullary atom ``$false`` (the atoms the TPTP reader builds), which is also what
3089
+ # ``Node.to_unicode_str`` prints them as, so a formula that contains one reads back to
3090
+ # itself. Every mode that has propositional atoms reads them. The two modes that do
3091
+ # not are left alone: ``linear`` already gives the glyph ``⊤`` a meaning of its own
3092
+ # (the additive unit of ``&``, a ``Top`` node, registered in ``_linear_nodes``) and
3093
+ # ``lambek`` is a calculus of category types, with no propositional constants.
3094
+ _TRUTH_ATOM_ALTS = (
3095
+ ' | "⊤" -> true_\n'
3096
+ ' | "⊥" -> false_\n'
3097
+ )
3098
+ _NO_TRUTH_ATOM_MODES = frozenset({"linear", "lambek"})
3099
+
3100
+
3101
+ def build_grammar(mode: str) -> str:
3102
+ """Assemble the Lark grammar STRING for ``mode`` from the registry + template.
3103
+
3104
+ Splices the mode's ParserOps into the base template's markers, preserving the
3105
+ exact precedence structure of the legacy hand-written grammar for that mode.
3106
+ Pure string assembly: no Lark object is built here (MSFLParser does that).
3107
+ """
3108
+ # Handler-only ops (empty grammar, e.g. sorted_const_ whose alternative lives
3109
+ # in the template's CONST_ALTS block) contribute a transform but no grammar
3110
+ # alternative, so they are excluded from every grammar-fragment join below.
3111
+ ops = [op for op in parser_ops_for_mode(mode) if op.grammar]
3112
+
3113
+ # --- named-terminal declarations (modal operators; dedup, preserve order) ---
3114
+ seen_terms = set()
3115
+ term_defs = []
3116
+ for op in ops:
3117
+ if op.terminal_def and op.terminal_name not in seen_terms:
3118
+ seen_terms.add(op.terminal_name)
3119
+ term_defs.append(op.terminal_def)
3120
+ # The generated identifier terminals (PREDICATE, CONSTANT, NAME, VARIABLE,
3121
+ # and SORT for the sorted modes) go first, ahead of any modal/temporal
3122
+ # operator terminal — see fol/_identifiers.py's module docstring for why
3123
+ # they are generated rather than %import'd from terminals.lark.
3124
+ identifier_defs = _identifiers.terminal_block(include_sort=mode in _SORTED_MODES)
3125
+ terminal_defs = identifier_defs + (("\n".join(term_defs) + "\n") if term_defs else "")
3126
+
3127
+ # --- until sub-level (modal only) ----------------------------------------
3128
+ # Determined first because it sets the implication body rule. ``op.grammar``
3129
+ # for an until op is just the operator glyph (literal or named terminal).
3130
+ until = [op for op in ops if op.level == "until"]
3131
+ if until:
3132
+ impl_body = "until"
3133
+ until_alts = "\n".join(
3134
+ f" | same_level_ops {op.grammar} until -> {op.rule_alias}"
3135
+ for op in until
3136
+ )
3137
+ until_block = f"\n?until: same_level_ops\n{until_alts}\n"
3138
+ else:
3139
+ impl_body = "same_level_ops"
3140
+ until_block = ""
3141
+
3142
+ # --- biimplication (↔) — right-assoc; ``op.grammar`` is just the glyph ----
3143
+ biimpl = [op for op in ops if op.level == "biimplication"]
3144
+ biimpl_ops = "\n".join(
3145
+ f" | implication {op.grammar} biimplication -> {op.rule_alias}"
3146
+ for op in biimpl
3147
+ )
3148
+
3149
+ # --- implication (→) — right-assoc; left operand is the implication body --
3150
+ impl = [op for op in ops if op.level == "implication"]
3151
+ impl_ops = "\n".join(
3152
+ f" | {impl_body} {op.grammar} implication -> {op.rule_alias}"
3153
+ for op in impl
3154
+ )
3155
+
3156
+ # --- level2 (the no-mixing same_level_ops group) -------------------------
3157
+ level2 = [op for op in ops if op.level == "level2"]
3158
+ only_members = " | ".join(op.only_name for op in level2)
3159
+ level2_alts = f"{only_members} | prefix" if only_members else "prefix"
3160
+ only_rules = "\n".join(
3161
+ f"?{op.only_name}: prefix ({op.grammar} prefix)+ -> {op.rule_alias}"
3162
+ for op in level2
3163
+ )
3164
+
3165
+ # --- prefix level (¬ and any prefix modal/temporal ops) ------------------
3166
+ prefix = [op for op in ops if op.level == "prefix"]
3167
+ prefix_ops = "\n | ".join(f"{op.grammar} -> {op.rule_alias}" for op in prefix)
3168
+
3169
+ # --- quantifier ----------------------------------------------------------
3170
+ quant = [op for op in ops if op.level == "quantifier"]
3171
+ quant_ops = "\n | ".join(f"{op.grammar} -> {op.rule_alias}" for op in quant)
3172
+
3173
+ # --- term-layer constant handling ----------------------------------------
3174
+ const_alts = _CONST_ALTS_SORTED if mode in _SORTED_MODES else _CONST_ALTS_PLAIN
3175
+
3176
+ # --- term-layer extensions (measure / cardinality; non-registry) ---------
3177
+ term_extra = _MODE_TERM_EXTRA.get(mode, "")
3178
+
3179
+ grammar = _BASE_GRAMMAR_TEMPLATE
3180
+ grammar = grammar.replace("%%TERMINAL_IMPORTS%%", _MODE_TERMINAL_IMPORTS[mode])
3181
+ grammar = grammar.replace("%%TERMINAL_DEFS%%\n", terminal_defs)
3182
+ grammar = grammar.replace("%%BIIMPL_OPS%%", biimpl_ops)
3183
+ grammar = grammar.replace("%%IMPL_BODY%%", impl_body)
3184
+ grammar = grammar.replace("%%IMPL_OPS%%", impl_ops)
3185
+ grammar = grammar.replace("%%UNTIL_BLOCK%%\n", until_block)
3186
+ grammar = grammar.replace("%%LEVEL2_ALTS%%", level2_alts)
3187
+ grammar = grammar.replace("%%ONLY_RULES%%\n", (only_rules + "\n") if only_rules else "")
3188
+ # A mode may register NO quantifier ops (linear, lambek — propositional) or
3189
+ # NO prefix ops. Lark rejects a rule with an empty right-hand side, so the
3190
+ # empty level is excised from the template rather than left dangling: the
3191
+ # `| quantifier` alternative and the ?quantifier rule disappear together,
3192
+ # and an empty prefix level promotes the next alternative into first place.
3193
+ if not quant:
3194
+ grammar = grammar.replace("\n | quantifier", "")
3195
+ grammar = grammar.replace("\n?quantifier: %%QUANT_OPS%%\n", "\n")
3196
+ if prefix_ops:
3197
+ grammar = grammar.replace("%%PREFIX_OPS%%", prefix_ops)
3198
+ else:
3199
+ grammar = grammar.replace("%%PREFIX_OPS%%\n | ", "")
3200
+ grammar = grammar.replace("%%QUANT_OPS%%", quant_ops)
3201
+ grammar = grammar.replace("%%CONST_ALTS%%", const_alts)
3202
+ grammar = grammar.replace("%%TERM_EXTRA%%\n", (term_extra + "\n") if term_extra else "")
3203
+ # The predicate-application argument layer: ``termlist`` (individuals
3204
+ # only) for every mode but the third-order ones, which widen it to
3205
+ # ``hoarglist`` and bring the two extra rules along with it.
3206
+ grammar = grammar.replace("%%ATOM_ARGS%%", _MODE_ATOM_ARGS.get(mode, "termlist"))
3207
+ grammar = grammar.replace(
3208
+ "%%TRUTH_ATOMS%%\n", "" if mode in _NO_TRUTH_ATOM_MODES else _TRUTH_ATOM_ALTS)
3209
+ atom_extra = _MODE_ATOM_EXTRA.get(mode, "")
3210
+ grammar = grammar.replace("%%ATOM_EXTRA%%\n", (atom_extra + "\n") if atom_extra else "")
3211
+ return grammar
3212
+
3213
+
3214
+ def build_transform_handlers(mode: str) -> Dict[str, object]:
3215
+ """Return ``{rule_alias: transform}`` for every ParserOp in ``mode``.
3216
+
3217
+ MSFLParser attaches these to the assembled Transformer so each operator's
3218
+ parse handler lives next to its node definition, not in a hand-written
3219
+ Transformer subclass.
3220
+ """
3221
+ return {op.rule_alias: op.transform for op in parser_ops_for_mode(mode)}
3222
+
3223
+
3224
+ # =========================
3225
+ # Transformer
3226
+ # =========================
3227
+
3228
+ class FOLTransformer(Transformer):
3229
+ """Transforms parsed tokens from Lark parser into AST nodes."""
3230
+
3231
+ @staticmethod
3232
+ def _fold_binary(items, node_cls):
3233
+ """Left-fold a variable-length item list into nested binary nodes."""
3234
+ node = items[0]
3235
+ for item in items[1:]:
3236
+ node = node_cls(node, item)
3237
+ return node
3238
+
3239
+ def atom0_(self, items):
3240
+ """Transform bare predicate symbol into a zero-arity Atom node."""
3241
+ pred = str(items[0])
3242
+ return Atom(pred, [])
3243
+
3244
+ def true_(self, items):
3245
+ """Transform the glyph ``⊤`` into the truth constant, the atom ``$true``."""
3246
+ return Atom("$true", [])
3247
+
3248
+ def false_(self, items):
3249
+ """Transform the glyph ``⊥`` into the falsity constant, the atom ``$false``."""
3250
+ return Atom("$false", [])
3251
+
3252
+ def VARIABLE(self, items):
3253
+ """Transform variable token into Variable node."""
3254
+ return Variable(str(items))
3255
+
3256
+ def NAME(self, items):
3257
+ """Transform name token into Constant node."""
3258
+ return Constant(str(items))
3259
+
3260
+ def const_(self, items):
3261
+ """Transform a ``c_``-prefixed constant token into a Constant node."""
3262
+ return Constant(str(items[0]))
3263
+
3264
+ def QUOTED_NAME(self, items):
3265
+ """Transform a quoted name token (``'k2'``) into the Constant of that name.
3266
+
3267
+ A token handler, like :meth:`NAME`, so the source span of the constant
3268
+ is the token's own (quotes included) and ``_sorted_const_transform``
3269
+ takes the name of the Constant here exactly as it does for a NAME.
3270
+ """
3271
+ return Constant(_identifiers._unquote_constant(str(items)))
3272
+
3273
+ def number_(self, items):
3274
+ """Transform numeric literal token into Number node."""
3275
+ try:
3276
+ return Number(_numeral_from_text(str(items[0])))
3277
+ except ValueError as exc:
3278
+ raise NumeralTextError(str(exc)) from None
3279
+
3280
+ def function_(self, items):
3281
+ """Transform function application into Function node."""
3282
+ head = items[0]
3283
+ name = head.name if isinstance(head, Constant) else str(head)
3284
+ args = items[1:]
3285
+ if args and isinstance(args[0], list):
3286
+ args = args[0]
3287
+ return Function(name, args)
3288
+
3289
+ def add_(self, items):
3290
+ """Transform addition into Function node."""
3291
+ left, right = items
3292
+ return Function("+", [left, right])
3293
+
3294
+ def sub_(self, items):
3295
+ """Transform subtraction into Function node."""
3296
+ left, right = items
3297
+ return Function("-", [left, right])
3298
+
3299
+ def mul_(self, items):
3300
+ """Transform multiplication into Function node."""
3301
+ left, right = items
3302
+ return Function("*", [left, right])
3303
+
3304
+ def div_(self, items):
3305
+ """Transform division into Function node."""
3306
+ left, right = items
3307
+ return Function("/", [left, right])
3308
+
3309
+ def atom_term(self, items):
3310
+ """Pass through atom term."""
3311
+ return items[0]
3312
+
3313
+ def term(self, items):
3314
+ """Pass through term."""
3315
+ return items[0]
3316
+
3317
+ def sum(self, items):
3318
+ """Pass through sum expression."""
3319
+ return items[0]
3320
+
3321
+ def product(self, items):
3322
+ """Pass through product expression."""
3323
+ return items[0]
3324
+
3325
+ def termlist(self, items):
3326
+ """Transform term list."""
3327
+ return items
3328
+
3329
+ def hoarglist(self, items):
3330
+ """Transform a third-order argument list (individuals and/or properties).
3331
+
3332
+ The third-order modes' counterpart to :meth:`termlist`: same contract
3333
+ (return the argument list for ``atom_`` to consume), but an entry may
3334
+ be a PredicateTerm or a Lambda as well as an ordinary term. Unlike
3335
+ ``?termlist`` this rule is NOT inlined by lark, so a one-argument
3336
+ application arrives here as a one-element list rather than as a bare
3337
+ node — ``atom_`` accepts either.
3338
+ """
3339
+ return items
3340
+
3341
+ def infix_predicate(self, items):
3342
+ """Pass through infix predicate."""
3343
+ return items[0]
3344
+
3345
+ def atom(self, items):
3346
+ """Pass through atom."""
3347
+ return items[0]
3348
+
3349
+ def atom_(self, items):
3350
+ """Transform predicate application into Atom node."""
3351
+ pred = str(items[0])
3352
+ if not isinstance(items[1], list):
3353
+ args = [items[1]]
3354
+ else:
3355
+ args = items[1]
3356
+ return Atom(pred, args)
3357
+
3358
+ def lt_(self, items):
3359
+ """Transform less-than comparison into Atom node."""
3360
+ left, right = items
3361
+ return Atom("<", [left, right])
3362
+
3363
+ def gt_(self, items):
3364
+ """Transform greater-than comparison into Atom node."""
3365
+ left, right = items
3366
+ return Atom(">", [left, right])
3367
+
3368
+ def eq_(self, items):
3369
+ """Transform equality comparison into Atom node."""
3370
+ left, right = items
3371
+ return Atom("=", [left, right])
3372
+
3373
+ def le_(self, items):
3374
+ """Transform less-than-or-equal comparison into Atom node."""
3375
+ left, right = items
3376
+ return Atom("≤", [left, right])
3377
+
3378
+ def ge_(self, items):
3379
+ """Transform greater-than-or-equal comparison into Atom node."""
3380
+ left, right = items
3381
+ return Atom("≥", [left, right])
3382
+
3383
+ def ne_(self, items):
3384
+ """Transform not-equal comparison into Atom node."""
3385
+ left, right = items
3386
+ return Atom("≠", [left, right])
3387
+
3388
+ def not_(self, items):
3389
+ """Transform negation into Not node."""
3390
+ return Not(items[0])
3391
+
3392
+ def and_(self, items):
3393
+ """Transform conjunction into And node."""
3394
+ return self._fold_binary(items, And)
3395
+
3396
+ def or_(self, items):
3397
+ """Transform disjunction into Or node."""
3398
+ return self._fold_binary(items, Or)
3399
+
3400
+ def xor_(self, items):
3401
+ """Transform exclusive or into Xor node."""
3402
+ return self._fold_binary(items, Xor)
3403
+
3404
+ def implies_(self, items):
3405
+ """Transform implication into Implies node."""
3406
+ return Implies(items[0], items[1])
3407
+
3408
+ def iff_(self, items):
3409
+ """Transform biconditional into Iff node."""
3410
+ return Iff(items[0], items[1])
3411
+
3412
+ def quantifier_(self, items):
3413
+ """Transform quantifier expression into Quantifier node."""
3414
+ quant = items[0]
3415
+ var = items[1]
3416
+ formula = items[2]
3417
+ return Quantifier(str(quant), var, formula)
3418
+
3419
+ def measure_(self, items):
3420
+ """Transform μ(entity, dimension) into a Measure term node (exactly 2 args)."""
3421
+ args = items[0] if items and isinstance(items[0], list) else list(items)
3422
+ if len(args) != 2:
3423
+ raise ValueError(
3424
+ f"μ(...) takes exactly two arguments (entity, dimension); got {len(args)}.")
3425
+ return Measure(args[0], args[1])
3426
+
3427
+ def cardinality_(self, items):
3428
+ """Transform |{v : φ}| into a Cardinality term node binding v over φ."""
3429
+ return Cardinality(items[0], items[1])
3430
+
3431
+
3432
+ # =========================
3433
+ # Parser registration (FOL / MSFOL connectives + quantifier)
3434
+ # =========================
3435
+ #
3436
+ # Self-register the classical connectives and the unsorted quantifier with the
3437
+ # parser registry. Each transform mirrors the corresponding FOLTransformer method
3438
+ # exactly (same items[…] handling, same node), so the assembled parser produces
3439
+ # byte-identical ASTs. The connectives shared by FOL and MSFOL (∧ ∨ ¬ → ↔) and
3440
+ # the quantifier register once per mode they appear in; FOL additionally has ⊕
3441
+ # (Xor). The sorted quantifier and the Łukasiewicz/MSFL bindings live in
3442
+ # _msfl_nodes.py; the modal/second-order bindings in their own modules.
3443
+
3444
+ def _fold_binary(items, node_cls):
3445
+ """Left-fold a variable-length item list into nested binary nodes (registry copy)."""
3446
+ node = items[0]
3447
+ for item in items[1:]:
3448
+ node = node_cls(node, item)
3449
+ return node
3450
+
3451
+
3452
+ # Classical ∧ ∨ ¬ → ↔ are shared by FOL, MSFOL, modal, and second-order modes;
3453
+ # ⊕ (Xor) by every CLASSICAL mode — FOL, MSFOL, modal, and second-order (the glyph ⊕
3454
+ # is the Łukasiewicz strong disjunction in the fuzzy modes, so Xor stays out of those).
3455
+ # The unsorted quantifier is shared by FOL, modal, and second-order (the sorted modes
3456
+ # use SortedQuantifier). Each connective registers once per mode with the SAME grammar
3457
+ # fragment and transform, so the assembled parser produces byte-identical ASTs.
3458
+ _CLASSICAL_MODES = ("fol", "msfol", "modal", "second_order")
3459
+ _XOR_MODES = ("fol", "msfol", "modal", "second_order")
3460
+ _UNSORTED_QUANT_MODES = ("fol", "modal", "second_order")
3461
+
3462
+
3463
+ def _quantifier_transform(items):
3464
+ """Build an unsorted Quantifier from [FORALL/EXISTS token, Variable, body]."""
3465
+ return Quantifier(str(items[0]), items[1], items[2])
3466
+
3467
+
3468
+ # --- prefix: ¬ (Not) ---
3469
+ for _m in _CLASSICAL_MODES:
3470
+ register_parser_op(Not, _m, "prefix", "not_", '"¬" prefix',
3471
+ lambda items: Not(items[0]))
3472
+
3473
+ # --- level2: ∧ ∨ (And, Or) everywhere classical; ⊕ (Xor) where allowed ---
3474
+ for _m in _CLASSICAL_MODES:
3475
+ register_parser_op(And, _m, "level2", "and_", '"∧"',
3476
+ lambda items: _fold_binary(items, And), only_name="only_and")
3477
+ register_parser_op(Or, _m, "level2", "or_", '"∨"',
3478
+ lambda items: _fold_binary(items, Or), only_name="only_or")
3479
+ for _m in _XOR_MODES:
3480
+ register_parser_op(Xor, _m, "level2", "xor_", '"⊕"',
3481
+ lambda items: _fold_binary(items, Xor), only_name="only_xor")
3482
+
3483
+ # --- implication: → (Implies) ---
3484
+ # For binary levels (implication / biimplication / until) the ``grammar`` field
3485
+ # holds JUST the operator glyph; build_grammar assembles the full right-assoc
3486
+ # rule from it (the operand rule names are fixed by the level structure). This
3487
+ # lets the → rule's left operand follow the mode's implication body (same_level_ops
3488
+ # normally, or until in modal mode) without a mode-specific fragment.
3489
+ for _m in _CLASSICAL_MODES:
3490
+ register_parser_op(Implies, _m, "implication", "implies_", '"→"',
3491
+ lambda items: Implies(items[0], items[1]))
3492
+
3493
+ # --- biimplication: ↔ (Iff) ---
3494
+ for _m in _CLASSICAL_MODES:
3495
+ register_parser_op(Iff, _m, "biimplication", "iff_", '"↔"',
3496
+ lambda items: Iff(items[0], items[1]))
3497
+
3498
+ # --- quantifier: unsorted ∀x / ∃x (Quantifier) ---
3499
+ for _m in _UNSORTED_QUANT_MODES:
3500
+ register_parser_op(Quantifier, _m, "quantifier", "quantifier_",
3501
+ "(FORALL | EXISTS) VARIABLE prefix", _quantifier_transform)
3502
+
3503
+
3504
+ # Modes that accept the NL / CCG translation-target nodes (Count, Contrast, and —
3505
+ # via _MODE_TERM_EXTRA below — the Measure / Cardinality terms). These are the
3506
+ # CLASSICAL, UNSORTED modes — every mode that is a conservative extension of
3507
+ # classical unsorted FOL and therefore reads the constructs with IDENTICAL
3508
+ # semantics: plain fol, modal (fol + modal operators), and second-order (fol +
3509
+ # quantifiers over predicate variables). A CCG-derived form routinely nests one of
3510
+ # these fol-level constructs under a modal or second-order operator — e.g. "every
3511
+ # professor believes at least three students will pass" is Believes_x(∃≥3 y …) —
3512
+ # so registering the same grammar fragment + transform across the family lets the
3513
+ # whole mixed formula parse (and round-trip) as a single string, not only as a
3514
+ # hand-built AST. (The SORTED classical modes msfol/msfl need sort-annotated
3515
+ # binders, and the fuzzy modes fl/msfl reinterpret the connectives and reject
3516
+ # comparison atoms, so neither is included here.)
3517
+ _NL_NODE_MODES = ("fol", "modal", "second_order")
3518
+
3519
+
3520
+ # --- counting quantifier: ∃≥n / ∃≤n / ∃=n (Count), fol + modal modes ---
3521
+ # COUNTOP is one named terminal matching all three glyphs (∃ followed by ≥/≤/=),
3522
+ # at lexer priority 5 so it wins over EXISTS (∃) on the longer match; the matched
3523
+ # glyph in items[0] selects the op code. The bound NUMBER must be a non-negative
3524
+ # integer: the terminal reads a sign and a decimal point because TERMS need them
3525
+ # (``P(-3)``, ``x < 2.5``), so ``∃≥-2`` and ``∃≥2.5`` reach this rule and are
3526
+ # refused here, as a parse error (see CountBoundError).
3527
+ class CountBoundError(ParsingError):
3528
+ """A counting quantifier whose bound is not a non-negative integer.
3529
+
3530
+ Subclasses ParsingError, so the CLI, ``api.parse_any`` and every caller that
3531
+ catches the parser's error type report it as the one-line SYNTAX_ERROR it is
3532
+ (the parser re-raises a ParsingError a transformer handler produced instead
3533
+ of lark's opaque VisitError, as it does for ConflictingArityError). It is
3534
+ constructed directly, not from a Lark exception, so it sets its own message.
3535
+ """
3536
+
3537
+ def __init__(self, glyph: str, bound: str, column=None):
3538
+ where = f" at position {column}" if isinstance(column, int) and column >= 0 else ""
3539
+ message = (
3540
+ f"SYNTAX_ERROR: the bound of a counting quantifier must be a "
3541
+ f"non-negative integer, got {bound!r}{where} after {glyph!r}. A count "
3542
+ f"is a whole number of witnesses: write it without a sign or a "
3543
+ f"decimal point, e.g. {glyph}2.")
3544
+ self.args = (message,)
3545
+
3546
+ def __str__(self):
3547
+ return self.args[0]
3548
+
3549
+
3550
+ def _count_bound(glyph_token, number_token) -> int:
3551
+ """The integer a counting quantifier's bound token stands for.
3552
+
3553
+ A bound is an unsigned numeral: a sign makes it a CountBoundError whatever
3554
+ the number is (``-2``, and also ``-0``, which equals 0 as a number but is not
3555
+ the numeral ``0``), and so does a decimal point (``2.5``, and also ``2.0``).
3556
+ """
3557
+ text = str(number_token)
3558
+ if "." not in text and not text.startswith(("-", "+")):
3559
+ return int(text)
3560
+ raise CountBoundError(str(glyph_token), text, getattr(number_token, "column", None))
3561
+
3562
+
3563
+ def _count_transform(items):
3564
+ """Build a Count from [COUNTOP glyph token, NUMBER token, Variable, body]."""
3565
+ op = _COUNT_TOKEN_TO_OP[str(items[0])]
3566
+ return Count(op, Number(_count_bound(items[0], items[1])), items[2], items[3])
3567
+
3568
+
3569
+ for _m in _NL_NODE_MODES:
3570
+ register_parser_op(Count, _m, "quantifier", "count_",
3571
+ "COUNTOP NUMBER VARIABLE prefix", _count_transform,
3572
+ terminal_name="COUNTOP", terminal_def="COUNTOP.5: /∃[≥≤=]/")
3573
+
3574
+
3575
+ # --- concessive connective: P Ⓒ Q (Contrast) — every CLASSICAL mode ---
3576
+ # A regular level2 operator (same precedence as ∧ ∨ ⊕): self-registers with the
3577
+ # renderers and the parser, so no renderer branch is needed (it dispatches on
3578
+ # spec.fixity == "level2"). Truth-functionally conjunction; kept distinct in the AST.
3579
+ # Unlike the counting binder it needs no sort annotation, so it drops into the sorted
3580
+ # MSFOL mode too — hence the classical-mode list rather than _NL_NODE_MODES.
3581
+ _CONTRAST_MODES = ("fol", "modal", "second_order", "msfol")
3582
+ register_operator(Contrast, "level2", "Ⓒ", "\\mathbin{\\mathsf{C}}", 3)
3583
+ for _m in _CONTRAST_MODES:
3584
+ register_parser_op(Contrast, _m, "level2", "contrast_", '"Ⓒ"',
3585
+ lambda items: _fold_binary(items, Contrast),
3586
+ only_name="only_contrast")