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,1284 @@
1
+ """The core facade: seven verbs for NL→logic pipelines.
2
+
3
+ ``unicode_logic_kit.api`` bundles the whole toolkit behind a small, stable
4
+ vocabulary designed so that an LLM verification loop can consume every answer
5
+ without extra parsing:
6
+
7
+ from unicode_logic_kit import api
8
+
9
+ api.parse_any(text) # dialect detection + tolerant parsing
10
+ api.check(formula) # well-formedness + optional signature check
11
+ api.equivalent(f, g) # graded equivalence (structural → solver)
12
+ api.prove(f) # backend chain → one Verdict with provenance
13
+ api.countermodel(f) # refutation witness + plain-English gloss
14
+ api.repair(text, fixer=llm) # diagnose → suggest → (caller) fix → re-check
15
+ api.translate(t, "alc", "fol") # comorphism registry, composed via BFS
16
+
17
+ Every result object has ``to_dict()`` returning JSON-compatible data.
18
+
19
+ STABILITY POLICY (this module, :class:`~unicode_logic_kit.atp.protocol.Verdict`,
20
+ and the result dataclasses here): within a minor release line (0.N.x) nothing
21
+ is renamed or removed and dict keys only ever gain siblings; breaking changes
22
+ happen only at a minor bump, are listed in the CHANGELOG, and keep a
23
+ deprecation shim for one minor cycle where feasible. Downstream consumers
24
+ (e.g. FitchAsATP) should pin ``unicode-logic-kit>=0.N,<0.N+1`` per line.
25
+
26
+ Deliberately NOT re-exported at the package top level: ``prove`` would collide
27
+ with the resolution prover's ``prove`` that existing consumers already import
28
+ from ``unicode_logic_kit`` — the facade lives in this namespace, use
29
+ ``api.prove``.
30
+ """
31
+
32
+ import functools
33
+ import re
34
+ import sys
35
+ import threading
36
+ from dataclasses import dataclass
37
+ from typing import Callable, Iterator, Optional, Sequence, Tuple, TypeVar, Union
38
+
39
+ from .fol.nodes import Node, And
40
+ from .atp.protocol import (
41
+ Verdict, BackendUnavailable, PROVED, REFUTED, UNKNOWN, ERROR,
42
+ get_backend, default_chain, run_backend, _no_definitive_verdict,
43
+ plan_options,
44
+ )
45
+ from .eval.equivalence import EquivalenceResult
46
+ from .eval.equivalence import equivalent as _equivalent
47
+ from .comorphism import DEFAULT_REGISTRY, TranslationResult
48
+
49
+ __all__ = [
50
+ "ParseResult", "CheckResult", "RepairStep",
51
+ "parse_any", "check", "equivalent", "prove", "countermodel",
52
+ "repair", "translate",
53
+ "EquivalenceResult", "Verdict", "TranslationResult", "CountermodelResult",
54
+ ]
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # parse_any — dialect detection + tolerant parsing
59
+ # ---------------------------------------------------------------------------
60
+
61
+ # The unicode-surface parser modes tried, in order, when the text is (or falls
62
+ # back to) the kit's own syntax. Broadest-first would mask mode-specific
63
+ # meanings (⊕ is Xor in fol but StrongDisjunction in fl), so classical first.
64
+ _UNICODE_MODES: Tuple[Tuple[str, dict], ...] = (
65
+ ("fol", {}),
66
+ ("modal", {"modal": True}),
67
+ ("second_order", {"second_order": True}),
68
+ # Second-order syntax plus predicates in argument position, and NOTHING
69
+ # else: it is served by the same LALR table as the mode above it, so the
70
+ # only inputs it newly accepts are the ones with a predicate really standing
71
+ # in an argument slot -- which no mode above can express.
72
+ #
73
+ # Its MODAL sibling is deliberately NOT on the ladder. That one falls back
74
+ # to Earley (inherited from `modal`, which needs it), and Earley reaches
75
+ # readings LALR does not: with a second-order binder available, "∀ P(x)" parses
76
+ # as a quantifier over the propositional atom `x` instead of failing. Every
77
+ # other dialect reports that string as the malformed quantifier it is, and
78
+ # the repair / error-routing machinery depends on their agreeing. Reach the
79
+ # mode explicitly with MSFLParser(third_order=True, modal=True).
80
+ ("third_order", {"third_order": True}),
81
+ ("dependence", {"dependence": True}),
82
+ ("msfol", {"many_sorted": True}),
83
+ ("msfl", {"many_sorted": True, "fuzzy": True}),
84
+ ("fl", {"fuzzy": True}),
85
+ ("linear", {"linear": True}),
86
+ ("lambek", {"lambek": True}),
87
+ )
88
+ _UNICODE_HINTS = {name: kwargs for name, kwargs in _UNICODE_MODES}
89
+
90
+ # The detection signals and their order live in fol.dialect_detect — one
91
+ # importable source of truth; parse_any consumes its candidate list.
92
+ from .fol.dialect_detect import detect_dialects
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ParseResult:
97
+ """Outcome of :func:`parse_any`.
98
+
99
+ ``dialect`` names what succeeded (a unicode mode name, or ``"tptp"`` /
100
+ ``"tptp_bare"`` / ``"latex"`` / ``"prover9"`` / ``"smtlib"``); ``errors``
101
+ records every failed attempt as ``{"dialect": ..., "message": ...}`` so a
102
+ repair loop can see exactly what each parser objected to.
103
+ """
104
+
105
+ ok: bool
106
+ formula: Optional[Node] = None
107
+ dialect: Optional[str] = None
108
+ errors: Tuple[dict, ...] = ()
109
+
110
+ def __bool__(self) -> bool:
111
+ return self.ok
112
+
113
+ def to_dict(self) -> dict:
114
+ return {
115
+ "ok": self.ok,
116
+ "dialect": self.dialect,
117
+ "formula": self.formula.to_dict() if self.formula is not None else None,
118
+ "errors": list(self.errors),
119
+ }
120
+
121
+
122
+ def _try(fn, dialect: str, errors: list) -> Optional[ParseResult]:
123
+ """Run one parse attempt; success → ParseResult, failure → recorded."""
124
+ try:
125
+ node = fn()
126
+ except Exception as exc: # each parser has its own error types
127
+ errors.append({"dialect": dialect, "message": str(exc)})
128
+ return None
129
+ return ParseResult(ok=True, formula=node, dialect=dialect,
130
+ errors=tuple(errors))
131
+
132
+
133
+ def _parse_smtlib(text: str) -> Node:
134
+ from .atp.z3_input import parse_smtlib
135
+ asserts = parse_smtlib(text)
136
+ if not asserts:
137
+ raise ValueError("smtlib: no assertions found")
138
+ conj = asserts[0]
139
+ for a in asserts[1:]: # multiple asserts = their conjunction
140
+ conj = And(conj, a)
141
+ return conj
142
+
143
+
144
+ def _parse_tptp_annotated(text: str) -> Node:
145
+ from .fol.tptp_input import parse_tptp
146
+ records = parse_tptp(text)
147
+ if len(records) != 1:
148
+ raise ValueError(
149
+ f"tptp: {len(records)} annotated formulas — parse_any handles a single "
150
+ "formula; use parse_tptp / load_tptp_problem for whole problems")
151
+ return records[0].formula
152
+
153
+
154
+ def _unicode_ladder(text: str, errors: list) -> ParseResult:
155
+ """Try every unicode-surface mode in order; first success wins."""
156
+ from .fol.msflparser import MSFLParser
157
+
158
+ for name, kwargs in _UNICODE_MODES:
159
+ result = _try(lambda k=kwargs: MSFLParser(**k).parse(text), name, errors)
160
+ if result is not None:
161
+ return result
162
+ return ParseResult(ok=False, errors=tuple(errors))
163
+
164
+
165
+ def parse_any(text: str, *, hint: Optional[str] = None) -> ParseResult:
166
+ """Parse ``text`` in whatever dialect it appears to be written.
167
+
168
+ Detection order: SMT-LIB (``(assert`` …) → annotated TPTP (``fof(...)``) →
169
+ LaTeX (``\\forall`` …) → the kit's own unicode surface syntax (mode ladder,
170
+ classical first) → for pure-ASCII leftovers, bare TPTP (``![X]: ...``) and
171
+ Prover9. Nothing raises: failure returns ``ok=False`` with every attempt's
172
+ error message, ready for a repair loop.
173
+
174
+ ``hint`` pins the dialect instead of detecting: a unicode mode name
175
+ (``"fol"``, ``"modal"``, …), ``"unicode"`` (the whole mode ladder), or
176
+ ``"tptp"`` / ``"tptp_bare"`` / ``"latex"`` / ``"prover9"`` / ``"smtlib"``.
177
+ Multiple SMT-LIB assertions fold into their conjunction; a multi-formula
178
+ TPTP problem is refused (use ``load_tptp_problem``).
179
+ """
180
+ errors: list = []
181
+
182
+ if hint is not None:
183
+ if hint in _UNICODE_HINTS:
184
+ from .fol.msflparser import MSFLParser
185
+ kwargs = _UNICODE_HINTS[hint]
186
+ result = _try(lambda: MSFLParser(**kwargs).parse(text), hint, errors)
187
+ return result or ParseResult(ok=False, errors=tuple(errors))
188
+ if hint == "unicode":
189
+ return _unicode_ladder(text, errors)
190
+ if hint == "smtlib":
191
+ result = _try(lambda: _parse_smtlib(text), "smtlib", errors)
192
+ return result or ParseResult(ok=False, errors=tuple(errors))
193
+ if hint == "tptp":
194
+ result = _try(lambda: _parse_tptp_annotated(text), "tptp", errors)
195
+ return result or ParseResult(ok=False, errors=tuple(errors))
196
+ if hint == "tptp_bare":
197
+ from .fol.tptp_input import parse_tptp_formula
198
+ result = _try(lambda: parse_tptp_formula(text), "tptp_bare", errors)
199
+ return result or ParseResult(ok=False, errors=tuple(errors))
200
+ if hint == "latex":
201
+ from .fol.latex_input import parse_latex
202
+ result = _try(lambda: parse_latex(text), "latex", errors)
203
+ return result or ParseResult(ok=False, errors=tuple(errors))
204
+ if hint == "prover9":
205
+ from .fol.prover9_input import parse_prover9
206
+ result = _try(lambda: parse_prover9(text), "prover9", errors)
207
+ return result or ParseResult(ok=False, errors=tuple(errors))
208
+ raise ValueError(
209
+ f"parse_any: unknown hint {hint!r} (unicode modes "
210
+ f"{sorted(_UNICODE_HINTS)}, or 'unicode'/'tptp'/'tptp_bare'/"
211
+ f"'latex'/'prover9'/'smtlib')")
212
+
213
+ # detect_dialects nominates candidates in order (always ending in
214
+ # "unicode", the mode-ladder catch-all); each one is tried and a parse
215
+ # failure falls through to the next, accumulating its error.
216
+ for dialect in detect_dialects(text):
217
+ if dialect == "unicode":
218
+ return _unicode_ladder(text, errors)
219
+ if dialect == "smtlib":
220
+ result = _try(lambda: _parse_smtlib(text), "smtlib", errors)
221
+ elif dialect == "tptp":
222
+ result = _try(lambda: _parse_tptp_annotated(text), "tptp", errors)
223
+ elif dialect == "latex":
224
+ from .fol.latex_input import parse_latex
225
+ result = _try(lambda: parse_latex(text), "latex", errors)
226
+ elif dialect == "tptp_bare":
227
+ from .fol.tptp_input import parse_tptp_formula
228
+ result = _try(lambda: parse_tptp_formula(text), "tptp_bare", errors)
229
+ else: # "prover9"
230
+ from .fol.prover9_input import parse_prover9
231
+ result = _try(lambda: parse_prover9(text), "prover9", errors)
232
+ if result is not None:
233
+ return result
234
+ raise AssertionError("detect_dialects always ends in 'unicode'")
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Formulas nested deeper than the interpreter's recursion limit
239
+ #
240
+ # Most readers of a formula (a prover's translation, a validity check, a
241
+ # canonical form) recurse over it, and CPython's recursion limit (1000 by default)
242
+ # stops them at a nesting of a few hundred levels. A verb of this module does not
243
+ # let that surface as an exception: a formula nested at least ``_SHALLOW_DEPTH``
244
+ # levels deep is read on a worker thread whose stack and recursion limit are sized
245
+ # for it, and a reader that still runs out says so by the nesting depth.
246
+ # ---------------------------------------------------------------------------
247
+
248
+ _T = TypeVar("_T")
249
+
250
+ #: A formula nested fewer levels than this is handled on the calling thread under the
251
+ #: interpreter's own recursion limit, as every formula was before.
252
+ _SHALLOW_DEPTH = 100
253
+
254
+ #: What a deep run asks of the interpreter: the stack of its worker thread, the bytes
255
+ #: one Python frame may take of it (generous: a frame takes a few hundred), the frames
256
+ #: that one level of nesting costs a recursive reader (the backends that recurse most
257
+ #: take three on a chain of negations; eight leaves room for the other connectives) and
258
+ #: the frames the callers above the reader need.
259
+ _DEEP_STACK_BYTES = 128 * 1024 * 1024
260
+ _FRAME_BYTES = 2048
261
+ _FRAMES_PER_LEVEL = 8
262
+ _FRAME_HEADROOM = 1000
263
+
264
+ #: The deepest nesting a deep run takes on: the recursion limit it needs still fits the
265
+ #: stack, so a runaway recursion ends in a ``RecursionError``, never in a crash.
266
+ _DEEP_MAX_LEVELS = (_DEEP_STACK_BYTES // _FRAME_BYTES - _FRAME_HEADROOM) // _FRAMES_PER_LEVEL
267
+
268
+ #: The recursion limit and the stack size of new threads are process-wide settings; one
269
+ #: deep run at a time changes them.
270
+ _deep_run_lock = threading.Lock()
271
+
272
+ #: Set on the worker of a deep run: a call made from there is already where a deep formula
273
+ #: can be read, and waiting for the lock its own caller holds would wait for itself.
274
+ _on_deep_worker = threading.local()
275
+
276
+
277
+ def _nesting_depth(*formulas) -> int:
278
+ """The most nodes on one path from a root down, over ``formulas`` (computed iteratively)."""
279
+ from .atp.tableau import nesting_depth
280
+
281
+ return nesting_depth(*(f for f in formulas if isinstance(f, Node)))
282
+
283
+
284
+ def _call_deep(depth: int, function: Callable[[], _T]) -> _T:
285
+ """The result of ``function()``, run where a formula nested ``depth`` levels deep can be read.
286
+
287
+ A ``depth`` below ``_SHALLOW_DEPTH``, above what the worker's stack carries
288
+ (``_DEEP_MAX_LEVELS``), or within a recursion limit the caller has already raised
289
+ is called on the calling thread, unchanged. Otherwise the call runs on a worker
290
+ thread with a stack of ``_DEEP_STACK_BYTES`` and a recursion limit of
291
+ ``_FRAMES_PER_LEVEL`` frames per level, both restored when it returns; what the
292
+ function raises is raised on the calling thread. A worker that cannot be started
293
+ leaves the call on the calling thread. Calls of this kind do not overlap: the
294
+ recursion limit belongs to the whole process.
295
+ """
296
+ if not _SHALLOW_DEPTH <= depth <= _DEEP_MAX_LEVELS or getattr(_on_deep_worker, "on", False):
297
+ return function()
298
+ needed = depth * _FRAMES_PER_LEVEL + _FRAME_HEADROOM
299
+ if sys.getrecursionlimit() >= needed:
300
+ return function()
301
+
302
+ outcome: dict = {}
303
+
304
+ def work() -> None:
305
+ _on_deep_worker.on = True
306
+ try:
307
+ outcome["value"] = function()
308
+ except BaseException as exc: # re-raised on the calling thread
309
+ outcome["error"] = exc
310
+
311
+ with _deep_run_lock:
312
+ limit = sys.getrecursionlimit()
313
+ stack = threading.stack_size()
314
+ started = False
315
+ try:
316
+ sys.setrecursionlimit(max(limit, needed))
317
+ threading.stack_size(_DEEP_STACK_BYTES)
318
+ worker = threading.Thread(target=work, name="unicode-logic-kit-deep", daemon=True)
319
+ try:
320
+ worker.start()
321
+ started = True
322
+ finally:
323
+ threading.stack_size(stack)
324
+ worker.join()
325
+ except (ValueError, RuntimeError):
326
+ if started:
327
+ raise
328
+ finally:
329
+ sys.setrecursionlimit(limit)
330
+ if not started:
331
+ return function() # no worker could be started
332
+ if "error" in outcome:
333
+ raise outcome["error"]
334
+ return outcome["value"]
335
+
336
+
337
+ def _deep_detail(depth: int, who: str) -> str:
338
+ """The sentence that names the nesting depth a reader could not follow."""
339
+ return (f"a formula nested {depth} levels deep is deeper than {who} can read within the "
340
+ f"interpreter's recursion limit ({sys.getrecursionlimit()}): nothing was decided")
341
+
342
+
343
+ def _name_nesting(verdict: Verdict, depth: int) -> Verdict:
344
+ """``verdict`` with the nesting depth named, when the member failed because of it.
345
+
346
+ A backend that ran out of recursion on a deep formula answers ``error`` /
347
+ ``infra`` with the bare ``RecursionError``; here it becomes ``unknown`` /
348
+ ``bound_hit`` with the depth in its detail, the way the tableau reports a formula
349
+ nested deeper than it can read (the recursion limit is a bound of the search). Any
350
+ other member that mentions the recursion limit gets the depth appended to what it said.
351
+ """
352
+ if depth < _SHALLOW_DEPTH or verdict.is_definitive:
353
+ return verdict
354
+ from dataclasses import replace as _replace
355
+
356
+ detail = verdict.detail or ""
357
+ if verdict.status == ERROR and verdict.reason == "infra" and detail.startswith("RecursionError"):
358
+ return _replace(verdict, status=UNKNOWN, reason="bound_hit", szs_status=None,
359
+ detail=_deep_detail(depth, f"the {verdict.backend} backend"))
360
+ said = detail.lower()
361
+ if ("recursion limit" in said or "recursion depth" in said) and "levels deep" not in said:
362
+ return _replace(verdict, detail=f"{detail} ({_deep_detail(depth, 'the backend')})")
363
+ return verdict
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # check — well-formedness + optional signature conformance
368
+ # ---------------------------------------------------------------------------
369
+
370
+ @dataclass(frozen=True)
371
+ class CheckResult:
372
+ """Outcome of :func:`check` — a ValidationReport plus signature errors.
373
+
374
+ ``ok`` is the headline: parseable AND closed AND arity-consistent AND
375
+ lambda-free AND (when a signature was given) signature-conformant.
376
+ ``signature_errors`` entries are dicts with ``kind`` ∈
377
+ ``{"unknown_predicate", "unknown_function", "unknown_constant",
378
+ "wrong_arity"}`` plus ``symbol`` and, where applicable,
379
+ ``expected``/``seen`` and a ``suggestion`` (closest signature symbol).
380
+ """
381
+
382
+ ok: bool
383
+ parseable: bool
384
+ is_closed: bool
385
+ free_variables: Tuple[str, ...]
386
+ arity_consistent: bool
387
+ arity_conflicts: Tuple[dict, ...]
388
+ has_lambdas: bool
389
+ predicates: Tuple[str, ...]
390
+ functions: Tuple[str, ...]
391
+ constants: Tuple[str, ...]
392
+ signature_errors: Tuple[dict, ...] = ()
393
+ error: Optional[str] = None
394
+
395
+ def __bool__(self) -> bool:
396
+ return self.ok
397
+
398
+ def to_dict(self) -> dict:
399
+ return {
400
+ "ok": self.ok,
401
+ "parseable": self.parseable,
402
+ "is_closed": self.is_closed,
403
+ "free_variables": list(self.free_variables),
404
+ "arity_consistent": self.arity_consistent,
405
+ "arity_conflicts": list(self.arity_conflicts),
406
+ "has_lambdas": self.has_lambdas,
407
+ "predicates": list(self.predicates),
408
+ "functions": list(self.functions),
409
+ "constants": list(self.constants),
410
+ "signature_errors": list(self.signature_errors),
411
+ "error": self.error,
412
+ }
413
+
414
+
415
+ def _closest(name: str, candidates) -> Optional[str]:
416
+ """The lexically closest candidate for a did-you-mean suggestion."""
417
+ import difflib
418
+ matches = difflib.get_close_matches(name, list(candidates), n=1, cutoff=0.6)
419
+ return matches[0] if matches else None
420
+
421
+
422
+ def _signature_errors(report, signature: dict) -> Tuple[dict, ...]:
423
+ """Compare a ValidationReport's inventories against a signature spec.
424
+
425
+ ``signature`` keys (all optional): ``"predicates"`` / ``"functions"`` map
426
+ name → arity (or an iterable of allowed arities); ``"constants"`` is an
427
+ iterable of names. Symbols absent from a PROVIDED section are unknown;
428
+ a section left out entirely is unconstrained.
429
+ """
430
+ def allowed_arities(spec_value):
431
+ if isinstance(spec_value, int):
432
+ return {spec_value}
433
+ return set(spec_value)
434
+
435
+ errors = []
436
+ for section, kind_unknown, inventory in (
437
+ ("predicates", "unknown_predicate", report.predicates),
438
+ ("functions", "unknown_function", report.functions),
439
+ ):
440
+ if section not in signature:
441
+ continue
442
+ spec = signature[section]
443
+ for entry in inventory: # entries look like "Name/2"
444
+ name, _, arity_text = entry.rpartition("/")
445
+ arity = int(arity_text)
446
+ if name not in spec:
447
+ errors.append({
448
+ "kind": kind_unknown, "symbol": name, "arity": arity,
449
+ "suggestion": _closest(name, spec),
450
+ })
451
+ elif arity not in allowed_arities(spec[name]):
452
+ errors.append({
453
+ "kind": "wrong_arity", "symbol": name,
454
+ "expected": sorted(allowed_arities(spec[name])), "seen": arity,
455
+ "suggestion": None,
456
+ })
457
+ if "constants" in signature:
458
+ known = set(signature["constants"])
459
+ for name in report.constants:
460
+ if name not in known:
461
+ errors.append({
462
+ "kind": "unknown_constant", "symbol": name,
463
+ "suggestion": _closest(name, known),
464
+ })
465
+ return tuple(errors)
466
+
467
+
468
+ def _declares_sorts(signature) -> bool:
469
+ """Whether a signature dict is in the rich form :meth:`Signature.to_dict` emits.
470
+
471
+ That is the case when it has a ``"sorts"`` or a ``"subsorts"`` key, when an
472
+ entry of ``"predicates"`` / ``"functions"`` is itself a dict
473
+ (``{"arity": 1, "arg_sorts": None}``), or when ``"constants"`` is a dict that
474
+ gives some constant a sort. Everything else is the loose convention of
475
+ :func:`_signature_errors`.
476
+ """
477
+ from collections.abc import Mapping
478
+
479
+ if "sorts" in signature or "subsorts" in signature:
480
+ return True
481
+ for section in ("predicates", "functions"):
482
+ spec = signature.get(section)
483
+ if isinstance(spec, Mapping) and any(isinstance(v, Mapping) for v in spec.values()):
484
+ return True
485
+ constants = signature.get("constants")
486
+ return isinstance(constants, Mapping) and any(v is not None for v in constants.values())
487
+
488
+
489
+ def _loose_signature(signature) -> dict:
490
+ """The loose spec of ``signature`` (a dict), after checking its shape.
491
+
492
+ ``ValueError`` naming the offending key for what :func:`_signature_errors` could
493
+ not read: a key other than ``predicates`` / ``functions`` / ``constants``, a
494
+ section that is not a dict from a name to an arity (an ``int``, or a list of
495
+ allowed arities), a ``constants`` section that is not a list of names. It is a
496
+ ``ValueError`` (not a ``TypeError``) because the dict is a document, often a
497
+ JSON file, whose content is invalid; the command line reports that one cleanly.
498
+ """
499
+ from collections.abc import Mapping
500
+
501
+ allowed = ("constants", "functions", "predicates")
502
+ unknown = sorted(str(key) for key in signature if key not in allowed)
503
+ if unknown:
504
+ raise ValueError(
505
+ f"check: signature has the unknown key(s) {unknown}; the loose form has "
506
+ f"{list(allowed)} (the form Signature.to_dict() emits also has 'sorts' and "
507
+ "'subsorts')")
508
+ for section in ("predicates", "functions"):
509
+ if section not in signature:
510
+ continue
511
+ spec = signature[section]
512
+ if not isinstance(spec, Mapping):
513
+ raise ValueError(
514
+ f"check: signature[{section!r}] must be a dict from each name to its arity "
515
+ f"(an int, or a list of allowed arities), got {type(spec).__name__} {spec!r}")
516
+ for name, value in spec.items():
517
+ arities = value if isinstance(value, (list, tuple, set, frozenset)) else (value,)
518
+ if not all(isinstance(a, int) and not isinstance(a, bool) for a in arities):
519
+ raise ValueError(
520
+ f"check: signature[{section!r}][{name!r}] must be an arity (an int) or a "
521
+ f"list of allowed arities, got {value!r}")
522
+ if "constants" in signature:
523
+ constants = signature["constants"]
524
+ if (isinstance(constants, str) or not isinstance(constants, (list, tuple, set, frozenset, Mapping))
525
+ or not all(isinstance(c, str) for c in constants)):
526
+ raise ValueError(
527
+ f"check: signature['constants'] must be a list of constant names, got "
528
+ f"{type(constants).__name__} {constants!r}")
529
+ return dict(signature)
530
+
531
+
532
+ def _read_signature(signature):
533
+ """``(loose spec, Signature or None)`` for the ``signature=`` of :func:`check`.
534
+
535
+ A :class:`~unicode_logic_kit.fol.signature.Signature` is projected onto the loose
536
+ convention (name → arity per namespace, constant name list) and kept as the
537
+ object, because its sort declarations have no loose counterpart. A dict in the
538
+ rich form that :meth:`~unicode_logic_kit.fol.signature.Signature.to_dict` emits is
539
+ read as the ``Signature`` it describes (:meth:`~unicode_logic_kit.fol.signature.Signature.from_dict`,
540
+ whose own refusals name the entry), so what ``to_dict`` returns passes
541
+ :func:`check` unchanged. Any other dict is the loose convention, shape-checked.
542
+ ``None`` is ``(None, None)``. ``TypeError`` for an argument that is neither a
543
+ ``Signature`` nor a dict, ``ValueError`` (naming the entry) for a dict that is
544
+ malformed: a malformed ``signature=`` is a mistake of the caller's, never a
545
+ verdict about a formula.
546
+ """
547
+ from collections.abc import Mapping
548
+ from .fol.signature import Signature as _Signature
549
+
550
+ if signature is None:
551
+ return None, None
552
+ if isinstance(signature, Mapping):
553
+ if not _declares_sorts(signature):
554
+ return _loose_signature(signature), None
555
+ try:
556
+ signature = _Signature.from_dict(signature)
557
+ except TypeError as exc: # from_dict says "wrong type" with a TypeError
558
+ raise ValueError(str(exc)) from exc
559
+ if not isinstance(signature, _Signature):
560
+ raise TypeError(
561
+ f"check: signature= must be a unicode_logic_kit.fol.signature.Signature or a dict, got "
562
+ f"{type(signature).__name__}")
563
+ # Project onto the loose convention for the classic diagnostics (unknown
564
+ # symbol / wrong arity, with did-you-mean suggestions), but KEEP the object:
565
+ # its sort declarations have no loose-dict counterpart and are checked
566
+ # additionally in check() (dropping them silently returned ok=True on
567
+ # sort-violating formulas the object itself rejects).
568
+ return {
569
+ "predicates": {d.name: d.arity for d in signature.predicates.values()},
570
+ "functions": {d.name: d.arity for d in signature.functions.values()},
571
+ "constants": sorted(signature.constants),
572
+ }, signature
573
+
574
+
575
+ def check(formula: Union[Node, str], *, signature=None,
576
+ dialect: Optional[str] = None) -> CheckResult:
577
+ """Well-formedness report for a formula (or raw text) — never raises for a formula.
578
+
579
+ Accepts a parsed ``Node`` or a raw string (which goes through
580
+ :func:`parse_any` first, honouring ``dialect`` as its hint). The
581
+ structural checks are :func:`unicode_logic_kit.eval.validate`'s: closedness,
582
+ per-namespace arity consistency, lambda residue. ``signature`` adds
583
+ vocabulary conformance with did-you-mean suggestions (see
584
+ :func:`_signature_errors` for the loose-dict spec format) — and also
585
+ accepts a first-class :class:`unicode_logic_kit.fol.signature.Signature`,
586
+ which is projected onto that loose convention (name → arity per
587
+ namespace, constant name list) so the structured did-you-mean
588
+ diagnostics stay identical either way, and the dict that
589
+ :meth:`~unicode_logic_kit.fol.signature.Signature.to_dict` returns (what the
590
+ ``get_signature`` tool hands out), which is read as the ``Signature`` it
591
+ describes. The two truth constants (``⊤`` / ``$true``, ``⊥`` / ``$false``) are
592
+ logical constants, not predicates: they are listed in no inventory and are
593
+ never an unknown predicate. A malformed ``signature`` is a mistake of the
594
+ caller's, not a property of the formula: ``TypeError`` for an argument that is
595
+ neither a ``Signature`` nor a dict, ``ValueError`` naming the entry for a dict
596
+ with a key that is none of the sections, a section of the wrong type, or a rich
597
+ entry ``Signature.from_dict`` refuses. A formula nested too deeply for the
598
+ checks to read it (see :func:`prove`) comes back as ``ok=False`` with ``error``
599
+ naming the nesting depth, never as a ``RecursionError``.
600
+ """
601
+ from .eval.validate import validate
602
+
603
+ signature, signature_object = _read_signature(signature)
604
+
605
+ if isinstance(formula, str):
606
+ parsed = parse_any(formula, hint=dialect)
607
+ if not parsed.ok or parsed.formula is None: # an ok result always carries its formula
608
+ message = parsed.errors[-1]["message"] if parsed.errors else "unparseable"
609
+ return CheckResult(
610
+ ok=False, parseable=False, is_closed=False, free_variables=(),
611
+ arity_consistent=False, arity_conflicts=(), has_lambdas=False,
612
+ predicates=(), functions=(), constants=(), error=message)
613
+ formula = parsed.formula
614
+
615
+ def examine() -> CheckResult:
616
+ report = validate(formula)
617
+ conflicts = tuple(
618
+ {"namespace": ns, "symbol": name, "arities": list(arities)}
619
+ for (ns, name), arities in sorted(report.arity_conflicts.items()))
620
+ sig_errors = _signature_errors(report, signature) if signature else ()
621
+ if signature_object is not None:
622
+ # The Signature's own validate() covers what the projection cannot:
623
+ # declared argument/result/constant SORTS. Only its sort messages
624
+ # are added (the undeclared/arity classes are already reported
625
+ # above with richer, did-you-mean-carrying dicts).
626
+ sort_errors = tuple(
627
+ {"kind": "sort_mismatch", "message": message, "suggestion": None}
628
+ for message in signature_object.validate(formula)
629
+ if "sort" in message)
630
+ sig_errors = tuple(sig_errors) + sort_errors
631
+ ok = (report.is_closed and report.arity_consistent
632
+ and not report.has_lambdas and not sig_errors)
633
+ return CheckResult(
634
+ ok=ok, parseable=True,
635
+ is_closed=report.is_closed, free_variables=report.free_variable_names,
636
+ arity_consistent=report.arity_consistent, arity_conflicts=conflicts,
637
+ has_lambdas=report.has_lambdas,
638
+ predicates=report.predicates, functions=report.functions,
639
+ constants=report.constants,
640
+ signature_errors=sig_errors)
641
+
642
+ depth = _nesting_depth(formula)
643
+ try:
644
+ return _call_deep(depth, examine)
645
+ except RecursionError:
646
+ # nested deeper than a recursive reader can follow, even on the deep worker: the
647
+ # report is not made up, the result says why there is none
648
+ return CheckResult(
649
+ ok=False, parseable=True, is_closed=False, free_variables=(),
650
+ arity_consistent=False, arity_conflicts=(), has_lambdas=False,
651
+ predicates=(), functions=(), constants=(),
652
+ error=_deep_detail(depth, "the checks"))
653
+
654
+
655
+ @functools.wraps(_equivalent)
656
+ def equivalent(prediction: Node, reference: Node, **options) -> EquivalenceResult:
657
+ depth = _nesting_depth(prediction, reference)
658
+ try:
659
+ return _call_deep(depth, lambda: _equivalent(prediction, reference, **options))
660
+ except RecursionError:
661
+ # nested deeper than a recursive level can follow, even on the deep worker: the
662
+ # question stays undecided, and the result says why
663
+ method = options.get("method", "auto")
664
+ return EquivalenceResult(
665
+ equivalent=None,
666
+ method_used="solver" if method == "auto" else method,
667
+ reason=_deep_detail(depth, "the equivalence levels"))
668
+
669
+
670
+ # ---------------------------------------------------------------------------
671
+ # prove / countermodel — the backend chain
672
+ # ---------------------------------------------------------------------------
673
+
674
+ #: Node types that are the SOLE occupant of their own parser mode — a formula
675
+ #: containing one of these can only have come from that mode's grammar, so
676
+ #: routing on it is sound (see logic_backends' module docstring). Order
677
+ #: matters: hybrid is checked before the general modal test because
678
+ #: `atp.modal_tableau.has_modal` itself also counts Nominal/At as "modal"
679
+ #: (so the classical tableau gets a clean hybrid-specific rejection) — here
680
+ #: we want the MORE SPECIFIC "hybrid" logic label, not "modal", for exactly
681
+ #: those formulas. Intuitionistic and relevant logic reuse the plain
682
+ #: classical AST with no marker of their own, so they are deliberately
683
+ #: ABSENT from this table — `logic=` must be given explicitly for those two.
684
+ def _detect_logic(formula: Node, premises: Sequence[Node]) -> str:
685
+ from .atp.modal_tableau import has_modal
686
+ from .fol.nodes import Nominal, At, Product, Under, Over
687
+ from .fol._linear_nodes import (
688
+ Tensor, With, OPlus, LinearImplies, OfCourse, One, Top, Zero,
689
+ )
690
+
691
+ formulas = (formula, *premises)
692
+
693
+ def _contains(*node_types) -> bool:
694
+ return any(isinstance(node, node_types)
695
+ for f in formulas for node in f.walk())
696
+
697
+ if _contains(Nominal, At):
698
+ return "hybrid"
699
+ if _contains(Product, Under, Over):
700
+ return "lambek"
701
+ if _contains(Tensor, With, OPlus, LinearImplies, OfCourse, One, Top, Zero):
702
+ return "ill"
703
+ if any(has_modal(f) for f in formulas):
704
+ return "modal"
705
+ return "fol"
706
+
707
+
708
+ #: Which backend names this facade knows how to ask for premise relevance,
709
+ #: and the free function that answers it — see :func:`_attach_relevant_premises`.
710
+ #: Deliberately NOT every backend :func:`prove` can route to: a route with no
711
+ #: entry here leaves ``Verdict.relevant_premises`` at its default ``None``
712
+ #: rather than guessing. Vampire has no entry either, for the opposite reason:
713
+ #: its own verdict already carries the caller's premise indices (the backend asks
714
+ #: Vampire for the names of the axioms of its proof), so there is nothing to
715
+ #: re-ask.
716
+ def _relevant_premises_for(backend_name: str, formula: Node, premises: Sequence[Node],
717
+ timeout: int) -> Optional[Tuple[int, ...]]:
718
+ if backend_name == "z3":
719
+ from .atp.protocol import z3_relevant_premises
720
+ return z3_relevant_premises(formula, premises, timeout=timeout)
721
+ if backend_name == "eprover":
722
+ from .atp.eprover_backend import eprover_relevant_premises
723
+ return eprover_relevant_premises(list(premises), formula, timeout=max(1, timeout // 1000))
724
+ return None
725
+
726
+
727
+ def _attach_relevant_premises(verdict: Verdict, formula: Node, premises: Sequence[Node],
728
+ timeout: int) -> Verdict:
729
+ """Best-effort: fill ``relevant_premises`` on a PROVED verdict, using
730
+ whichever route :func:`_relevant_premises_for` supports for the backend
731
+ that actually produced ``verdict`` — re-running that SAME query, not a
732
+ different one, so the reported premises are relevant to the ANSWER the
733
+ caller got, not to some other backend's independent proof of the same
734
+ entailment. Leaves ``verdict`` untouched (``relevant_premises`` stays its
735
+ default ``None``) when the winning backend has no route, or that route
736
+ itself could not produce a trustworthy answer.
737
+ """
738
+ if verdict.status != PROVED or verdict.relevant_premises is not None:
739
+ return verdict # a backend that already reports them (Vampire) is not asked again
740
+ try:
741
+ indices = _relevant_premises_for(verdict.backend, formula, premises, timeout)
742
+ except RecursionError:
743
+ return verdict # a formula nested deeper than the re-run can read: no breakdown
744
+ if indices is None:
745
+ return verdict
746
+ from dataclasses import replace as _replace
747
+ return _replace(verdict, relevant_premises=indices)
748
+
749
+
750
+ def _unwrap_sentences(formula, premises, route: str):
751
+ """Accept :class:`unicode_logic_kit.logic.Sentence` values next to bare nodes.
752
+
753
+ A Sentence carries the side axioms of the translation that produced it (the
754
+ non-emptiness of every sort AND the membership atom ``S(c)`` of every sorted
755
+ constant of a many-sorted formula, frame conditions, a domain regime). Those are exactly the
756
+ premises the question needs, so a Sentence handed to a decision route is
757
+ unwrapped WITH them instead of having them silently dropped — dropping them
758
+ is how a valid formula comes back REFUTED. A Sentence in a logic other than
759
+ classical FOL is refused by name: convert it first.
760
+ """
761
+ from .logic import Sentence # local: logic imports api-free
762
+ extra = []
763
+ def one(value, what):
764
+ if not isinstance(value, Sentence):
765
+ return value
766
+ if value.logic not in ("fol", "msfol"):
767
+ raise ValueError(
768
+ f"{route}: {what} is a Sentence in logic {value.logic!r}, which "
769
+ f"these routes do not decide — convert it first, e.g. "
770
+ f"FOL(sentence) (unicode_logic_kit.logic), and pass that")
771
+ extra.extend(value.axioms)
772
+ return value.term
773
+ formula = one(formula, "the goal")
774
+ premises = [one(p, f"premise {i}") for i, p in enumerate(premises)]
775
+ return formula, premises + extra
776
+
777
+
778
+ def _signature_premises(caller: str, signature, logic: str) -> Tuple[Node, ...]:
779
+ """The premises that ``signature=`` adds to a call: :func:`~unicode_logic_kit.fol.signature_axioms`.
780
+
781
+ ``TypeError`` for anything but a :class:`~unicode_logic_kit.fol.signature.Signature`
782
+ (a dict is turned into one with ``Signature.from_dict``), and ``ValueError`` for a
783
+ logic other than classical first-order logic: the sentences are plain first-order
784
+ assumptions, and a modal, hybrid, intuitionistic or substructural route would read
785
+ them at one world or one resource only, where a sort guard is world-relative and a
786
+ constant is a rigid designator.
787
+ """
788
+ from .fol.signature import Signature
789
+ from .fol._msfl_nodes import signature_axioms
790
+
791
+ if not isinstance(signature, Signature):
792
+ raise TypeError(
793
+ f"{caller}: signature= must be a unicode_logic_kit.fol.signature.Signature, got "
794
+ f"{type(signature).__name__}; build one from a dict with Signature.from_dict(d)")
795
+ if logic != "fol":
796
+ raise ValueError(
797
+ f"{caller}: signature= is read by the classical first-order routes only, and "
798
+ f"the logic of this call is {logic!r}: what a signature declares is added as "
799
+ "plain first-order assumptions, and a route for another logic would read them "
800
+ "at one world (or one resource) only -- a sort guard is world-relative and a "
801
+ "constant is a rigid designator there. Leave signature= out, or state the "
802
+ "declarations in the formula.")
803
+ return signature_axioms(signature)
804
+
805
+
806
+ def _name_background(options: dict, passed: int, total: int, caller: str) -> dict:
807
+ """``options`` with ``premise_names=`` extended by names for the premises that were
808
+ appended to the ``passed`` the caller gave (the signature's sentences, the side axioms of
809
+ a Sentence): ``total`` premises are asked, the caller named only its own.
810
+
811
+ The names are the caller's premises', so what a prover reports about its proof is read as
812
+ the caller's own premises; the appended ones are background and are named by the writer
813
+ (:func:`~unicode_logic_kit.atp._writer_support.name_background_premises`). Without
814
+ ``premise_names=``, or when nothing was appended, ``options`` is returned as it is."""
815
+ names = options.get("premise_names")
816
+ if names is None or total == passed:
817
+ return options
818
+ from .atp._writer_support import name_background_premises
819
+ return {**options, "premise_names": name_background_premises(names, passed, total,
820
+ where=caller)}
821
+
822
+
823
+ def _without_background(verdict: Verdict, given: int) -> Verdict:
824
+ """``verdict`` with the premises that ``signature=`` appended removed from what it indexes.
825
+
826
+ A backend that tracks its premises (Z3's unsat core, a premise-relevance query)
827
+ names them by position, and the signature's sentences stand after the caller's
828
+ ``given`` premises. Only the caller's own premises are reported back, as indices
829
+ into the caller's own list.
830
+ """
831
+ from dataclasses import replace as _replace
832
+
833
+ changes: dict = {}
834
+ if verdict.relevant_premises is not None:
835
+ kept = tuple(i for i in verdict.relevant_premises if i < given)
836
+ if kept != tuple(verdict.relevant_premises):
837
+ changes["relevant_premises"] = kept
838
+ proof = verdict.proof
839
+ if isinstance(proof, dict) and proof.get("kind") == "z3_unsat_core":
840
+ core = list(proof.get("core", ()))
841
+ kept_core = [tag for tag in core
842
+ if not (tag.startswith("p") and tag[1:].isdigit() and int(tag[1:]) >= given)]
843
+ if kept_core != core:
844
+ changes["proof"] = {**proof, "core": kept_core}
845
+ return _replace(verdict, **changes) if changes else verdict
846
+
847
+
848
+ def prove(formula: Node, premises: Sequence[Node] = (), *,
849
+ logic: str = "auto", backends: Optional[Sequence[str]] = None,
850
+ timeout: int = 10000, require_agreement: int = 1,
851
+ relevant_premises: bool = False, signature=None,
852
+ **options) -> Verdict:
853
+ """Decide ``premises ⊨ formula`` over a chain of backends.
854
+
855
+ ``logic="auto"`` routes by syntax (modal operators → the modal chain).
856
+ ``backends=None`` uses :func:`~unicode_logic_kit.atp.protocol.default_chain`
857
+ — fast, deterministic, and never silently expensive (the minutes-per-call
858
+ ``isabelle`` backend runs only when named explicitly). An explicitly named
859
+ backend that is missing raises
860
+ :class:`~unicode_logic_kit.atp.protocol.BackendUnavailable`; one that does
861
+ not support the detected logic raises ``ValueError``.
862
+
863
+ The chain runs in order and returns the first DEFINITIVE verdict (proved /
864
+ refuted). With ``require_agreement=n`` > 1 it keeps going until ``n``
865
+ backends report the same definitive status — the returned verdict's
866
+ ``agreement`` then lists them all. If nothing definitive emerges, the
867
+ result is an UNKNOWN verdict from the pseudo-backend ``"chain"`` whose
868
+ ``detail`` summarises every member's answer: for each member that ran, its
869
+ status, its reason, and its own account of them when it gave one (the
870
+ refusal of a prover that would not read its input, the bound a search hit).
871
+ When EVERY member failed the result is an ERROR verdict
872
+ rather than an UNKNOWN one: nothing was asked and answered, and "unknown"
873
+ would read like a question that ran out of time.
874
+
875
+ ``relevant_premises=True`` additionally fills the returned verdict's
876
+ ``relevant_premises`` field on a PROVED result, by re-asking the SAME
877
+ winning backend which premises it actually needed (see
878
+ :mod:`unicode_logic_kit.atp.protocol`'s ``z3_relevant_premises`` and
879
+ :mod:`unicode_logic_kit.atp.eprover_backend`'s
880
+ ``eprover_relevant_premises``) — currently supported only when that
881
+ backend is ``"z3"`` or ``"eprover"``; any other winning backend (or a
882
+ query that route itself could not answer) leaves the field at its
883
+ default ``None`` rather than guessing. Off by default: it re-runs the
884
+ winning backend's decision procedure a second time, so only pay for it
885
+ when the caller actually wants the breakdown. The ``"vampire"`` backend
886
+ needs no second run: its verdict carries the caller's premise indices
887
+ whenever its proof names its axioms (see
888
+ :class:`~unicode_logic_kit.atp.protocol.VampireBackend`), with or without
889
+ this flag, and ``premise_names=`` names the premises it reads them by.
890
+ The indices are indices into ``premises`` as the caller gave them: the side
891
+ axioms that a :class:`~unicode_logic_kit.logic.Sentence` brings along (the
892
+ non-emptiness of its sorts, the membership atoms of its sorted constants) and
893
+ the sentences of ``signature=`` are background, and are never reported as
894
+ premises.
895
+
896
+ ``signature=`` (a :class:`~unicode_logic_kit.fol.signature.Signature`; a dict
897
+ is a ``TypeError`` that points at ``Signature.from_dict``) states what the
898
+ caller declared: the sorts, the sort of each constant, the argument and result
899
+ sorts of each function, the subsort edges. What it declares is added to the
900
+ premises of every backend of the chain as the sentences
901
+ :func:`~unicode_logic_kit.fol.signature_axioms` returns, so ``f: A → B`` makes
902
+ ``∃x:B x = f(carl:A)`` valid, a constant declared in a sort is in it, and a
903
+ subsort edge ``A < B`` is the inclusion ``A ⊆ B``. A predicate's declared
904
+ argument sorts add nothing: a predicate is a relation over the whole universe.
905
+ Indices that come back (``relevant_premises``, the Z3 core) stay indices into
906
+ ``premises``. The input is NOT checked against the signature -- a formula
907
+ that uses an undeclared symbol is decided as written; that check is
908
+ ``api.check(formula, signature=...)``. Only the classical first-order routes
909
+ read a signature (``ValueError`` for another logic).
910
+
911
+ Extra keyword ``options`` go to the backends that read them, each backend
912
+ being handed only the options it declares
913
+ (:meth:`~unicode_logic_kit.atp.protocol.ProverBackend.accepted_options`). An
914
+ option that NO backend of the chain reads is a ``ValueError`` naming the
915
+ option and the chain, raised before anything runs: it would otherwise be
916
+ ignored, and the answer given to a question the caller did not ask. A backend
917
+ that does not read an option that changes the question (a modal ``frame=``,
918
+ ``bridges=``, a ``subsorts=`` edge) while another backend of the chain does is
919
+ not run: it is listed in the chain's ``detail`` as ``unknown/unsupported``,
920
+ with the option named. An option that only bounds a search or says where a
921
+ binary lives (``max_steps=``, ``use_wsl=``) is simply not handed to a backend
922
+ that has no use for it.
923
+
924
+ A formula nested a hundred levels deep or more is decided on a worker thread
925
+ whose stack and recursion limit are sized for it (up to a nesting of about
926
+ eight thousand levels), so the interpreter's recursion limit does not decide
927
+ which backend can read it. A backend that still cannot is ``unknown`` /
928
+ ``bound_hit`` and its detail names the nesting depth; nothing in this
929
+ function raises ``RecursionError``.
930
+ """
931
+ premises = list(premises)
932
+ passed_premises = len(premises)
933
+ formula, premises = _unwrap_sentences(formula, premises, "prove")
934
+ if logic == "auto":
935
+ logic = _detect_logic(formula, premises)
936
+ chain = tuple(backends) if backends is not None else default_chain(logic)
937
+
938
+ for name in chain:
939
+ backend = get_backend(name) # ValueError on unknown names
940
+ if logic not in backend.logics:
941
+ raise ValueError(
942
+ f"prove: backend {name!r} does not support logic {logic!r} "
943
+ f"(it handles {sorted(backend.logics)})")
944
+
945
+ if signature is not None:
946
+ premises = [*premises, *_signature_premises("prove", signature, logic)]
947
+ options = _name_background(options, passed_premises, len(premises), "prove")
948
+ plan = plan_options("prove", chain, logic, options)
949
+ depth = _nesting_depth(formula, *premises)
950
+
951
+ def run_chain() -> Verdict:
952
+ verdicts = []
953
+ agreeing: dict = {}
954
+ for name in chain:
955
+ passed, refusal = plan[name]
956
+ if refusal is not None:
957
+ verdicts.append(Verdict(UNKNOWN, name, logic=logic, reason="unsupported",
958
+ detail=refusal))
959
+ continue
960
+ extra = dict(passed)
961
+ if name == "isabelle":
962
+ extra["logic"] = logic # the dual-logic backend routes on it
963
+ verdict = _name_nesting(
964
+ run_backend(name, formula, premises, timeout=timeout, **extra), depth)
965
+ verdicts.append(verdict)
966
+ if verdict.is_definitive:
967
+ group = agreeing.setdefault(verdict.status, [])
968
+ group.append(verdict)
969
+ if len(group) >= require_agreement:
970
+ first = group[0]
971
+ if len(group) == 1:
972
+ result = first
973
+ else:
974
+ from dataclasses import replace as _replace
975
+ result = _replace(first, agreement=tuple(v.backend for v in group))
976
+ if relevant_premises:
977
+ result = _attach_relevant_premises(result, formula, premises, timeout)
978
+ # what the signature and the side axioms of a Sentence added is
979
+ # background: only the caller's own premises are indexed back
980
+ return (_without_background(result, passed_premises)
981
+ if len(premises) > passed_premises else result)
982
+
983
+ return _no_definitive_verdict("chain", logic, verdicts, "empty backend chain")
984
+
985
+ return _call_deep(depth, run_chain)
986
+
987
+
988
+ @dataclass(frozen=True)
989
+ class CountermodelResult:
990
+ """Outcome of :func:`countermodel`.
991
+
992
+ ``found`` says whether a witness exists; ``model`` is the JSON-able
993
+ witness dict (same shapes as ``Verdict.countermodel``), ``backend`` names
994
+ the route that found it, and ``explanation_nl`` is a short plain-English
995
+ rendering from :func:`unicode_logic_kit.eval.explain.explain_countermodel`
996
+ (a structured Kripke witness is rebuilt and narrated world by world; a
997
+ Z3 assignment is read out; an opaque witness falls back to a one-line
998
+ gloss of its kind). ``reason`` is ``None`` unless a backend could not read
999
+ the formula it was given: a formula nested deeper than a recursive reader
1000
+ can follow is named there by its nesting depth, so that ``found=False`` is
1001
+ not mistaken for a finished search.
1002
+ """
1003
+
1004
+ found: bool
1005
+ model: Optional[dict] = None
1006
+ backend: Optional[str] = None
1007
+ explanation_nl: Optional[str] = None
1008
+ reason: Optional[str] = None
1009
+
1010
+ def __bool__(self) -> bool:
1011
+ return self.found
1012
+
1013
+ def to_dict(self) -> dict:
1014
+ return {"found": self.found, "model": self.model,
1015
+ "backend": self.backend, "explanation_nl": self.explanation_nl,
1016
+ "reason": self.reason}
1017
+
1018
+
1019
+ _WITNESS_GLOSS = {
1020
+ "finite_structure": "A finite first-order structure satisfies the premises "
1021
+ "but falsifies the conclusion.",
1022
+ "z3_model": "An assignment of the symbols (found by Z3) makes the premises "
1023
+ "true and the conclusion false.",
1024
+ "kripke": "A Kripke model falsifies the formula at its root world.",
1025
+ "nitpick": "Isabelle's nitpick found a finite counter-interpretation.",
1026
+ }
1027
+
1028
+
1029
+ def _explain_witness(witness: dict, formula: Node,
1030
+ premises: Sequence[Node]) -> Optional[str]:
1031
+ """Best-effort plain-English rendering of a Verdict witness dict.
1032
+
1033
+ A ``"kripke"`` witness carrying the structured ``"data"`` payload is
1034
+ rebuilt into a real KripkeModel so
1035
+ :func:`~unicode_logic_kit.eval.explain.explain_countermodel` can narrate
1036
+ its worlds (the formula handed along for the world-0 check is the folded
1037
+ goal ``(∧ premises) → formula`` — that is what the model falsifies, not
1038
+ the bare conclusion). Every other shape goes to ``explain_countermodel``
1039
+ directly. This field is presentational: if the rendering fails for an
1040
+ unforeseen witness payload, the one-line kind gloss is the fallback —
1041
+ never an exception out of :func:`countermodel`.
1042
+ """
1043
+ from .eval.explain import explain_countermodel
1044
+
1045
+ try:
1046
+ data = witness.get("data")
1047
+ if witness.get("kind") == "kripke" and isinstance(data, dict):
1048
+ from .atp.kripke_enum import kripke_model_from_dict
1049
+ from .fol.nodes import Implies
1050
+ goal = formula
1051
+ if premises:
1052
+ conj = premises[0]
1053
+ for p in premises[1:]:
1054
+ conj = And(conj, p)
1055
+ goal = Implies(conj, goal)
1056
+ return explain_countermodel(kripke_model_from_dict(data), goal)
1057
+ return explain_countermodel(witness)
1058
+ except Exception:
1059
+ kind = witness.get("kind")
1060
+ return _WITNESS_GLOSS.get(kind) if kind is not None else None
1061
+
1062
+
1063
+ def countermodel(formula: Node, premises: Sequence[Node] = (), *,
1064
+ logic: str = "auto", backends: Optional[Sequence[str]] = None,
1065
+ timeout: int = 10000, signature=None,
1066
+ **options) -> CountermodelResult:
1067
+ """Search for a countermodel to ``premises ⊨ formula``.
1068
+
1069
+ Runs the refutation-capable backends for the logic (FOL: the finite model
1070
+ finder first — its structures are the most readable — then Z3; modal: the
1071
+ labelled tableau, then the bounded Kripke-model enumeration — the only
1072
+ route that can refute a temporal-closure formula) and returns the first
1073
+ witness. ``found=False`` means "no countermodel within the budgets",
1074
+ never a validity claim.
1075
+
1076
+ ``signature=`` and the extra ``options`` mean here what they mean in
1077
+ :func:`prove`: what a :class:`~unicode_logic_kit.fol.signature.Signature`
1078
+ declares is added to the premises of every backend, so the witness is a
1079
+ structure in which the declarations hold; an option that no backend of the
1080
+ chain reads is a ``ValueError``, and a backend is handed only the options it
1081
+ reads.
1082
+ """
1083
+ premises = list(premises)
1084
+ if logic == "auto":
1085
+ logic = _detect_logic(formula, premises)
1086
+ chain = tuple(backends) if backends is not None else (
1087
+ ("modelfinder", "z3") if logic == "fol"
1088
+ else ("modal-tableau", "kripke-enum"))
1089
+
1090
+ asked = premises
1091
+ if signature is not None:
1092
+ asked = premises + list(_signature_premises("countermodel", signature, logic))
1093
+ options = _name_background(options, len(premises), len(asked), "countermodel")
1094
+ plan = plan_options("countermodel", chain, logic, options)
1095
+ depth = _nesting_depth(formula, *asked)
1096
+
1097
+ def search() -> CountermodelResult:
1098
+ unread = None
1099
+ for name in chain:
1100
+ passed, refusal = plan[name]
1101
+ if refusal is not None:
1102
+ continue # it would answer a different question
1103
+ verdict = _name_nesting(
1104
+ run_backend(name, formula, asked, timeout=timeout, **passed), depth)
1105
+ if verdict.status == REFUTED and verdict.countermodel is not None:
1106
+ return CountermodelResult(
1107
+ found=True, model=verdict.countermodel, backend=verdict.backend,
1108
+ explanation_nl=_explain_witness(verdict.countermodel,
1109
+ formula, premises))
1110
+ if unread is None and "levels deep" in (verdict.detail or ""):
1111
+ unread = f"{verdict.backend}: {verdict.detail}"
1112
+ return CountermodelResult(found=False, reason=unread)
1113
+
1114
+ return _call_deep(depth, search)
1115
+
1116
+
1117
+ # ---------------------------------------------------------------------------
1118
+ # repair — the diagnose→suggest→fix loop (the caller's LLM supplies the fix)
1119
+ # ---------------------------------------------------------------------------
1120
+
1121
+ @dataclass(frozen=True)
1122
+ class RepairStep:
1123
+ """One round of the repair loop.
1124
+
1125
+ ``diagnostics`` carries the machine-readable evidence (parse errors or a
1126
+ ``CheckResult`` dict), ``suggestion`` a one-line human/LLM-readable
1127
+ instruction, ``converged`` whether this text finally passed.
1128
+ """
1129
+
1130
+ attempt: int
1131
+ text: str
1132
+ ok: bool
1133
+ diagnostics: dict
1134
+ suggestion: Optional[str]
1135
+ converged: bool
1136
+
1137
+ def to_dict(self) -> dict:
1138
+ return {"attempt": self.attempt, "text": self.text, "ok": self.ok,
1139
+ "diagnostics": self.diagnostics, "suggestion": self.suggestion,
1140
+ "converged": self.converged}
1141
+
1142
+
1143
+ _POSITION_RE = re.compile(r"at position (\d+)")
1144
+
1145
+
1146
+ def _message_progress(message: str) -> float:
1147
+ """How far into the input the dialect that produced ``message`` got.
1148
+
1149
+ A message reporting that the input "ended unexpectedly" consumed all of
1150
+ it — the farthest any attempt can get; everything else is located by the
1151
+ position the parser names. Used to pick the most informative of several
1152
+ competing diagnoses (:func:`_suggest`, and the MCP server's spec-topic
1153
+ routing, which imports this rather than reimplementing it).
1154
+ """
1155
+ if "ended unexpectedly" in message.lower():
1156
+ return float("inf")
1157
+ return max((int(p) for p in _POSITION_RE.findall(message)), default=0)
1158
+
1159
+
1160
+ def _suggest(parse_result: Optional[ParseResult],
1161
+ check_result: Optional[CheckResult]) -> Optional[str]:
1162
+ """One actionable sentence out of the strongest diagnostic available."""
1163
+ if parse_result is not None and not parse_result.ok:
1164
+ if parse_result.errors:
1165
+ # The FARTHEST failure, not the last one listed. Errors arrive one
1166
+ # per candidate dialect in detection order, and the dialects at
1167
+ # the end of that order are the specialised ones that give up
1168
+ # earliest on ordinary input: for 'A ∧ B ∨ C' the last entry is
1169
+ # lambek's "Invalid predicate 'A'" at position 3, while seven
1170
+ # other dialects read to the ∨ and name the real cause (mixed
1171
+ # connectives). Handing back the last one sends the reader off
1172
+ # renaming a predicate that is already well formed.
1173
+ best = max(parse_result.errors,
1174
+ key=lambda e: _message_progress(e.get("message", "")))
1175
+ return f"Fix the syntax: {best['message']}"
1176
+ return "Fix the syntax (no parser accepted the text)."
1177
+ if check_result is None or check_result.ok:
1178
+ return None
1179
+ if not check_result.is_closed:
1180
+ free = ", ".join(check_result.free_variables)
1181
+ return (f"Bind or replace the free variable(s) {free}: quantify them "
1182
+ f"(∀/∃) or use constant names (multi-letter lowercase, e.g. "
1183
+ f"'alice' — single lowercase letters are variables).")
1184
+ if not check_result.arity_consistent:
1185
+ c = check_result.arity_conflicts[0]
1186
+ arities = "/".join(str(a) for a in c["arities"])
1187
+ return (f"Use {c['symbol']} with ONE arity — it appears with "
1188
+ f"arities {arities}.")
1189
+ if check_result.has_lambdas:
1190
+ return "Eliminate the lambda residue (beta-reduce before finalising)."
1191
+ if check_result.signature_errors:
1192
+ e = check_result.signature_errors[0]
1193
+ base = f"{e['kind'].replace('_', ' ')}: {e['symbol']}"
1194
+ if e.get("suggestion"):
1195
+ return f"{base} — did you mean {e['suggestion']!r}?"
1196
+ return f"{base} is not in the signature."
1197
+ return None
1198
+
1199
+
1200
+ def repair(text: str, *, dialect: Optional[str] = None,
1201
+ signature: Optional[dict] = None,
1202
+ fixer: Optional[Callable[[str, dict], str]] = None,
1203
+ max_attempts: int = 5) -> Iterator[RepairStep]:
1204
+ """Generator driving a diagnose→suggest→fix loop over raw formula text.
1205
+
1206
+ Each round parses (``parse_any``), checks (``check``), and yields a
1207
+ :class:`RepairStep` with machine-readable diagnostics and a one-line
1208
+ suggestion. The kit deliberately does NOT rewrite the text itself — the
1209
+ ``fixer`` callback (typically the caller's LLM, prompted with
1210
+ ``step.diagnostics`` / ``step.suggestion``) returns the next candidate
1211
+ text; without a fixer the generator yields the diagnosis for the input
1212
+ and stops. The loop ends on convergence (``converged=True``), on fixer
1213
+ absence, or after ``max_attempts`` rounds.
1214
+ """
1215
+ if max_attempts < 1:
1216
+ raise ValueError("repair: max_attempts must be >= 1")
1217
+ _read_signature(signature) # a malformed signature is refused before any round, also for text that does not parse
1218
+ current = text
1219
+ for attempt in range(1, max_attempts + 1):
1220
+ parsed = parse_any(current, hint=dialect)
1221
+ diagnostics: dict
1222
+ if parsed.ok and parsed.formula is not None: # an ok result always carries its formula
1223
+ checked = check(parsed.formula, signature=signature)
1224
+ ok = checked.ok
1225
+ diagnostics = {"parse": None, "check": checked.to_dict()}
1226
+ suggestion = _suggest(None, checked)
1227
+ else:
1228
+ ok = False
1229
+ checked = None
1230
+ diagnostics = {"parse": list(parsed.errors), "check": None}
1231
+ suggestion = _suggest(parsed, None)
1232
+ yield RepairStep(attempt=attempt, text=current, ok=ok,
1233
+ diagnostics=diagnostics, suggestion=suggestion,
1234
+ converged=ok)
1235
+ if ok or fixer is None:
1236
+ return
1237
+ current = fixer(current, diagnostics)
1238
+ if not isinstance(current, str):
1239
+ raise TypeError("repair: fixer must return the next candidate text (str)")
1240
+
1241
+
1242
+ # ---------------------------------------------------------------------------
1243
+ # translate — the comorphism registry
1244
+ # ---------------------------------------------------------------------------
1245
+
1246
+ def translate(term, from_logic: str, to_logic: str,
1247
+ **options) -> TranslationResult:
1248
+ """Translate ``term`` between logics via the comorphism registry.
1249
+
1250
+ Composes registered edges by BFS when there is no direct one (e.g.
1251
+ ``alc → modal → fol`` if the direct ``alc → fol`` edge were absent). See
1252
+ :mod:`unicode_logic_kit.comorphism` for the default edges, their term types
1253
+ and conventions (free anchors, fragment limits), and how to register your
1254
+ own.
1255
+
1256
+ ``options`` are forwarded to the edges on the path that declare them
1257
+ (``frame=``/``systems=``/``temporal_closure=`` for the modal edge,
1258
+ ``signature=`` for the sorted one, ``mode=``/``bridges=`` for the
1259
+ quantified-modal one); one that no edge on the path accepts raises
1260
+ ``ValueError`` naming what is accepted, so an option can never be
1261
+ ignored into a different question.
1262
+
1263
+ The result's ``axioms`` are the translation's SIDE CONDITIONS, already
1264
+ in the target logic, and belong in the premises::
1265
+
1266
+ t = api.translate(f, "msfol", "fol")
1267
+ api.prove(t.result, [*premises, *t.axioms])
1268
+
1269
+ or carry the pair around as a :class:`unicode_logic_kit.logic.Sentence`,
1270
+ which :func:`prove` unwraps together with its axioms. Dropping them is
1271
+ how a valid formula comes back REFUTED.
1272
+
1273
+ A formula nested a hundred levels deep or more is translated on a worker
1274
+ thread sized for it (see :func:`prove`). One that is still too deep for a
1275
+ recursive translation is a ``ValueError`` that names the nesting depth.
1276
+ """
1277
+ depth = _nesting_depth(term)
1278
+ try:
1279
+ return _call_deep(
1280
+ depth, lambda: DEFAULT_REGISTRY.translate(term, from_logic, to_logic, **options))
1281
+ except RecursionError:
1282
+ if depth < _SHALLOW_DEPTH:
1283
+ raise
1284
+ raise ValueError(f"translate: {_deep_detail(depth, 'the translation')}") from None