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,1018 @@
1
+ r"""Classical FOL/MSFOL and propositional modal-K → **Lean 4** exporter, plus an
2
+ optional LIVE elaboration tier through a local Lean 4 toolchain.
3
+
4
+ This is the Lean counterpart of :mod:`unicode_logic_kit.hol.classical` (THF /
5
+ Isabelle) — a first vertical slice, structured the same way, and deliberately
6
+ **not** parity with the full Isabelle/THF pair (no relevant/substructural/
7
+ many-valued/second-third-order/deepshallow Lean routes yet; those are separate
8
+ follow-on items once this pattern is proven). It covers exactly two fragments:
9
+
10
+ * classical **FOL / MSFOL** (:func:`to_lean_fol`, :func:`to_lean_msfol`), and
11
+ * the propositional **modal-K** fragment — Box/Diamond + connectives over
12
+ ground atoms, no frame conditions on the accessibility relation
13
+ (:func:`to_lean_modal_k`).
14
+
15
+ **Emit-only is the honest scope.** Like :mod:`~unicode_logic_kit.hol.classical`,
16
+ the functions above only *emit* a self-contained ``.lean`` source that always
17
+ elaborates (the goal is ``sorry``-closed by default): "here is a well-typed
18
+ statement", never "proved". The optional live tier below is the other half —
19
+ if a local Lean 4 toolchain is installed, :func:`check_theory` actually
20
+ *elaborates* the emitted file (catching syntax/typing bugs no string test
21
+ can), and :func:`lean_decide_fol` / :func:`lean_decide_modal_k` additionally
22
+ try a small tactic battery for a genuine, kernel-checked proof. A file this
23
+ module PRESENTS as checked never contains ``sorry``: :class:`LeanBuildResult`
24
+ tracks ``uses_sorry`` separately from ``ok`` (a ``sorry``-closed file still
25
+ *elaborates*, exit 0), and every ``*_decide_*`` verdict is **VALID only when
26
+ the proof is genuinely sorry-free** — never a false "proved". There is no
27
+ ``INVALID`` verdict here (unlike :mod:`~unicode_logic_kit.hol.isabelle_runner`'s
28
+ nitpick-backed refutation): only ``VALID`` / ``UNKNOWN``, exactly the two
29
+ outcomes the tactic battery can honestly support without a model finder.
30
+
31
+ Faithfulness of the encoding
32
+ -----------------------------
33
+ **The uninterpreted individual type is non-empty by construction, exactly
34
+ like Isabelle's ``typedecl``.** A bare Lean ``axiom Ind : Type`` is *not*
35
+ non-empty by fiat (unlike Isabelle/HOL's ``typedecl``, which always denotes a
36
+ non-empty type) — omitting a witness would silently invalidate classical
37
+ schemas that depend on a non-empty domain (e.g. ``(∀x, P x) → ∃x, P x``),
38
+ exactly the "approximate silently" failure this project refuses to ship. Every
39
+ emitted theory therefore declares
40
+
41
+ .. code-block:: lean
42
+
43
+ axiom Ind : Type
44
+ axiom Ind_nonempty : Nonempty Ind
45
+ instance : Nonempty Ind := Ind_nonempty
46
+
47
+ — an explicit witness *axiom*, registered as a type-class ``instance`` too so
48
+ downstream tactics that need ``Nonempty``/``Inhabited`` search find it. The
49
+ propositional modal-K embedding does the same for its Kripke world type
50
+ (``World`` / ``World_nonempty``).
51
+
52
+ **Many-sorted input.** :func:`to_lean_msfol` reduces a many-sorted formula
53
+ with :func:`~unicode_logic_kit.fol.nodes.to_fol` (each sort becomes a unary
54
+ guard predicate over the single flat ``Ind``, each sorted quantifier is
55
+ relativised — ``∀x:S φ ↦ ∀x (S(x) → φ)``, ``∃x:S φ ↦ ∃x (S(x) ∧ φ)``) and
56
+ then emits the resulting plain-FOL formula as :func:`to_lean_fol` does,
57
+ **exactly like** :func:`~unicode_logic_kit.hol.classical.to_isabelle_msfol` /
58
+ :func:`~unicode_logic_kit.hol.classical.to_thf_msfol`. That reduction forgets the
59
+ two facts of the many-sorted reading (:func:`~unicode_logic_kit.fol.nodes.sort_axioms`):
60
+ no sort is empty, and a sorted constant ``c:S`` is an element of ``S``. For a
61
+ theorem they are stated as HYPOTHESES of it — ``theorem goal (sort_nonempty_0 :
62
+ ∃ x : Ind, human x) (sort_member_0 : human socrates) : φ`` — never conjoined to
63
+ the goal (``human socrates ∧ φ`` could not be proved even for a tautology ``φ``)
64
+ and never folded into the per-node translation, which is polarity-blind (an
65
+ existential baked in there would land under the wrong polarity whenever the
66
+ sorted node occurs negated). Being local hypotheses they are visible to
67
+ ``tauto`` and ``simp``, which a global ``axiom`` is not. ``Ind`` itself is
68
+ guaranteed non-empty (above); ``include_sort_facts=False`` is the bare
69
+ relativisation with no sort facts at all.
70
+
71
+ **Equality.** By default ``=`` / ``≠`` are the *uninterpreted* predicates
72
+ ``feq`` / ``fneq`` (not Lean's own ``=``), matching the toolkit-wide HOL
73
+ convention (:mod:`~unicode_logic_kit.hol.classical`, ``qml.to_thf_modal``). Pass
74
+ ``native_equality=True`` to emit Lean's own built-in, congruence-free ``=`` /
75
+ ``≠`` instead — genuine identity at ``Ind``, so no axioms are needed for it.
76
+
77
+ **Propositional modal-K.** :func:`to_lean_modal_k` is a Benzmüller-style
78
+ shallow embedding, faithful to
79
+ :func:`~unicode_logic_kit.semantics.kripke.satisfies_modal` restricted to the
80
+ propositional fragment (``Box``/``Diamond`` + connectives over ground atoms
81
+ only — no quantifiers, agent-indexed modalities, deontic, or temporal
82
+ operators; those raise ``NotImplementedError`` naming the construct) over an
83
+ **unconstrained** accessibility relation ``R`` — frame **K**, no ``refl`` /
84
+ ``trans`` / ... conditions, since this vertical slice covers only K. Box/
85
+ Diamond translate the standard clauses ``⟦□φ⟧w = ∀v, R w v → ⟦φ⟧v`` /
86
+ ``⟦◇φ⟧w = ∃v, R w v ∧ ⟦φ⟧v``, and every distinct ground atom becomes its own
87
+ ``World → Prop`` valuation axiom. ``tests/test_lean.py`` validates this
88
+ encoding two ways: (a) the modal **K axiom** ``□(p→q) → (□p → □q)`` elaborates
89
+ *and* has a hand-written, kernel-checked proof (no ``sorry``) — live-checked
90
+ against a real Lean 4 install; (b) a known **non**-theorem of K (``□p → p``,
91
+ the ``T`` axiom, which needs reflexivity K does not have) is refuted: a
92
+ concrete, decidable, two-world instantiation makes Lean's own ``decide``
93
+ tactic *prove the goal is false* (not merely "fails to prove it") — so the
94
+ encoding is never one Isabelle-style ``INVALID`` verdict shy of a false
95
+ positive.
96
+
97
+ **Lean 4 core only — no Mathlib.** Every fragment above (classical FOL/MSFOL
98
+ over ``Classical`` reasoning, propositional modal K) is expressible in bare
99
+ Lean 4 core + the ``Classical`` namespace it ships with (``Classical.em`` and
100
+ friends — confirmed by hand: this module's own live tests run against a real,
101
+ Mathlib-free Lean 4.34 toolchain). Emitted theories always ``open Classical``
102
+ so a later, more elaborate hand proof can reach for it. The optional
103
+ :data:`DEFAULT_METHODS` battery (``decide`` / ``tauto`` / ``aesop``) reflects
104
+ this honestly: ``tauto`` and ``aesop`` are *Mathlib* tactics, so without it
105
+ they simply fail to parse ("unknown tactic") and the battery moves on to the
106
+ next method — never a crash, never a false ``VALID``. ``decide`` only closes
107
+ a goal Lean can find a computable ``Decidable`` instance for; the axiom-based
108
+ shallow embeddings above are intentionally *not* decidable (``Ind`` / ``World``
109
+ are opaque, uninterpreted types), so in a Mathlib-free environment
110
+ :func:`lean_decide_fol` / :func:`lean_decide_modal_k` will typically report
111
+ ``UNKNOWN`` for a nontrivial goal even when it IS valid — exactly the honest,
112
+ incomplete-but-sound behaviour :mod:`~unicode_logic_kit.hol.isabelle_runner`
113
+ already documents for its own battery, never a false "proved".
114
+
115
+ Locating Lean
116
+ --------------
117
+ :func:`find_lean` looks, in order, at an explicit path, the env var
118
+ ``UFK_LEAN_HOME``, ``lean`` on ``PATH``, and the standard ``elan`` install
119
+ location ``~/.elan/bin`` — the same discovery shape
120
+ :func:`~unicode_logic_kit.hol.isabelle_runner.find_isabelle` uses for Isabelle.
121
+ :func:`lean_available` is the cheap predicate tests gate on. Nothing in this
122
+ module modifies ``PATH`` or a shell profile; a toolchain installed via
123
+ ``elan-init --no-modify-path`` is still found through ``~/.elan/bin`` or
124
+ ``UFK_LEAN_HOME``.
125
+ """
126
+
127
+ from __future__ import annotations
128
+
129
+ import os
130
+ import re
131
+ import shutil
132
+ import subprocess
133
+ import tempfile
134
+ import time
135
+ import uuid
136
+ from dataclasses import dataclass
137
+ from typing import Dict, List, Optional, Sequence, Tuple
138
+
139
+ from ..fol._msfl_nodes import _reduce_nl_nodes, key_text
140
+ from ..fol._numeral_symbols import numerals_as_constants, prefixed_numeral_name
141
+ from ..fol._symbol_names import dedupe
142
+ from ..fol._truth_constants import truth_value
143
+ from ..fol.nodes import (
144
+ Node, Variable, Constant, Number, Function, Measure,
145
+ Atom, Not, And, Or, Xor, Implies, Iff, Quantifier,
146
+ Box, Diamond,
147
+ )
148
+ from .classical import (
149
+ _signature, _sanitize, _SymbolResolver, _VarResolver, _free_variables,
150
+ _CAT_PRED, _CAT_FUNC, _CAT_CONST, _is_native_eq, _FORALL,
151
+ _scope, _msfol_split, _refuse_open_assertion,
152
+ )
153
+
154
+ __all__ = [
155
+ "to_lean_fol", "to_lean_msfol", "to_lean_modal_k",
156
+ "LeanInstall", "LeanNotAvailable", "LeanBuildResult", "LeanVerdict",
157
+ "find_lean", "lean_available", "check_theory",
158
+ "lean_decide_fol", "lean_decide_modal_k",
159
+ "DEFAULT_METHODS", "VALID", "UNKNOWN",
160
+ ]
161
+
162
+
163
+ # ===========================================================================
164
+ # Lean 4 identifier safety: reserved words + this module's own scaffold names
165
+ # ===========================================================================
166
+
167
+ # Lean 4 keywords / predeclared core identifiers a sanitised source-symbol
168
+ # name (e.g. a natural-language predicate literally called "in" or "at")
169
+ # could otherwise collide with. Not claimed to be exhaustive of every Lean
170
+ # parser token, but covers every keyword class (binders, declarations, macro/
171
+ # syntax machinery, control flow, placeholders) plus the handful of lowercase
172
+ # core names (`true`/`false`/...) most likely to appear in a translated
173
+ # natural-language formula.
174
+ _LEAN_RESERVED = frozenset({
175
+ "fun", "let", "in", "do", "at", "with", "where", "from", "have", "show",
176
+ "suffices", "calc", "this", "match", "if", "then", "else", "by",
177
+ "forall", "exists", "nomatch", "rec", "generalizing", "deriving",
178
+ "def", "theorem", "lemma", "axiom", "opaque", "constant", "abbrev",
179
+ "instance", "class", "structure", "inductive", "mutual", "partial",
180
+ "unsafe", "noncomputable", "private", "protected", "scoped", "local",
181
+ "variable", "variables", "universe", "universes", "namespace", "section",
182
+ "end", "open", "import", "extends", "attribute",
183
+ "macro", "macro_rules", "syntax", "elab", "elab_rules", "notation",
184
+ "infix", "infixl", "infixr", "prefix", "postfix",
185
+ "set_option", "run_cmd",
186
+ "sorry", "admit", "native_decide", "stop",
187
+ "for", "while", "return", "try", "catch", "finally", "break", "continue",
188
+ "mut",
189
+ "true", "false", "and", "or", "not", "iff", "eq", "ne",
190
+ })
191
+
192
+
193
+ class _LeanNames:
194
+ """Wrap an already-de-collided identifier (from :class:`_SymbolResolver`,
195
+ :class:`_VarResolver`, or a locally synthesised name) into one that ALSO
196
+ avoids Lean 4 reserved words and this emission's own scaffold identifiers
197
+ (``Ind`` / ``World`` / ``R`` / ``goal`` / ...), while staying globally
198
+ injective: two distinct LOGICAL symbols never map to the same output.
199
+
200
+ Memoisation is keyed on an explicit, caller-supplied ``key`` that
201
+ identifies the *logical* symbol requesting a name — e.g. ``(category,
202
+ raw_name, arity)`` for a predicate/function/constant (the same key
203
+ :class:`_SymbolResolver` itself uses), ``("var", raw_name)`` for a bound
204
+ variable, or ``("atom", atom_key)`` / ``("world", depth)`` for the modal
205
+ encoding — NEVER the candidate string itself. This distinguishes "the
206
+ same logical symbol requested twice" (which must be idempotent: the
207
+ second request returns the identifier already assigned) from "two
208
+ DIFFERENT logical symbols whose independently-sanitised candidate
209
+ strings just happen to coincide" (which must still be de-collided against
210
+ each other). Caching on the candidate string alone cannot tell these
211
+ apart — a predicate resolved by :class:`_SymbolResolver` and a bound
212
+ variable resolved by the entirely separate :class:`_VarResolver` (or the
213
+ ``w{n}`` Kripke-world counter in :func:`to_lean_modal_k`) run their OWN
214
+ de-collision within their own namespace, so they can and do produce the
215
+ identical candidate string for two distinct symbols (e.g. a predicate
216
+ named ``Foo`` and a bound variable also named ``Foo`` both sanitise to
217
+ ``"foo"``; an atom literally named ``w1`` and the Kripke-world binder at
218
+ depth 1 both produce ``"w1"``) — exactly the scenario this class exists
219
+ to rule out.
220
+
221
+ One instance is threaded through a whole emission (declarations first,
222
+ then the formula body), so a synthesised bound-variable name is checked
223
+ against every symbol identifier already declared, not just against other
224
+ bound-variable names — Lean itself tolerates a local binder shadowing a
225
+ top-level ``axiom`` (verified by hand: it elaborates, just silently reads
226
+ the wrong one inside the shadowed scope, or in the modal case fails to
227
+ elaborate at all because the shadowed ``axiom``'s function type is gone),
228
+ so keying only by logical identity, never by the rendered string, is what
229
+ keeps that from being a silent semantic bug.
230
+ """
231
+
232
+ def __init__(self, reserved=frozenset()):
233
+ self._used = set(_LEAN_RESERVED) | set(reserved)
234
+ self._map: Dict[object, str] = {}
235
+
236
+ def safe(self, key: object, ident: str) -> str:
237
+ """Return the unique Lean identifier for the logical symbol ``key``.
238
+
239
+ ``ident`` is that symbol's already-sanitised candidate string (from
240
+ the appropriate resolver). The SAME ``key`` always returns the SAME
241
+ identifier (idempotent); a DIFFERENT ``key`` whose ``ident`` collides
242
+ with one already assigned is de-collided via :func:`dedupe` instead
243
+ of silently reusing it.
244
+ """
245
+ if key in self._map:
246
+ return self._map[key]
247
+ cand = dedupe(ident, self._used)
248
+ self._map[key] = cand
249
+ return cand
250
+
251
+
252
+ # ===========================================================================
253
+ # (A) Classical FOL / MSFOL -> Lean 4
254
+ # ===========================================================================
255
+
256
+ _LEAN_BINOP = {And: "∧", Or: "∨", Implies: "→", Iff: "↔"}
257
+
258
+ _FOL_RESERVED = frozenset({"Ind", "Ind_nonempty", "goal"})
259
+
260
+
261
+ def _lean_term(node: Node, syms: "_SymbolResolver", vars_: "_VarResolver",
262
+ names: "_LeanNames") -> str:
263
+ """Render an individual (``Ind``) term in Lean prefix-application syntax."""
264
+ if isinstance(node, Variable):
265
+ return names.safe(("var", node.name), vars_.token(node.name))
266
+ if isinstance(node, Constant):
267
+ return names.safe((_CAT_CONST, node.name, 0), syms.name(_CAT_CONST, node.name, 0))
268
+ if isinstance(node, Number):
269
+ raw = prefixed_numeral_name(node.value) # one constant per VALUE: 1 and 1.0 are ``n1``
270
+ return names.safe((_CAT_CONST, raw, 0), syms.name(_CAT_CONST, raw, 0))
271
+ if isinstance(node, Function):
272
+ key = (_CAT_FUNC, node.name, len(node.args))
273
+ head = names.safe(key, syms.name(_CAT_FUNC, node.name, len(node.args)))
274
+ args = [_lean_term(a, syms, vars_, names) for a in node.args]
275
+ return "(" + " ".join([head] + args) + ")"
276
+ if isinstance(node, Measure):
277
+ key = (_CAT_FUNC, "measure", 2)
278
+ head = names.safe(key, syms.name(_CAT_FUNC, "measure", 2))
279
+ return ("(" + " ".join([head, _lean_term(node.entity, syms, vars_, names),
280
+ _lean_term(node.dimension, syms, vars_, names)]) + ")")
281
+ raise NotImplementedError(
282
+ f"to_lean_fol: unsupported term {type(node).__name__} (classical FOL terms only)."
283
+ )
284
+
285
+
286
+ def _lean_formula(node: Node, syms: "_SymbolResolver", vars_: "_VarResolver",
287
+ names: "_LeanNames", native_equality: bool = False) -> str:
288
+ """Render a classical FOL formula as a Lean ``Prop`` term.
289
+
290
+ Connectives use Lean 4's own Unicode notation (``¬ ∧ ∨ → ↔``, all part of
291
+ core, no import needed); quantifiers bind ``Ind`` variables (``∀ x : Ind,
292
+ …`` / ``∃ x : Ind, …``); atoms apply their declared predicate by plain
293
+ juxtaposition. Every predicate / function / constant / variable name is
294
+ routed through the resolvers AND :class:`_LeanNames`, so distinct source
295
+ symbols stay distinct and none collides with a Lean keyword or this
296
+ emission's own scaffold names. Anything outside the classical fragment
297
+ (modal, second-order, many-valued, substructural, lambda) raises
298
+ ``NotImplementedError``.
299
+
300
+ With ``native_equality=True``, a binary ``=``/``≠`` atom renders as
301
+ Lean's own infix ``(a = b)`` / ``(a ≠ b)`` instead of the ``feq``/``fneq``
302
+ functor — genuine, axiom-free Lean identity.
303
+ """
304
+ def f(n):
305
+ return _lean_formula(n, syms, vars_, names, native_equality)
306
+ if isinstance(node, Atom):
307
+ if truth_value(node) is not None:
308
+ return "True" if truth_value(node) else "False"
309
+ if _is_native_eq(node.predicate, len(node.args), native_equality):
310
+ op = "=" if node.predicate == "=" else "≠"
311
+ left = _lean_term(node.args[0], syms, vars_, names)
312
+ right = _lean_term(node.args[1], syms, vars_, names)
313
+ return f"({left} {op} {right})"
314
+ key = (_CAT_PRED, node.predicate, len(node.args))
315
+ head = names.safe(key, syms.name(_CAT_PRED, node.predicate, len(node.args)))
316
+ if not node.args:
317
+ return head
318
+ return "(" + " ".join([head] + [_lean_term(a, syms, vars_, names)
319
+ for a in node.args]) + ")"
320
+ if isinstance(node, Not):
321
+ return f"(¬ {f(node.formula)})"
322
+ if type(node) in _LEAN_BINOP:
323
+ op = _LEAN_BINOP[type(node)]
324
+ return f"({f(node.left)} {op} {f(node.right)})"
325
+ if isinstance(node, Xor):
326
+ return f"(¬ ({f(node.left)} ↔ {f(node.right)}))"
327
+ if isinstance(node, Quantifier):
328
+ x = names.safe(("var", node.variable.name), vars_.token(node.variable.name))
329
+ binder = "∀" if node.type in (_FORALL, "forall") else "∃"
330
+ return f"({binder} {x} : Ind, {f(node.formula)})"
331
+ raise NotImplementedError(
332
+ f"to_lean_fol: {type(node).__name__} is outside the classical FOL fragment "
333
+ "supported by the Lean export (no modal / second-order / many-valued / "
334
+ "substructural / lambda). Propositional modal K -> hol.lean.to_lean_modal_k; "
335
+ "every other non-classical logic has no Lean route yet (see the module "
336
+ "docstring: this is a first vertical slice)."
337
+ )
338
+
339
+
340
+ def _lean_signature_decls(formula: Node, syms: "_SymbolResolver", names: "_LeanNames",
341
+ native_equality: bool = False) -> List[str]:
342
+ """``axiom`` declarations for every predicate / function / constant.
343
+
344
+ Individuals live in the single uninterpreted, explicitly-``Nonempty`` type
345
+ ``Ind``; a k-ary predicate has type ``Ind → … → Prop`` and a k-ary
346
+ function ``Ind → … → Ind``. Each declaration uses the resolver-assigned,
347
+ then :class:`_LeanNames`-wrapped identifier, so no two declarations share
348
+ a name and every name matches its usages. A binary ``=``/``≠`` skips its
349
+ declaration when ``native_equality=True`` — Lean's own polymorphic ``=``
350
+ needs no ``axiom``.
351
+ """
352
+ preds, funcs, consts = _signature(formula)
353
+ decls: List[str] = []
354
+ for name, arity in sorted(preds):
355
+ if _is_native_eq(name, arity, native_equality):
356
+ continue
357
+ key = (_CAT_PRED, name, arity)
358
+ ident = names.safe(key, syms.name(_CAT_PRED, name, arity))
359
+ typ = " → ".join(["Ind"] * arity + ["Prop"]) if arity else "Prop"
360
+ decls.append(f"axiom {ident} : {typ}")
361
+ for name, arity in sorted(funcs):
362
+ key = (_CAT_FUNC, name, arity)
363
+ ident = names.safe(key, syms.name(_CAT_FUNC, name, arity))
364
+ typ = " → ".join(["Ind"] * (arity + 1))
365
+ decls.append(f"axiom {ident} : {typ}")
366
+ for name in sorted(consts):
367
+ key = (_CAT_CONST, name, 0)
368
+ ident = names.safe(key, syms.name(_CAT_CONST, name, 0))
369
+ decls.append(f"axiom {ident} : Ind")
370
+ return decls
371
+
372
+
373
+ def to_lean_fol(formula: Node, conjecture: bool = True, native_equality: bool = False,
374
+ proof: str = "sorry") -> str:
375
+ """Emit a complete, self-contained **Lean 4** source for a classical FOL ``formula``.
376
+
377
+ Declares the uninterpreted individual type ``Ind`` (with an explicit
378
+ ``Nonempty`` witness — see the module docstring on why that is the one
379
+ real semantic trap here), a typed ``axiom`` for every predicate / function
380
+ / constant in the signature, ``open Classical`` (for a later hand proof
381
+ that needs ``Classical.em`` / ``Classical.byContradiction``), and the
382
+ formula itself as ``theorem goal : … := by`` / `` <proof>`` (default
383
+ ``proof="sorry"`` — the file always elaborates without claiming a proof,
384
+ Lean's analogue of Isabelle's ``oops``). ``proof`` is the TACTIC BLOCK
385
+ BODY (no leading ``by`` — this function supplies it), one or more
386
+ newline-separated tactics, each re-indented two spaces so a multi-line
387
+ hand proof parses regardless of how the caller indented it. With
388
+ ``conjecture=False`` the formula is instead emitted as ``axiom goal : …``
389
+ — no proof needed, useful for asserting the formula as a hypothesis in a
390
+ larger hand-written problem.
391
+
392
+ A free variable of a theorem is closed universally before emission (matching
393
+ :func:`~unicode_logic_kit.hol.classical.to_thf_fol` /
394
+ :func:`~unicode_logic_kit.hol.classical.to_isabelle_fol`). A free variable is a
395
+ parameter of the problem, one unknown individual, and for a single formula with no
396
+ premise the two readings coincide: the formula is valid for the parameter iff it is
397
+ valid for every individual. An ASSERTED formula (``conjecture=False``) is a premise
398
+ of a larger problem, and there the closure would say more than the formula says
399
+ (``∀x P(x)`` entails ``P(a)``, the premise ``P(x)`` does not), so an asserted
400
+ formula with a free variable is refused by name: state the parameter with a
401
+ constant, or bind the variable with a quantifier.
402
+
403
+ By default, equality ``=`` / ``≠`` becomes the uninterpreted
404
+ predicate ``feq`` / ``fneq`` (see module docstring); pass
405
+ ``native_equality=True`` to instead emit Lean's own built-in ``=`` / ``≠``.
406
+
407
+ Classical FOL is *semi-decidable only*: no tactic is guaranteed to close
408
+ every valid goal. This function only emits the file; it does not run
409
+ Lean — see :func:`check_theory` / :func:`lean_decide_fol` for the optional
410
+ live tier.
411
+
412
+ Raises:
413
+ NotImplementedError: ``conjecture`` is false and ``formula`` has a free variable;
414
+ or a numeral and a constant are spelled alike.
415
+ """
416
+ return _lean_problem(formula, conjecture, native_equality, proof)
417
+
418
+
419
+ def _lean_problem(formula: Node, conjecture: bool, native_equality: bool, proof: str,
420
+ background: Sequence[Node] = (), where: str = "to_lean_fol") -> str:
421
+ """The Lean source of ``formula``, with ``background`` as hypotheses of its theorem.
422
+
423
+ ``background`` are closed sentences (the many-sorted reading's sort facts, see
424
+ :func:`~unicode_logic_kit.hol.classical._msfol_split`). For a theorem each becomes
425
+ a named hypothesis (``sort_nonempty_<i>`` for an ``∃``, ``sort_member_<i>`` for
426
+ an atom), so they are in the local context a tactic searches; for an asserted
427
+ formula each is an ``axiom`` line of its own.
428
+
429
+ A numeral is a constant identified by its value (``1`` and ``1.0`` are the one constant
430
+ ``n1``); a constant spelled like it, in the formula or in ``background``, is refused by
431
+ name (``where`` is the function that is writing, which the refusal names).
432
+ """
433
+ formulas, _ = numerals_as_constants([formula, *background], where=where,
434
+ spell=prefixed_numeral_name)
435
+ formula, background = formulas[0], formulas[1:]
436
+ formula = _reduce_nl_nodes(formula) # Contrast -> And, Count -> witnesses
437
+ _refuse_open_assertion(formula, conjecture, where)
438
+ closed = formula
439
+ for name in reversed(_free_variables(formula)):
440
+ closed = Quantifier(_FORALL, Variable(name), closed)
441
+ scope = _scope(closed, background)
442
+ syms = _SymbolResolver(scope, native_equality=native_equality)
443
+ vars_ = _VarResolver(_sanitize)
444
+ names = _LeanNames(_FOL_RESERVED)
445
+
446
+ lines = [
447
+ "-- Classical FOL embedded into Lean 4 core (no Mathlib) over an",
448
+ "-- uninterpreted, EXPLICITLY NONEMPTY individual type `Ind` and",
449
+ "-- uninterpreted predicates/functions/constants declared as `axiom`s.",
450
+ ]
451
+ preds, _, _ = _signature(scope)
452
+ if any(_is_native_eq(n, a, native_equality) for n, a in preds):
453
+ lines.append("-- '=' / '≠' are Lean's own built-in identity (no axioms needed).")
454
+ else:
455
+ lines.append("-- '=' / '≠' are the uninterpreted predicates feq / fneq, NOT Lean's `=`.")
456
+ lines += [
457
+ "",
458
+ "axiom Ind : Type",
459
+ "axiom Ind_nonempty : Nonempty Ind",
460
+ "instance : Nonempty Ind := Ind_nonempty",
461
+ "",
462
+ "open Classical",
463
+ "",
464
+ ]
465
+ decls = _lean_signature_decls(scope, syms, names, native_equality=native_equality)
466
+ if decls:
467
+ lines += decls
468
+ lines.append("")
469
+ body = _lean_formula(closed, syms, vars_, names, native_equality=native_equality)
470
+ facts = []
471
+ nonempty = member = 0
472
+ for fact in background:
473
+ if isinstance(fact, Quantifier):
474
+ label, nonempty = f"sort_nonempty_{nonempty}", nonempty + 1
475
+ else:
476
+ label, member = f"sort_member_{member}", member + 1
477
+ facts.append((names.safe(("sort_fact", label), label),
478
+ _lean_formula(fact, syms, vars_, names, native_equality=native_equality)))
479
+ if conjecture:
480
+ hypotheses = "".join(f" ({label} : {text})" for label, text in facts)
481
+ lines.append(f"theorem goal{hypotheses} : {body} := by")
482
+ lines.extend(f" {pl}" for pl in proof.split("\n"))
483
+ else:
484
+ lines.extend(f"axiom {label} : {text}" for label, text in facts)
485
+ lines.append(f"axiom goal : {body}")
486
+ return "\n".join(lines) + "\n"
487
+
488
+
489
+ def to_lean_msfol(formula: Node, conjecture: bool = True, include_sort_facts: bool = True,
490
+ native_equality: bool = False, proof: str = "sorry") -> str:
491
+ """Emit a **Lean 4** source for a *many-sorted* FOL ``formula`` via guard relativization.
492
+
493
+ Each sort becomes a unary guard predicate and each sorted quantifier is
494
+ relativized (``∀x:S φ ↦ ∀x (S(x) → φ)``, ``∃x:S φ ↦ ∃x (S(x) ∧ φ)``) by
495
+ the toolkit's :func:`~unicode_logic_kit.fol.nodes.to_fol`; the resulting
496
+ plain-FOL formula is then emitted with :func:`to_lean_fol` — exactly the
497
+ same reduction :func:`~unicode_logic_kit.hol.classical.to_isabelle_msfol` /
498
+ :func:`~unicode_logic_kit.hol.classical.to_thf_msfol` already use, so this
499
+ adds no new semantics. With ``include_sort_facts=True`` (default) the two
500
+ facts that reduction forgets are stated too
501
+ (:func:`~unicode_logic_kit.fol.nodes.sort_axioms`): every sort is non-empty
502
+ (``∃ x : Ind, S x``) and a sorted constant is in its sort (``Human socrates``
503
+ for ``Mortal(socrates:Human)``). For a theorem each is a named HYPOTHESIS of
504
+ it — ``theorem goal (sort_nonempty_0 : …) (sort_member_0 : …) : φ`` — never a
505
+ conjunct of the goal, which could not be proved even for a tautology ``φ``.
506
+ An asserted formula (``conjecture=False``) keeps the membership atoms as a
507
+ conjunct, since they are part of what is asserted, and gets the non-emptiness
508
+ facts as ``axiom`` lines. ``include_sort_facts=False`` is the bare
509
+ relativisation, no sort facts. All sorts share the single Lean type ``Ind`` —
510
+ the relativisation, not the type system, keeps the sorts apart. See
511
+ :func:`to_lean_fol` for ``native_equality`` / ``proof`` and for the free variable of an
512
+ asserted formula, which is refused by name.
513
+
514
+ Raises:
515
+ NotImplementedError: ``conjecture`` is false and ``formula`` has a free variable.
516
+ """
517
+ plain, background = _msfol_split(formula, conjecture, include_sort_facts)
518
+ return _lean_problem(plain, conjecture, native_equality, proof, background,
519
+ where="to_lean_msfol")
520
+
521
+
522
+ # ===========================================================================
523
+ # (B) Propositional modal K -> Lean 4
524
+ # ===========================================================================
525
+
526
+ _MODAL_K_ALLOWED = (Atom, Not, And, Or, Xor, Implies, Iff, Box, Diamond)
527
+ _MODAL_GROUND_TERM = (Constant, Number, Function)
528
+ _MODAL_RESERVED = frozenset({"World", "World_nonempty", "R", "goal"})
529
+
530
+
531
+ def _reject_non_propositional_modal(formula: Node) -> None:
532
+ """Raise ``NotImplementedError`` naming the first node outside the
533
+ propositional modal-K fragment: ``Box``/``Diamond`` + classical
534
+ connectives over GROUND atoms only. A free/bound ``Variable`` (hence any
535
+ ``Quantifier``), an agent-indexed modality (``Knows``/``Believes``/…), or
536
+ a deontic/temporal operator is rejected — the same node-type gate
537
+ :func:`~unicode_logic_kit.hol.isabelle_runner._is_alethic_propositional`
538
+ uses for the analogous Isabelle-side check.
539
+ """
540
+ for n in formula.walk():
541
+ if isinstance(n, _MODAL_K_ALLOWED) or isinstance(n, _MODAL_GROUND_TERM):
542
+ continue
543
+ raise NotImplementedError(
544
+ f"to_lean_modal_k: {type(n).__name__} is outside the propositional "
545
+ "modal-K fragment (Box/Diamond + not/and/or/xor/implies/iff over "
546
+ "GROUND atoms only -- no quantifiers, free variables, agent-indexed "
547
+ "modalities Knows/Believes, or deontic/temporal operators)."
548
+ )
549
+
550
+
551
+ class _ModalAtomNames:
552
+ """Map each DISTINCT ground atom (by its rendered Unicode key,
553
+ ``key_text(atom)`` — the text with every constant written by its bare name, the same
554
+ key :func:`~unicode_logic_kit.semantics.kripke.satisfies_modal` uses) to a
555
+ unique Lean identifier stem, exactly the way :class:`_SymbolResolver`
556
+ de-collides FOL symbols: keyed on the ORIGINAL atom key, not the
557
+ sanitised string, so two semantically distinct atoms that happen to
558
+ sanitise to the same stem (``_sanitize`` is not injective — see its
559
+ docstring) still get distinct identifiers instead of silently colliding.
560
+ """
561
+
562
+ def __init__(self):
563
+ self._used: set = set()
564
+ self._map: Dict[str, str] = {}
565
+
566
+ def ident(self, atom_key: str) -> str:
567
+ if atom_key in self._map:
568
+ return self._map[atom_key]
569
+ cand = dedupe(_sanitize(atom_key), self._used)
570
+ self._map[atom_key] = cand
571
+ return cand
572
+
573
+
574
+ def _lean_modal_body(node: Node, world: str, counter: List[int],
575
+ names: "_LeanNames", atoms: "_ModalAtomNames") -> str:
576
+ """Render ``node``'s Kripke truth condition at the Lean world-variable ``world``.
577
+
578
+ ``Box``/``Diamond`` allocate a FRESH bound-world name from ``counter``
579
+ (a single, monotonically increasing, whole-formula counter — never reused
580
+ across sibling branches either, so no nested scope can ever need to
581
+ reason about whether reuse was safe), rendering the standard clauses
582
+ ``∀ v, R w v → ⟦φ⟧v`` / ``∃ v, R w v ∧ ⟦φ⟧v``.
583
+ """
584
+ if isinstance(node, Atom):
585
+ if truth_value(node) is not None:
586
+ return "True" if truth_value(node) else "False"
587
+ atom_key = key_text(node)
588
+ ident = names.safe(("atom", atom_key), atoms.ident(atom_key))
589
+ return f"({ident} {world})"
590
+ if isinstance(node, Not):
591
+ return f"(¬ {_lean_modal_body(node.formula, world, counter, names, atoms)})"
592
+ if type(node) in _LEAN_BINOP:
593
+ op = _LEAN_BINOP[type(node)]
594
+ left = _lean_modal_body(node.left, world, counter, names, atoms)
595
+ right = _lean_modal_body(node.right, world, counter, names, atoms)
596
+ return f"({left} {op} {right})"
597
+ if isinstance(node, Xor):
598
+ left = _lean_modal_body(node.left, world, counter, names, atoms)
599
+ right = _lean_modal_body(node.right, world, counter, names, atoms)
600
+ return f"(¬ ({left} ↔ {right}))"
601
+ if isinstance(node, Box):
602
+ depth = counter[0]
603
+ counter[0] += 1
604
+ v = names.safe(("world", depth), f"w{depth}")
605
+ inner = _lean_modal_body(node.formula, v, counter, names, atoms)
606
+ return f"(∀ {v} : World, R {world} {v} → {inner})"
607
+ if isinstance(node, Diamond):
608
+ depth = counter[0]
609
+ counter[0] += 1
610
+ v = names.safe(("world", depth), f"w{depth}")
611
+ inner = _lean_modal_body(node.formula, v, counter, names, atoms)
612
+ return f"(∃ {v} : World, R {world} {v} ∧ {inner})"
613
+ raise NotImplementedError(
614
+ f"to_lean_modal_k: unsupported node {type(node).__name__} "
615
+ "(unreachable if _reject_non_propositional_modal ran first)."
616
+ )
617
+
618
+
619
+ def to_lean_modal_k(formula: Node, conjecture: bool = True, proof: str = "sorry") -> str:
620
+ """Emit a **Lean 4** shallow embedding of a propositional modal-**K** ``formula``.
621
+
622
+ Declares an uninterpreted, explicitly ``Nonempty`` Kripke world type
623
+ ``World``, an UNCONSTRAINED accessibility relation ``R : World → World →
624
+ Prop`` (frame K — no ``refl``/``trans``/... axioms), and a ``World → Prop``
625
+ valuation ``axiom`` for every distinct ground atom in ``formula``. The
626
+ goal is the formula's validity — true at every world, over any ``R`` and
627
+ any valuation satisfying just those types — ``theorem goal : ∀ w, ⟦formula⟧w
628
+ := by`` / `` <proof>`` (default ``proof="sorry"``, so the file always
629
+ elaborates without claiming a proof). ``proof`` is the tactic block body
630
+ (no leading ``by``), see :func:`to_lean_fol`. With ``conjecture=False``,
631
+ emits ``axiom goal : ∀ w, ⟦formula⟧w`` instead (no proof needed).
632
+
633
+ Only the propositional fragment is supported: ``Box``/``Diamond`` plus the
634
+ classical connectives over GROUND atoms (no quantifiers, agent-indexed
635
+ modalities, or deontic/temporal operators) — anything else raises
636
+ ``NotImplementedError`` naming the construct (see module docstring for
637
+ the two-way validation of this encoding against
638
+ :func:`~unicode_logic_kit.semantics.kripke.satisfies_modal`). A numeral inside an atom is
639
+ the constant of its value (``P(1)`` and ``P(1.0)`` are one letter); a constant spelled
640
+ like it (``n1`` next to the number ``1``) is refused by name.
641
+
642
+ This function only emits the file; it does not run Lean — see
643
+ :func:`check_theory` / :func:`lean_decide_modal_k` for the optional live tier.
644
+ """
645
+ _reject_non_propositional_modal(formula)
646
+ # An atom is a propositional letter keyed by its printed text, so ``P(1)`` and ``P(1.0)`` --
647
+ # one atom, a numeral is identified by its value -- would be two letters. Writing each numeral
648
+ # as the constant of its value gives them one text (and refuses a constant spelled like it).
649
+ [formula], _ = numerals_as_constants([formula], where="to_lean_modal_k",
650
+ spell=prefixed_numeral_name)
651
+ names = _LeanNames(_MODAL_RESERVED)
652
+ atoms = _ModalAtomNames()
653
+
654
+ seen: List[str] = []
655
+ seen_set = set()
656
+ for n in formula.walk():
657
+ if isinstance(n, Atom) and truth_value(n) is None:
658
+ key = key_text(n)
659
+ if key not in seen_set:
660
+ seen_set.add(key)
661
+ seen.append(key)
662
+
663
+ lines = [
664
+ "-- Propositional modal K embedded into Lean 4 core (no Mathlib): a shallow",
665
+ "-- Kripke embedding faithful to",
666
+ "-- unicode_logic_kit.semantics.kripke.satisfies_modal restricted to the",
667
+ "-- propositional fragment, over frame K (R is UNCONSTRAINED -- no frame",
668
+ "-- conditions). World is EXPLICITLY NONEMPTY, same reasoning as `Ind` in",
669
+ "-- the classical FOL export (see hol.lean's module docstring).",
670
+ "",
671
+ "axiom World : Type",
672
+ "axiom World_nonempty : Nonempty World",
673
+ "instance : Nonempty World := World_nonempty",
674
+ "",
675
+ "open Classical",
676
+ "",
677
+ "axiom R : World → World → Prop",
678
+ "",
679
+ ]
680
+ decls = [f"axiom {names.safe(('atom', key), atoms.ident(key))} : World → Prop"
681
+ for key in seen]
682
+ if decls:
683
+ lines += decls
684
+ lines.append("")
685
+
686
+ counter = [1]
687
+ w0 = names.safe(("world", 0), "w0")
688
+ body = _lean_modal_body(formula, w0, counter, names, atoms)
689
+ if conjecture:
690
+ lines.append(f"theorem goal : ∀ {w0} : World, {body} := by")
691
+ lines.extend(f" {pl}" for pl in proof.split("\n"))
692
+ else:
693
+ lines.append(f"axiom goal : ∀ {w0} : World, {body}")
694
+ return "\n".join(lines) + "\n"
695
+
696
+
697
+ # ===========================================================================
698
+ # (C) Optional LIVE tier: elaborate / prove through a local Lean 4 toolchain
699
+ # ===========================================================================
700
+
701
+ class LeanNotAvailable(RuntimeError):
702
+ """Raised when a Lean 4 install is required but none could be located."""
703
+
704
+
705
+ @dataclass(frozen=True)
706
+ class LeanInstall:
707
+ """A located Lean 4 installation."""
708
+
709
+ lean_exe: str # path to the `lean` binary itself
710
+ home: str # its containing toolchain/bin directory
711
+ version: Optional[str] = None
712
+
713
+ def __str__(self) -> str:
714
+ return f"Lean({self.version or '?'} at {self.lean_exe})"
715
+
716
+
717
+ def _lean_version(exe: str) -> Optional[str]:
718
+ """Return ``lean --version``'s version number, or ``None`` if it fails."""
719
+ try:
720
+ proc = subprocess.run(
721
+ [exe, "--version"], capture_output=True, text=True,
722
+ encoding="utf-8", errors="replace", timeout=10,
723
+ )
724
+ except Exception:
725
+ return None
726
+ if proc.returncode != 0:
727
+ return None
728
+ m = re.search(r"\d+\.\d+\.\d+", proc.stdout or "")
729
+ return m.group(0) if m else (proc.stdout or "").strip() or None
730
+
731
+
732
+ def _validate_lean(path: Optional[str]) -> Optional[LeanInstall]:
733
+ """Turn a candidate path (a `lean` executable, or a dir containing one,
734
+ directly or under `bin/`) into a :class:`LeanInstall`, or ``None``."""
735
+ if not path:
736
+ return None
737
+ ext = ".exe" if os.name == "nt" else ""
738
+ candidates = [path] if os.path.isfile(path) else [
739
+ os.path.join(path, "lean" + ext),
740
+ os.path.join(path, "bin", "lean" + ext),
741
+ ]
742
+ for exe in candidates:
743
+ if os.path.isfile(exe):
744
+ exe = os.path.abspath(exe)
745
+ home = os.path.dirname(exe)
746
+ return LeanInstall(lean_exe=exe, home=home, version=_lean_version(exe))
747
+ return None
748
+
749
+
750
+ _FIND_CACHE: dict = {}
751
+
752
+
753
+ def find_lean(lean_home: Optional[str] = None, *, use_cache: bool = True) -> Optional[LeanInstall]:
754
+ """Locate a Lean 4 installation, or return ``None`` if none is found.
755
+
756
+ Search order: ``lean_home`` argument (a `lean` executable, or a directory
757
+ containing one directly or under ``bin/``) -> env ``UFK_LEAN_HOME`` ->
758
+ ``lean`` on ``PATH`` -> the standard ``elan`` bin directory
759
+ ``~/.elan/bin`` (found even when ``elan-init`` was run with
760
+ ``--no-modify-path``, i.e. nothing was put on ``PATH``). The result is
761
+ cached (keyed by the explicit argument); pass ``use_cache=False`` to
762
+ force a fresh lookup.
763
+ """
764
+ key = lean_home or ""
765
+ if use_cache and key in _FIND_CACHE:
766
+ return _FIND_CACHE[key]
767
+
768
+ candidates: List[str] = []
769
+ if lean_home:
770
+ candidates.append(lean_home)
771
+ v = os.environ.get("UFK_LEAN_HOME")
772
+ if v:
773
+ candidates.append(v)
774
+ exe = shutil.which("lean")
775
+ if exe:
776
+ candidates.append(exe)
777
+ ext = ".exe" if os.name == "nt" else ""
778
+ candidates.append(os.path.join(os.path.expanduser("~"), ".elan", "bin", "lean" + ext))
779
+
780
+ found: Optional[LeanInstall] = None
781
+ seen = set()
782
+ for c in candidates:
783
+ ac = os.path.abspath(c) if c else c
784
+ if ac in seen:
785
+ continue
786
+ seen.add(ac)
787
+ inst = _validate_lean(c)
788
+ if inst:
789
+ found = inst
790
+ break
791
+ if use_cache:
792
+ _FIND_CACHE[key] = found
793
+ return found
794
+
795
+
796
+ def lean_available(lean_home: Optional[str] = None) -> bool:
797
+ """``True`` iff a Lean 4 installation can be located (cheap; cached)."""
798
+ return find_lean(lean_home) is not None
799
+
800
+
801
+ _SORRY_RE = re.compile(r"declaration uses[^\n]*sorry")
802
+
803
+
804
+ @dataclass
805
+ class LeanBuildResult:
806
+ """Outcome of elaborating one self-contained ``.lean`` file.
807
+
808
+ ``ok`` is ``True`` iff ``lean`` exits 0 — the file **elaborates** (every
809
+ declaration type-checks), which is true for a ``sorry``-closed goal too.
810
+ ``uses_sorry`` is set when the (successful) elaboration warned that a
811
+ declaration used ``sorry`` — Lean's own signal that a "proof" is not one.
812
+ :attr:`proved` (and truthiness) is the honest, stronger claim: elaborates
813
+ AND is genuinely sorry-free.
814
+ """
815
+
816
+ ok: bool
817
+ exit_code: int
818
+ output: str
819
+ theory_name: str
820
+ elapsed: float
821
+ uses_sorry: bool = False
822
+
823
+ @property
824
+ def proved(self) -> bool:
825
+ return self.ok and not self.uses_sorry
826
+
827
+ def __bool__(self) -> bool:
828
+ return self.proved
829
+
830
+
831
+ def check_theory(lean_source: str, theory_name: str, *,
832
+ install: Optional[LeanInstall] = None,
833
+ timeout: float = 120.0,
834
+ keep: bool = False) -> LeanBuildResult:
835
+ """Elaborate one self-contained Lean 4 source file and report the outcome.
836
+
837
+ Writes ``lean_source`` to ``<theory_name>.lean`` in a scratch directory
838
+ and runs bare ``lean <file>.lean`` on it (no ``lakefile``/project needed:
839
+ the classical-FOL and modal-K fragments this module emits use only Lean 4
840
+ core + ``Classical``, confirmed by hand against a real toolchain — see
841
+ module docstring). ``ok`` is ``True`` iff the process exits 0.
842
+
843
+ Args:
844
+ lean_source: the full file text (as :func:`to_lean_fol` et al. emit).
845
+ theory_name: the file's base name (without ``.lean``) — also used as
846
+ a scratch-directory-local label.
847
+ install: a :class:`LeanInstall`; located via :func:`find_lean` when
848
+ ``None``.
849
+ timeout: wall-clock subprocess timeout, in seconds.
850
+ keep: keep the scratch directory (for debugging) instead of deleting it.
851
+
852
+ Raises:
853
+ LeanNotAvailable: if no Lean 4 installation can be located.
854
+ """
855
+ install = install or find_lean()
856
+ if install is None:
857
+ raise LeanNotAvailable(
858
+ "No Lean 4 installation found. Set UFK_LEAN_HOME, put `lean` on PATH, "
859
+ "or install elan (https://leanprover-community.github.io/get_started.html) "
860
+ "with `elan-init --no-modify-path -y --default-toolchain stable`.")
861
+
862
+ work = tempfile.mkdtemp(prefix="ufk_lean_")
863
+ try:
864
+ path = os.path.join(work, theory_name + ".lean")
865
+ with open(path, "w", encoding="utf-8") as f:
866
+ f.write(lean_source)
867
+ t0 = time.perf_counter()
868
+ try:
869
+ proc = subprocess.run(
870
+ [install.lean_exe, path], capture_output=True, text=True,
871
+ encoding="utf-8", errors="replace", timeout=timeout, cwd=work,
872
+ )
873
+ code = proc.returncode
874
+ output = (proc.stdout or "") + (proc.stderr or "")
875
+ except subprocess.TimeoutExpired as e:
876
+ out = (e.stdout or "") + (e.stderr or "")
877
+ if isinstance(out, bytes):
878
+ out = out.decode("utf-8", "replace")
879
+ code, output = 124, out + f"\n[runner] wall-clock timeout after {timeout}s\n"
880
+ elapsed = time.perf_counter() - t0
881
+ return LeanBuildResult(
882
+ ok=(code == 0), exit_code=code, output=output, theory_name=theory_name,
883
+ elapsed=elapsed, uses_sorry=bool(_SORRY_RE.search(output)),
884
+ )
885
+ finally:
886
+ if not keep:
887
+ shutil.rmtree(work, ignore_errors=True)
888
+
889
+
890
+ # --------------------------------------------------------------------------- #
891
+ # Deciding validity through Lean: a small, honest tactic battery.
892
+ # --------------------------------------------------------------------------- #
893
+
894
+ VALID = "valid"
895
+ UNKNOWN = "unknown"
896
+
897
+ # `decide` is Lean 4 core (only closes a goal with a synthesizable Decidable
898
+ # instance -- the axiom-based embeddings above are intentionally NOT
899
+ # decidable, see module docstring). `tauto` / `aesop` are MATHLIB tactics:
900
+ # without Mathlib installed they fail to PARSE ("unknown tactic"), which
901
+ # check_theory reports as ok=False like any other failed attempt -- the
902
+ # battery just moves on, never a crash, never a false VALID.
903
+ DEFAULT_METHODS: Tuple[str, ...] = ("decide", "tauto", "aesop")
904
+
905
+
906
+ @dataclass
907
+ class LeanVerdict:
908
+ """Result of trying to prove a formula's validity through a Lean tactic battery.
909
+
910
+ ``status`` is ``"valid"`` (some method closed the goal with a genuinely
911
+ sorry-free proof) or ``"unknown"`` (no method in the battery did, within
912
+ budget) — there is no ``"invalid"`` here (see module docstring): this
913
+ battery can certify a proof but not construct a countermodel, so
914
+ "unknown" is the only honest outcome for a formula it cannot close,
915
+ valid or not.
916
+ """
917
+
918
+ status: str
919
+ method: Optional[str] = None
920
+ output: str = ""
921
+ elapsed: float = 0.0
922
+
923
+ @property
924
+ def is_valid(self) -> bool:
925
+ return self.status == VALID
926
+
927
+ @property
928
+ def is_unknown(self) -> bool:
929
+ return self.status == UNKNOWN
930
+
931
+ def __bool__(self) -> bool:
932
+ return self.status == VALID
933
+
934
+ def __str__(self) -> str:
935
+ extra = f" (by {self.method})" if self.status == VALID and self.method else ""
936
+ return f"LeanVerdict[{self.status}{extra}]"
937
+
938
+
939
+ def _run_battery(emit, methods: Sequence[str], timeout: float,
940
+ install: Optional[LeanInstall]) -> LeanVerdict:
941
+ install = install or find_lean()
942
+ if install is None:
943
+ raise LeanNotAvailable(
944
+ "No Lean 4 installation found. Set UFK_LEAN_HOME, put `lean` on PATH, "
945
+ "or install elan (https://leanprover-community.github.io/get_started.html).")
946
+ for m in methods:
947
+ tok = "G" + uuid.uuid4().hex[:8]
948
+ src = emit(proof=m)
949
+ r = check_theory(src, tok, install=install, timeout=timeout)
950
+ if r.proved:
951
+ return LeanVerdict(status=VALID, method=m, output=r.output, elapsed=r.elapsed)
952
+ return LeanVerdict(status=UNKNOWN)
953
+
954
+
955
+ def lean_decide_fol(
956
+ formula: Node, *,
957
+ msfol: bool = False,
958
+ native_equality: bool = False,
959
+ methods: Sequence[str] = DEFAULT_METHODS,
960
+ timeout: float = 60.0,
961
+ install: Optional[LeanInstall] = None,
962
+ ) -> LeanVerdict:
963
+ """Try to prove a classical FOL (or MSFOL) formula's validity via a Lean tactic battery.
964
+
965
+ Emits :func:`to_lean_fol` (or :func:`to_lean_msfol` when ``msfol=True``)
966
+ once per method in ``methods``, each time with that method as the tactic
967
+ (``proof=method``), and returns the first genuinely sorry-free success as
968
+ :data:`VALID` — or :data:`UNKNOWN` if none of them close it
969
+ (see :class:`LeanVerdict` and the module docstring for why there is no
970
+ ``INVALID`` outcome, and why ``UNKNOWN`` is the expected result in a
971
+ Mathlib-free environment for most nontrivial formulas).
972
+
973
+ Args:
974
+ formula: the FOL AST node.
975
+ msfol: emit the many-sorted embedding instead of plain FOL.
976
+ native_equality: render ``=`` / ``≠`` as Lean identity instead of the
977
+ uninterpreted ``feq`` / ``fneq``.
978
+ methods / timeout / install: as for :func:`check_theory`; ``methods``
979
+ defaults to :data:`DEFAULT_METHODS`.
980
+
981
+ Raises:
982
+ LeanNotAvailable: if no Lean 4 installation can be located.
983
+ """
984
+ emit_fn = to_lean_msfol if msfol else to_lean_fol
985
+
986
+ def emit(proof: str) -> str:
987
+ return emit_fn(formula, native_equality=native_equality, proof=proof)
988
+
989
+ return _run_battery(emit, methods, timeout, install)
990
+
991
+
992
+ def lean_decide_modal_k(
993
+ formula: Node, *,
994
+ methods: Sequence[str] = DEFAULT_METHODS,
995
+ timeout: float = 60.0,
996
+ install: Optional[LeanInstall] = None,
997
+ ) -> LeanVerdict:
998
+ """Try to prove a propositional modal-K formula's validity via a Lean tactic battery.
999
+
1000
+ Emits :func:`to_lean_modal_k` once per method in ``methods`` and returns
1001
+ the first genuinely sorry-free success as :data:`VALID`, else
1002
+ :data:`UNKNOWN` — exactly like :func:`lean_decide_fol` (see there, and
1003
+ the module docstring, for why there is no ``INVALID`` outcome).
1004
+
1005
+ Args:
1006
+ formula: the modal AST node (propositional fragment only — see
1007
+ :func:`to_lean_modal_k`).
1008
+ methods / timeout / install: as for :func:`lean_decide_fol`.
1009
+
1010
+ Raises:
1011
+ LeanNotAvailable: if no Lean 4 installation can be located.
1012
+ NotImplementedError: propagated from :func:`to_lean_modal_k` for a
1013
+ construct outside the propositional modal-K fragment.
1014
+ """
1015
+ def emit(proof: str) -> str:
1016
+ return to_lean_modal_k(formula, proof=proof)
1017
+
1018
+ return _run_battery(emit, methods, timeout, install)