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,941 @@
1
+ """Repair layer over :mod:`unicode_logic_kit.fol.tptp_input` for LLM-generated TPTP.
2
+
3
+ LLM-generated TPTP fails on pure SYNTAX often enough to be worth repairing
4
+ rather than retrying — a syntax rejection costs a whole generation attempt.
5
+ Three shapes account for nearly all of it:
6
+
7
+ 1. **Biimplication structure error** — the LLM writes
8
+ ``predicate(x) <=> condition1 & condition2`` and a strict downstream
9
+ parser (gavel) rejects it for lacking explicit brackets around the
10
+ right side.
11
+ 2. **Invalid predicate name** — a chemical name used as a
12
+ predicate/function symbol starts with a digit or contains characters TPTP
13
+ forbids in a bare ``lower_word`` (hyphens, commas, parentheses — e.g. a
14
+ stereodescriptor prefix like ``(2S)`` or a positional prefix like
15
+ ``1,2-``). Renaming such a name to a plain camelCase identifier
16
+ *loses* exactly the chemical information those characters encode and
17
+ severs the link back to the ChEBI class; TPTP single-quoting
18
+ (``'1,2-diacyl-sn-glycero-3-phosphocholine'``) keeps both.
19
+ 3. **Unbound variable error** — e.g.
20
+ ``threeOxoSteroid(X) <=> (steroid & ?[A1,A2]: (c(A1) & o(A2))))``: ``X``
21
+ on the left never occurs on the right. The conservative fix drops the
22
+ argument (``threeOxoSteroid <=> …``) precisely because the definition is
23
+ a 0-ary, molecule-level property, not a property of some individual
24
+ ``X`` — and this shape is NOT reliably fixable by a parser heuristic in
25
+ general.
26
+
27
+ This module's premise (verified against :func:`parse_tptp_formula` before
28
+ writing a line here): the kit's own Lark TPTP grammar already ACCEPTS all
29
+ three shapes — case 1 by having the same precedence ladder gavel wants made
30
+ explicit, case 2 via its single-quoted-atom (``SQ``) token, case 3 by simply
31
+ leaving the variable free in the AST rather than rejecting it. So repairing is
32
+ not "write a smarter TPTP parser"; it is: parse with the grammar the kit
33
+ already has, look at what came out, and turn that into gavel-compatible text
34
+ — or, for case 2 alone (the one shape the grammar cannot even tokenise
35
+ unquoted), a narrow, auditable text rewrite BEFORE parsing.
36
+
37
+ Public API
38
+ ----------
39
+ :func:`repair_tptp_formula` — repair one bare TPTP formula, returning a
40
+ :class:`RepairResult` (``ok``, ``formula``, ``repaired_text``, ``issues``,
41
+ ``changed``). :func:`repair_tptp_problem` — the same over a whole
42
+ ``fof(name, role, formula).`` file, formula by formula, preserving every
43
+ statement's name/role/comments and touching only the formula span that needed
44
+ it.
45
+
46
+ Hard limits (refuse rather than silently reinterpret)
47
+ -------------------------------------------------------
48
+ * Case 2's raw-text detector is a narrow, two-shape heuristic (see
49
+ :data:`_CANDIDATE_RE`), not a general TPTP-syntax-error corrector: genuinely
50
+ broken input (e.g. unbalanced parentheses) that isn't explained by an
51
+ unquoted invalid name is reported ``ok=False`` with the parser's own
52
+ diagnosis, never guessed at.
53
+ * Case 3's fix is applied ONLY with ``close_free_variables=True`` (default
54
+ ``False``: report, don't touch), and even then only the narrow
55
+ ``predicate(X) <=> …`` / ``predicate(X) => …`` shape (exactly: the free
56
+ variable is the sole argument of the definiendum on the LEFT, and does not
57
+ recur on the right) gets the meaning-preserving argument-drop; anything
58
+ else gets the standard, well-understood universal closure (∀-wrap every
59
+ free variable) — never a guess at what the author meant that could go
60
+ either way.
61
+ * A predicate name that needed quoting because :mod:`tptp_input` failed to
62
+ parse it unquoted is quoted using the EXACT original substring, tracked
63
+ through re-parsing by exact ``_cap()``-image lookup (see the
64
+ ``known_names`` machinery below ``_uncap``) — no case folding, no
65
+ character loss, EVER, for a name this module itself detected and fixed.
66
+ If two DISTINCT such candidate names would reduce to the SAME ``_cap()``
67
+ image (e.g. ``"N-acetyl-x"`` and ``"n-acetyl-x"``, differing only in the
68
+ case of their first letter — both invalid unquoted, both quoted by this
69
+ module, both reparsing to the identical ``Atom`` predicate), quoting
70
+ BOTH would silently merge two distinct predicates into one; this is
71
+ detected while ``known_names`` is built and reported ``ok=False`` (a
72
+ ``"name_collision"`` issue) rather than silently keeping one of the two.
73
+ * For a name the CALLER already had quoted before this module saw the
74
+ text, the raw text is scanned independently (:func:`_find_prequoted_names`
75
+ — a best-effort, position-agnostic sibling of the case-2 detector above)
76
+ for every single-quoted literal and its exact original spelling recovered
77
+ by the same ``_cap()``-image lookup; this covers every already-quoted name
78
+ that appears unambiguously (its ``_cap()`` image doesn't collide with a
79
+ DIFFERENT quoted original found elsewhere in the same text). It cannot
80
+ distinguish a quoted name's PREDICATE vs. term (function/constant) role
81
+ from raw text alone, but that distinction is immaterial here: only
82
+ ``Atom.predicate`` rendering ever consults it (``Function``/``Constant``
83
+ rendering never folds case in the first place, so it never needs it).
84
+ The one case this cannot recover — two DIFFERENT quoted originals sharing
85
+ one ``_cap()`` image (e.g. both ``'Foo'`` and ``'foo'`` literally present
86
+ in the text) — falls back to :func:`_uncap`'s guess for either, exactly as
87
+ before this scan existed, never worse. :func:`_uncap` assumes the true
88
+ original was lower-case, which is always correct for a name that arrived
89
+ via a bare (unquoted) functor — the TPTP grammar itself requires that
90
+ first character to already be lower-case, so there is no other
91
+ possibility for such a name — and is otherwise a last-resort, irreversible
92
+ GUESS reached only when neither of the two recovery mechanisms above
93
+ applies. This residual gap is real but narrow and fully documented at
94
+ :func:`_uncap`; it never fires on either literal example above (both start
95
+ with a digit / a parenthesis, so :func:`_uncap` is a no-op regardless of
96
+ which branch computes it).
97
+ """
98
+
99
+ import re
100
+ from dataclasses import dataclass
101
+ from typing import Optional, Tuple
102
+
103
+ from ._fol_nodes import _number_text, constant_name_to_ascii
104
+ from ._tptp_symbols import truth_constant_word
105
+ from .naming import ParsingError
106
+ from .nodes import (
107
+ Node, Variable, Constant, Number, Function,
108
+ Atom, Not, And, Or, Xor, Implies, Iff, Quantifier,
109
+ free_variables,
110
+ )
111
+ from .tptp_input import parse_tptp_formula, _cap, _functor_name
112
+
113
+ __all__ = [
114
+ "Issue", "RepairResult", "repair_tptp_formula",
115
+ "ProblemRepairEntry", "ProblemRepairResult", "repair_tptp_problem",
116
+ "TptpRepairError",
117
+ ]
118
+
119
+
120
+ class TptpRepairError(Exception):
121
+ """Raised when :func:`repair_tptp_problem` cannot even locate statement
122
+ boundaries in a ``fof(...)``/``cnf(...)`` file (malformed wrapper) — a
123
+ structural problem this module refuses to guess through, distinct from a
124
+ per-formula syntax issue (which :func:`repair_tptp_formula` reports
125
+ instead of raising)."""
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class Issue:
130
+ """One diagnostic from a repair pass.
131
+
132
+ ``kind`` is one of ``"bracket_normalisation"``, ``"invalid_predicate_name"``,
133
+ ``"free_variable"``, ``"syntax_error"`` (input :func:`repair_tptp_formula`
134
+ could not turn into a valid formula at all), or ``"name_collision"`` (two
135
+ of THIS module's own case-2 candidate names would be indistinguishable
136
+ once rendered — see :func:`repair_tptp_formula`'s known_names collision
137
+ check). ``suggestion`` is optional and, when present, is the concrete
138
+ text that either WAS applied (``changed=True``) or COULD be applied by
139
+ opting in to the relevant flag.
140
+ """
141
+
142
+ kind: str
143
+ message: str
144
+ suggestion: Optional[str] = None
145
+
146
+ def to_dict(self) -> dict:
147
+ return {"kind": self.kind, "message": self.message, "suggestion": self.suggestion}
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class RepairResult:
152
+ """Outcome of :func:`repair_tptp_formula`.
153
+
154
+ ``ok``: a valid :class:`Node` was produced (either the input already
155
+ parsed, or a case-2 text rewrite made it parse). ``False`` only for
156
+ genuinely unrepairable syntax — see the module docstring's hard limits.
157
+ Note ``ok=True`` does NOT mean issue-free: an unclosed free variable
158
+ (case 3, default ``close_free_variables=False``) still yields
159
+ ``ok=True`` with a ``"free_variable"`` issue, because the AST is
160
+ perfectly valid — the kit just refuses to guess how to close it.
161
+
162
+ ``formula``: the repaired AST (``None`` iff ``ok`` is ``False``).
163
+
164
+ ``repaired_text``: canonical, gavel-compatible TPTP — fully bracketed,
165
+ every name that needs quoting quoted — that re-parses to ``formula``
166
+ exactly (see :func:`repair_tptp_formula`'s docstring for the precedence
167
+ argument backing this for case 1). Equal to the ORIGINAL input text when
168
+ ``ok`` is ``False`` (nothing is ever silently rewritten into something
169
+ unverified).
170
+
171
+ ``issues``: every :class:`Issue` found, in detection order (name issues,
172
+ then free-variable issues; bracket normalisation — if any — is implicit
173
+ in whether ``repaired_text`` differs and is reported explicitly too).
174
+
175
+ ``changed``: whether ``repaired_text`` differs from ``text`` (whitespace
176
+ collapsed away — pure reformatting is not "changed").
177
+ """
178
+
179
+ ok: bool
180
+ formula: Optional[Node]
181
+ repaired_text: str
182
+ issues: Tuple[Issue, ...]
183
+ changed: bool
184
+
185
+ def to_dict(self) -> dict:
186
+ return {
187
+ "ok": self.ok,
188
+ "formula": self.formula.to_dict() if self.formula is not None else None,
189
+ "repaired_text": self.repaired_text,
190
+ "issues": [i.to_dict() for i in self.issues],
191
+ "changed": self.changed,
192
+ }
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Case 2 — invalid predicate/function/constant names: raw-text detection
197
+ # ---------------------------------------------------------------------------
198
+ #
199
+ # tptp_input.py's grammar requires an unquoted functor name to be a TPTP
200
+ # `lower_word` (LOWER token: `[a-z][A-Za-z0-9_]*`) — this is not a gavel
201
+ # quirk, it is the TPTP standard, so a name violating it is rejected by ANY
202
+ # conformant parser, this kit's own included, unless quoted. That means the
203
+ # raw text genuinely fails to PARSE (not merely fails some downstream
204
+ # validator) before we ever get an AST to inspect — the fix has to happen at
205
+ # the text level, on the ORIGINAL characters, before parsing.
206
+ #
207
+ # _CANDIDATE_RE looks for a name-shaped span immediately followed by an
208
+ # argument list's "(" (i.e. a functor application), built from the two
209
+ # concrete shapes case 2 takes in practice:
210
+ #
211
+ # (a) a stray parenthesised prefix glued to more identifier characters,
212
+ # e.g. "(2S)Flavan4One" — `(?:\([^()\n]{1,40}\))?` optionally eats a
213
+ # single, non-nested parenthesised fragment first;
214
+ # (b) a run of letters/digits/commas/hyphens, e.g.
215
+ # "1,2-diacyl-sn-glycero-3-phosphocholine".
216
+ #
217
+ # A left-boundary negative lookbehind stops the match from starting mid-run
218
+ # (so scanning is deterministic and non-overlapping), and the whole candidate
219
+ # is discarded (never rewritten) if it ALREADY satisfies TPTP's lower_word
220
+ # rule — i.e. this only ever fires on text that would otherwise fail to
221
+ # parse, never on an ordinary `steroid(x)`-style call.
222
+
223
+ _VALID_UNQUOTED = re.compile(r"^[a-z][A-Za-z0-9_]*$")
224
+
225
+ _CANDIDATE_RE = re.compile(
226
+ r"(?<![A-Za-z0-9,\-)])"
227
+ r"((?:\([^()\n]{1,40}\))?[A-Za-z0-9][A-Za-z0-9,\-]*)"
228
+ r"(?=\s*\()"
229
+ )
230
+
231
+
232
+ def _tptp_quote(name: str) -> str:
233
+ """Wrap ``name`` in TPTP single quotes, escaping exactly ``\\`` and ``'``.
234
+
235
+ The inverse of :func:`unicode_logic_kit.fol.tptp_input._functor_name`'s
236
+ unescaping (``re.sub(r"\\\\(.)", r"\\1", s[1:-1])``): escaping only these
237
+ two characters — backslash first, so a literal backslash in the name
238
+ never gets swallowed by the quote-escaping step that follows — produces
239
+ exactly the ``'(\\\\.|[^'\\\\])*'`` shape the grammar's ``SQ`` token
240
+ requires, so the result always re-parses to ``name`` unchanged.
241
+ """
242
+ escaped = name.replace("\\", "\\\\").replace("'", "\\'")
243
+ return f"'{escaped}'"
244
+
245
+
246
+ def _find_name_candidates(text: str):
247
+ """Distinct, order-preserving ``(span, name)`` pairs :data:`_CANDIDATE_RE`
248
+ finds in ``text`` that are NOT already valid unquoted TPTP names."""
249
+ seen = set()
250
+ out = []
251
+ for m in _CANDIDATE_RE.finditer(text):
252
+ name = m.group(1)
253
+ if _VALID_UNQUOTED.match(name):
254
+ continue
255
+ if name not in seen:
256
+ seen.add(name)
257
+ out.append((m.span(1), name))
258
+ return out
259
+
260
+
261
+ def _apply_name_quoting(text: str, candidates) -> str:
262
+ """Quote every distinct candidate NAME wherever it occurs immediately
263
+ before an argument list, by name rather than by the single span
264
+ :func:`_find_name_candidates` returned for it — the same invalid name can
265
+ legitimately appear more than once (e.g. the same mis-written predicate
266
+ used on both sides of a formula), and substituting by name catches every
267
+ occurrence in one pass without having to track shifting offsets."""
268
+ out = text
269
+ for _span, name in candidates:
270
+ pattern = re.compile(re.escape(name) + r"(?=\s*\()")
271
+ out = pattern.sub(lambda m, n=name: _tptp_quote(n), out)
272
+ return out
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Canonical renderer — full bracketing (case 1) + correct quoting (case 2)
277
+ # ---------------------------------------------------------------------------
278
+ #
279
+ # Node.to_tptp() (see _fol_nodes.py) already fully brackets every connective
280
+ # and quantifier UNCONDITIONALLY — that is exactly case 1's fix, for free.
281
+ # What it does NOT do is quote a name that needs quoting (it just
282
+ # lower-cases and emits it bare), which would silently re-emit an invalid
283
+ # case-2 name. _render duplicates to_tptp()'s exact structural/operator
284
+ # choices (so behaviour is identical for everything that was already valid)
285
+ # and adds the one thing to_tptp() is missing: a quoting decision for every
286
+ # Atom/Function/Constant name.
287
+
288
+ def _needs_quote(name: str) -> bool:
289
+ return not bool(_VALID_UNQUOTED.match(name))
290
+
291
+
292
+ def _uncap(name: str) -> str:
293
+ """Undo :mod:`tptp_input`'s ``Atom``-predicate ``_cap()``: that parser
294
+ force-uppercases the first character of EVERY parsed predicate name — its
295
+ "predicate names are capitalised" convention — whether the name came
296
+ from a bare ``LOWER`` token (whose first character the TPTP grammar
297
+ itself REQUIRES to already be lower-case) or an already single-quoted
298
+ ``SQ`` token (arbitrary content); everything from the second character
299
+ on is untouched by ``_cap()`` either way. Recovering the true original
300
+ first character is needed here to decide (and correctly render) whether
301
+ a predicate name needs quoting; force-lowercasing it back is the exact
302
+ inverse whenever the true original was lower-case. That covers the
303
+ ``LOWER``-token case UNCONDITIONALLY (the grammar leaves no other
304
+ possibility). The ``SQ``-quoted case is instead recovered exactly by
305
+ :func:`_find_prequoted_names` (a raw-text scan of the original quoted
306
+ spelling) BEFORE this function is ever consulted for such a name — see
307
+ :func:`_render_predicate_name`; this function is reached for an
308
+ ``SQ``-quoted name only as the last-resort fallback when that scan
309
+ could not recover it unambiguously (see the module docstring for that
310
+ one residual case).
311
+ """
312
+ return (name[:1].lower() + name[1:]) if name and name[0].isalpha() else name
313
+
314
+
315
+ _NO_KNOWN_NAMES: dict = {}
316
+
317
+ _SQ_TOKEN_RE = re.compile(r"'(?:\\.|[^'\\])*'")
318
+
319
+
320
+ def _find_prequoted_names(text: str) -> dict:
321
+ """Best-effort ``_cap(original) -> original`` map recovered from every
322
+ single-quoted TPTP atom (``'...'``) literally present in ``text`` — i.e.
323
+ every name the CALLER already had quoted before this module ever touched
324
+ the text, as opposed to the case-2 ``known_names`` entries built in
325
+ :func:`repair_tptp_formula`, which cover names THIS module quoted itself.
326
+
327
+ This is a raw-text scan (:data:`_SQ_TOKEN_RE`, mirroring the grammar's
328
+ own ``SQ`` token), not a re-parse: it cannot tell a predicate-position
329
+ quoted name (``'Foo'(a)`` as an atom, or ``'Foo'`` as a 0-ary atom) from
330
+ a term-position one (``'Foo'`` used as a constant, or nested as a
331
+ function argument) — but that distinction never matters for correctness
332
+ here, because :func:`_render_functor_name` (``Function``/``Constant``)
333
+ never consults this map at all; only :func:`_render_predicate_name`
334
+ (``Atom.predicate``) does, and only for names ``_cap()`` has already
335
+ folded. Two DIFFERENT quoted originals sharing one ``_cap()`` image
336
+ (e.g. both ``'Foo'`` and ``'foo'`` literally present) are unrecoverably
337
+ ambiguous for that shared image from text alone — silently dropped from
338
+ the returned map (falls back to :func:`_uncap`'s pre-existing guess for
339
+ either predicate that ends up with that image, exactly today's
340
+ behaviour — never worse than before this scan existed) rather than
341
+ picking one arbitrarily.
342
+ """
343
+ found: dict = {}
344
+ ambiguous: set = set()
345
+ for m in _SQ_TOKEN_RE.finditer(text):
346
+ original = _functor_name(m.group(0))
347
+ capped = _cap(original)
348
+ prior = found.get(capped)
349
+ if prior is not None and prior != original:
350
+ ambiguous.add(capped)
351
+ continue
352
+ found[capped] = original
353
+ for key in ambiguous:
354
+ found.pop(key, None)
355
+ return found
356
+
357
+
358
+ def _render_predicate_name(name: str, known_names: dict) -> str:
359
+ """An ``Atom.predicate`` name, quoted iff it needs it.
360
+
361
+ ``known_names`` maps ``_cap(original) -> original`` for every name this
362
+ module recovered the true original spelling of before ``_cap()`` folded
363
+ it: names THIS module's own case-2 raw-text repair just quoted (an
364
+ exact, ambiguity-free record — see :func:`repair_tptp_formula`), merged
365
+ with every name :func:`_find_prequoted_names` recovered from the raw
366
+ input text (a best-effort record for names the CALLER already had
367
+ quoted). When ``name`` (the stored, ``_cap()``'d predicate) is a hit in
368
+ either source, that recovered original is used; otherwise the
369
+ best-effort :func:`_uncap` guess is used (correct whenever the name
370
+ truly arrived as a bare unquoted functor — the realistic residual gap is
371
+ now limited to a quoted name whose distinct original spelling could not
372
+ be recovered from raw text at all, e.g. it collided ambiguously with
373
+ another quoted name sharing the same ``_cap()`` image — see the module
374
+ docstring).
375
+ """
376
+ original = known_names[name] if name in known_names else _uncap(name)
377
+ return _tptp_quote(original) if _needs_quote(original) else original
378
+
379
+
380
+ def _render_functor_name(name: str) -> str:
381
+ """A ``Function``/``Constant`` name — never touched by ``_cap()`` (only
382
+ ``Atom.predicate`` is), so the stored name IS the original: quoted iff it
383
+ is not already a valid bare TPTP ``lower_word``, unchanged otherwise."""
384
+ return _tptp_quote(name) if _needs_quote(name) else name
385
+
386
+
387
+ def _render(node: Node, known_names: dict = _NO_KNOWN_NAMES) -> str:
388
+ """Render ``node`` as canonical, gavel-compatible TPTP.
389
+
390
+ Structurally identical to :meth:`Node.to_tptp` for every connective,
391
+ quantifier, and already-valid name (same brackets, same operators, same
392
+ variable upper-casing) — the behavioural differences are: (1) a name
393
+ failing TPTP's unquoted ``lower_word`` rule is single-quoted instead of
394
+ emitted bare (case 2), via :func:`_render_predicate_name` /
395
+ :func:`_render_functor_name`; (2) an already-valid ``Atom`` predicate
396
+ name is emitted via ``known_names``/:func:`_uncap` (see
397
+ :func:`_render_predicate_name`) rather than :meth:`Node.to_tptp`'s
398
+ whole-string ``.lower()`` — the whole-string fold is lossy for a
399
+ mixed-case name like ``threeOxoSteroid`` (it would come back as
400
+ ``threeoxosteroid``, a DIFFERENT ``LOWER`` token, breaking this module's
401
+ own case-1 meaning-preservation guarantee for exactly the camelCase
402
+ names ChEBI definitions use); touching only the character ``_cap()``
403
+ actually touched reparses to the identical predicate string. (3) The
404
+ truth constants ``$true`` and ``$false`` are TPTP's own propositions and are
405
+ written as they are, never quoted: a quoted ``'$false'`` is an ordinary atom to
406
+ Vampire, which then answers another question.
407
+ """
408
+ if isinstance(node, Variable):
409
+ return node.name.upper()
410
+ if isinstance(node, Number):
411
+ return _number_text(node.value)
412
+ if isinstance(node, Constant):
413
+ return _render_functor_name(constant_name_to_ascii(node.name))
414
+ if isinstance(node, Function):
415
+ if node.name in Function.TPTP_ARITH_OPS:
416
+ args = ",".join(_render(a, known_names) for a in node.args)
417
+ return f"{Function.TPTP_ARITH_OPS[node.name]}({args})"
418
+ name = _render_functor_name(node.name)
419
+ if not node.args:
420
+ return name
421
+ args = ",".join(_render(a, known_names) for a in node.args)
422
+ return f"{name}({args})"
423
+ if isinstance(node, Atom):
424
+ if node.predicate in Atom.INFIX_PREDS_TPTP and len(node.args) == 2:
425
+ op = Atom.INFIX_PREDS_TPTP[node.predicate]
426
+ return f"({_render(node.args[0], known_names)} {op} {_render(node.args[1], known_names)})"
427
+ if node.predicate in Atom.PREFIX_PREDS_TPTP and len(node.args) == 2:
428
+ op = Atom.PREFIX_PREDS_TPTP[node.predicate]
429
+ return f"{op}({_render(node.args[0], known_names)},{_render(node.args[1], known_names)})"
430
+ truth = truth_constant_word(node)
431
+ if truth is not None:
432
+ return truth # TPTP's own propositions: a quoted '$true' is an ordinary atom
433
+ name = _render_predicate_name(node.predicate, known_names)
434
+ if not node.args:
435
+ return name
436
+ args = ",".join(_render(a, known_names) for a in node.args)
437
+ return f"{name}({args})"
438
+ if isinstance(node, Not):
439
+ return f"~({_render(node.formula, known_names)})"
440
+ if isinstance(node, And):
441
+ return f"({_render(node.left, known_names)} & {_render(node.right, known_names)})"
442
+ if isinstance(node, Or):
443
+ return f"({_render(node.left, known_names)} | {_render(node.right, known_names)})"
444
+ if isinstance(node, Xor):
445
+ return f"({_render(node.left, known_names)} <~> {_render(node.right, known_names)})"
446
+ if isinstance(node, Implies):
447
+ return f"({_render(node.left, known_names)} => {_render(node.right, known_names)})"
448
+ if isinstance(node, Iff):
449
+ return f"({_render(node.left, known_names)} <=> {_render(node.right, known_names)})"
450
+ if isinstance(node, Quantifier):
451
+ var = _render(node.variable, known_names)
452
+ body = _render(node.formula, known_names)
453
+ if node.type in ("forall", "∀"):
454
+ return f"(![{var}]: {body})"
455
+ if node.type in ("exists", "∃"):
456
+ return f"(?[{var}]: {body})"
457
+ raise ValueError(f"tptp_repair: unknown quantifier type {node.type!r}")
458
+ raise NotImplementedError(
459
+ f"tptp_repair: {type(node).__name__} is outside the fof/cnf fragment "
460
+ "parse_tptp_formula can produce (this module's scope, per "
461
+ "tptp_input.py's own docstring) — nothing built from TPTP text can "
462
+ "reach this branch; it would only fire on a hand-built AST using a "
463
+ "node type outside classical FOL.")
464
+
465
+
466
+ _WS_RE = re.compile(r"\s+")
467
+
468
+
469
+ def _squash(text: str) -> str:
470
+ """Whitespace-insensitive comparison key: TPTP tokens never depend on
471
+ inter-token whitespace to disambiguate (unlike e.g. Python), so this is a
472
+ safe basis for "is this really the same formula, modulo formatting"."""
473
+ return _WS_RE.sub("", text)
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # Case 3 — free variables: report, or (opt-in) close
478
+ # ---------------------------------------------------------------------------
479
+
480
+ def _drop_argument_fix(formula: Node, var: Variable) -> Optional[Node]:
481
+ """Case 3's argument-drop fix: ``P(X) <=> …`` / ``P(X) => …`` becomes
482
+ ``P <=> …`` when ``X`` is the SOLE free variable, is the entire (single)
483
+ argument list of the left-hand definiendum, and does not recur on the
484
+ right — i.e. exactly when the formula is, in substance, already a 0-ary
485
+ molecule-level definition that only accidentally carries an argument.
486
+ Returns ``None`` (defer to universal closure) whenever this precise shape
487
+ does not hold — this function never guesses.
488
+ """
489
+ if not isinstance(formula, (Iff, Implies)):
490
+ return None
491
+ left = formula.left
492
+ if not (isinstance(left, Atom) and len(left.args) == 1 and left.args[0] == var):
493
+ return None
494
+ if var in free_variables(formula.right):
495
+ return None
496
+ cls = type(formula)
497
+ return cls(Atom(left.predicate, []), formula.right)
498
+
499
+
500
+ def _close_universally(formula: Node, variables) -> Node:
501
+ """Standard universal closure: wrap ``formula`` in ``∀v`` for every free
502
+ ``v``, outermost quantifier first in alphabetical order (order among
503
+ universal quantifiers never changes truth, so this is purely for
504
+ deterministic, readable output)."""
505
+ result = formula
506
+ for var in sorted(variables, key=lambda v: v.name, reverse=True):
507
+ result = Quantifier("∀", var, result)
508
+ return result
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Public API
513
+ # ---------------------------------------------------------------------------
514
+
515
+ def repair_tptp_formula(text: str, *, quote_invalid_names: bool = True,
516
+ close_free_variables: bool = False) -> RepairResult:
517
+ """Repair one bare TPTP FOF/CNF formula (no ``fof(...)`` wrapper).
518
+
519
+ Pipeline:
520
+
521
+ 1. Try :func:`parse_tptp_formula` directly. If ``text`` fails to parse
522
+ AND ``quote_invalid_names`` is true, look for case-2 candidates (see
523
+ :data:`_CANDIDATE_RE`) and retry on the quoted text. Neither
524
+ candidate found nor the retry succeeding is honest failure:
525
+ ``ok=False``, ``repaired_text`` left as the untouched input, a single
526
+ ``"syntax_error"`` issue carrying the parser's own message. If two
527
+ DISTINCT candidates would reduce to the same predicate once quoted
528
+ and re-parsed (see the module docstring's ``"name_collision"``
529
+ paragraph), that is honest failure too — ``ok=False`` rather than
530
+ silently merging them.
531
+ 2. Free variables (case 3) are read off the resulting AST via
532
+ :func:`~unicode_logic_kit.fol.nodes.free_variables`. Reported via a
533
+ ``"free_variable"`` issue always; additionally CLOSED (via
534
+ :func:`_drop_argument_fix` if that exact shape applies, else
535
+ :func:`_close_universally`) only when ``close_free_variables=True``.
536
+ 3. ``repaired_text`` is :func:`_render` of the (possibly closed) AST —
537
+ always fully bracketed (case 1, for free: ``Node.to_tptp`` already
538
+ brackets unconditionally and :func:`_render` matches it exactly) and
539
+ with every name that needs it quoted (case 2, render-time half: a
540
+ name that arrived ALREADY quoted in ``text`` — recovered by
541
+ :func:`_find_prequoted_names`, independently of whether it still
542
+ needs quoting on output for the same reason it arrived quoted, e.g.
543
+ an internal hyphen, or for a different reason entirely, e.g. it
544
+ merely starts upper-case — is preserved quoted, with its exact
545
+ original spelling, rather than accidentally emitted bare or
546
+ mis-cased).
547
+
548
+ **Case-1 meaning-preservation argument** (why ``repaired_text``
549
+ reparses to a formula structurally ``==`` the one parsed from ``text``,
550
+ not just logically equivalent to it): the grammar's operator ladder is
551
+ ``equiv > imp > disj > conj > unary`` (``<=>``/``<~>`` loosest, ``&``
552
+ tightest among binary connectives), applied consistently whether or not
553
+ the input carries redundant parentheses — parentheses only ever
554
+ OVERRIDE precedence, they never change how already-unambiguous text
555
+ parses. So ``p <=> a & b`` and ``p <=> (a & b)`` walk the identical
556
+ production sequence (``equiv`` → two ``imp``s, the right one reducing
557
+ through ``conj: conj "&" unary``) and build the identical tree,
558
+ ``Iff(P, And(A, B))``, in either surface form. ``repaired_text`` is
559
+ :func:`_render` of the AST parsed from ``text`` — by the identity above,
560
+ reparsing it is guaranteed to retrace the same production sequence and
561
+ rebuild the same tree, not merely an equivalent one. See
562
+ ``test_bracket_normalisation_preserves_ast`` for the executable form of
563
+ this argument (AST identity, plus an independent
564
+ :func:`unicode_logic_kit.equivalent` check as a second witness).
565
+
566
+ Args:
567
+ text: a bare TPTP formula (as accepted by ``parse_tptp_formula``, or
568
+ one violating unquoted-name syntax in the specific ways
569
+ :data:`_CANDIDATE_RE` targets).
570
+ quote_invalid_names: attempt the case-2 raw-text quoting fallback
571
+ when direct parsing fails. ``False`` disables ONLY this
572
+ fallback attempt (unparseable input then reports ``ok=False``
573
+ immediately) — it never suppresses the render-time quoting an
574
+ already-parseable formula's own names may still need, since
575
+ that is required for ``repaired_text`` to be valid TPTP at all.
576
+ close_free_variables: apply case 3's fix (see step 2) instead of
577
+ only reporting it. Default ``False``: report, never touch — an
578
+ unclosed free variable never blocks ``ok=True``.
579
+
580
+ Returns:
581
+ A :class:`RepairResult`.
582
+ """
583
+ issues = []
584
+ working_text = text
585
+ formula = None
586
+ known_names = {} # _cap(original) -> original, for names THIS call quoted itself
587
+
588
+ try:
589
+ formula = parse_tptp_formula(working_text)
590
+ except ParsingError as exc:
591
+ if quote_invalid_names:
592
+ candidates = _find_name_candidates(text)
593
+ if candidates:
594
+ retried_text = _apply_name_quoting(text, candidates)
595
+ try:
596
+ formula = parse_tptp_formula(retried_text)
597
+ except ParsingError as exc2:
598
+ return RepairResult(
599
+ ok=False, formula=None, repaired_text=text,
600
+ issues=(Issue(
601
+ "syntax_error",
602
+ f"quoting {sorted(n for _s, n in candidates)!r} did not "
603
+ f"make the formula parseable: {exc2}",
604
+ ),),
605
+ changed=False)
606
+ working_text = retried_text
607
+ for _span, name in candidates:
608
+ capped = _cap(name)
609
+ prior = known_names.get(capped)
610
+ if prior is not None and prior != name:
611
+ # Two DISTINCT case-2 candidates (e.g. "N-acetyl-x"
612
+ # and "n-acetyl-x") that differ only in the case of
613
+ # their first letter both reduce to the same
614
+ # tptp_input _cap() image and would already reparse
615
+ # to the IDENTICAL Atom predicate — silently keeping
616
+ # only one of them in known_names would merge two
617
+ # distinct predicates into one without any warning.
618
+ # Refuse rather than choose (see the module
619
+ # docstring's "refuse rather than silently
620
+ # reinterpret" line).
621
+ return RepairResult(
622
+ ok=False, formula=None, repaired_text=text,
623
+ issues=(Issue(
624
+ "name_collision",
625
+ f"case-2 quoting {name!r} and {prior!r} both "
626
+ f"reduce to the same predicate name {capped!r} "
627
+ "once tptp_input capitalises the first "
628
+ "character of every parsed Atom predicate "
629
+ "(_cap) — quoting both would silently merge "
630
+ "two distinct predicates into one; refusing "
631
+ "rather than picking one arbitrarily.",
632
+ ),),
633
+ changed=False)
634
+ known_names[capped] = name
635
+ issues.append(Issue(
636
+ "invalid_predicate_name",
637
+ f"{name!r} is not a valid unquoted TPTP name "
638
+ "(fails lower_word: [a-z][A-Za-z0-9_]*); single-quoted "
639
+ "so the name is preserved exactly.",
640
+ suggestion=_tptp_quote(name)))
641
+ if formula is None:
642
+ return RepairResult(
643
+ ok=False, formula=None, repaired_text=text,
644
+ issues=(Issue("syntax_error", str(exc)),), changed=False)
645
+ else:
646
+ return RepairResult(
647
+ ok=False, formula=None, repaired_text=text,
648
+ issues=(Issue("syntax_error", str(exc)),), changed=False)
649
+
650
+ # Independent of the case-2 path above: recover the exact spelling of
651
+ # every name the CALLER already had quoted in the raw input (Finding-2
652
+ # fix — see _find_prequoted_names), so _render never has to fall back to
653
+ # _uncap's lossy guess for a name this module never itself quoted.
654
+ # setdefault: the case-2 known_names entries above are exact/authoritative
655
+ # (built from text this module itself controlled the quoting of) and
656
+ # always take precedence over this best-effort text scan.
657
+ for capped, original in _find_prequoted_names(text).items():
658
+ known_names.setdefault(capped, original)
659
+
660
+ canonical_before_close = _render(formula, known_names)
661
+ if _squash(working_text) != _squash(canonical_before_close):
662
+ issues.append(Issue(
663
+ "bracket_normalisation",
664
+ "the input parsed only via operator precedence; brackets made "
665
+ "explicit (meaning-preserving — see repair_tptp_formula's "
666
+ "docstring for the argument).",
667
+ suggestion=canonical_before_close))
668
+
669
+ free = free_variables(formula)
670
+ final_formula = formula
671
+ if free:
672
+ names = ", ".join(sorted(v.name for v in free))
673
+ if close_free_variables:
674
+ dropped = _drop_argument_fix(formula, next(iter(free))) if len(free) == 1 else None
675
+ if dropped is not None:
676
+ final_formula = dropped
677
+ issues.append(Issue(
678
+ "free_variable",
679
+ f"unbound variable {names!r} is the sole argument of the "
680
+ "left-hand definiendum and does not recur on the right — "
681
+ "dropped it: the definition is, in substance, a 0-ary "
682
+ "property that only accidentally carries an argument.",
683
+ suggestion=_render(dropped, known_names)))
684
+ else:
685
+ final_formula = _close_universally(formula, free)
686
+ issues.append(Issue(
687
+ "free_variable",
688
+ f"unbound variable(s) {names} closed via universal "
689
+ "quantification (standard universal-closure convention; "
690
+ "the argument-drop fix does not apply — the "
691
+ "formula is not a `predicate(X) <=> …`/`predicate(X) => …` "
692
+ "definition with X absent from the right-hand side).",
693
+ suggestion=_render(final_formula, known_names)))
694
+ else:
695
+ issues.append(Issue(
696
+ "free_variable",
697
+ f"unbound variable(s) {names} — not closed (default "
698
+ "close_free_variables=False). The kit will not guess whether "
699
+ "you meant universal closure or an argument that should be "
700
+ "dropped; pass close_free_variables=True to apply the "
701
+ "documented conservative fix, or rewrite by hand."))
702
+
703
+ repaired_text = _render(final_formula, known_names)
704
+ changed = _squash(text) != _squash(repaired_text)
705
+ return RepairResult(ok=True, formula=final_formula, repaired_text=repaired_text,
706
+ issues=tuple(issues), changed=changed)
707
+
708
+
709
+ # ---------------------------------------------------------------------------
710
+ # Whole-problem repair
711
+ # ---------------------------------------------------------------------------
712
+
713
+ @dataclass(frozen=True)
714
+ class _Statement:
715
+ name: str
716
+ role: str
717
+ formula_text: str
718
+ formula_span: Tuple[int, int]
719
+
720
+
721
+ def _skip_ws_and_comments(text: str, i: int) -> int:
722
+ n = len(text)
723
+ while i < n:
724
+ c = text[i]
725
+ if c.isspace():
726
+ i += 1
727
+ elif c == "%":
728
+ while i < n and text[i] not in "\r\n":
729
+ i += 1
730
+ elif text[i:i + 2] == "/*":
731
+ end = text.find("*/", i + 2)
732
+ i = (end + 2) if end != -1 else n
733
+ else:
734
+ break
735
+ return i
736
+
737
+
738
+ def _read_ident(text: str, i: int, what: str) -> Tuple[str, int]:
739
+ n = len(text)
740
+ start = i
741
+ while i < n and (text[i].isalnum() or text[i] == "_"):
742
+ i += 1
743
+ if i == start:
744
+ raise TptpRepairError(
745
+ f"tptp_repair: expected {what} at position {start} in TPTP problem text")
746
+ return text[start:i], i
747
+
748
+
749
+ def _expect(text: str, i: int, ch: str, what: str) -> int:
750
+ if i >= len(text) or text[i] != ch:
751
+ raise TptpRepairError(
752
+ f"tptp_repair: expected {ch!r} ({what}) at position {i} in TPTP problem text")
753
+ return i + 1
754
+
755
+
756
+ def _depth_zero_stop(text: str, i: int, n: int, stop_at_comma: bool = False) -> int:
757
+ """Index of the first ``)`` (or, with ``stop_at_comma``, ``,``) at bracket
758
+ depth 0 from ``i``, skipping ``'...'`` atoms, ``"..."`` strings and
759
+ comments; ``n`` if there is none. ``(`` and ``[`` both open a level."""
760
+ depth = 0
761
+ m = i
762
+ while m < n:
763
+ c = text[m]
764
+ if c in ("'", '"'):
765
+ m += 1
766
+ while m < n and text[m] != c:
767
+ m += 2 if text[m] == "\\" else 1
768
+ m += 1
769
+ continue
770
+ if c == "%":
771
+ while m < n and text[m] not in "\r\n":
772
+ m += 1
773
+ continue
774
+ if text[m:m + 2] == "/*":
775
+ end = text.find("*/", m + 2)
776
+ m = (end + 2) if end != -1 else n
777
+ continue
778
+ if c in "([":
779
+ depth += 1
780
+ elif c in ")]":
781
+ if depth == 0:
782
+ return m if c == ")" else n
783
+ depth -= 1
784
+ elif c == "," and depth == 0 and stop_at_comma:
785
+ return m
786
+ m += 1
787
+ return n
788
+
789
+
790
+ def _split_tptp_statements(text: str) -> Tuple[_Statement, ...]:
791
+ """Locate every ``fof(name, role, FORMULA).`` / ``cnf(...)`` statement's
792
+ exact ``FORMULA`` text span, without requiring the formula itself to be
793
+ parseable — the whole point is to hand each formula to
794
+ :func:`repair_tptp_formula` individually, so one broken statement must
795
+ never prevent locating the others.
796
+
797
+ A hand-rolled character scanner (not the Lark grammar) because the
798
+ grammar's own ``file`` rule parses formulas inline: a syntax error in
799
+ ANY one statement fails the whole file, which is exactly what per-formula
800
+ repair needs to route around. Tracks ``'...'`` quoted atoms (backslash
801
+ escapes) and ``%``/``/* */`` comments so parens/quotes inside them never
802
+ perturb paren-depth tracking; a statement's formula ends at the first
803
+ ``,`` or ``)`` seen at depth 0 (both ``()`` and ``[]`` count, so the comma
804
+ in ``![X,Y]`` stays inside), and the statement at the ``)`` closing it,
805
+ which must be followed (after whitespace/comments) by ``.``. Whatever sits
806
+ between a formula's ``,`` and that ``)`` — the optional source and
807
+ useful-info annotations — is left untouched. An ``include(...)``
808
+ directive holds no formula and is skipped the same way.
809
+ """
810
+ n = len(text)
811
+ i = 0
812
+ out = []
813
+ while True:
814
+ i = _skip_ws_and_comments(text, i)
815
+ if i >= n:
816
+ break
817
+ keyword, i = _read_ident(text, i, "'fof', 'cnf' or 'include'")
818
+ if keyword == "include":
819
+ i = _skip_ws_and_comments(text, i)
820
+ i = _expect(text, i, "(", "include opening paren")
821
+ end = _depth_zero_stop(text, i, n)
822
+ if end >= n or text[end] != ")":
823
+ raise TptpRepairError(
824
+ "tptp_repair: an include directive has unbalanced parentheses")
825
+ after = _skip_ws_and_comments(text, end + 1)
826
+ i = _expect(text, after, ".", "include terminator")
827
+ continue
828
+ if keyword not in ("fof", "cnf"):
829
+ raise TptpRepairError(
830
+ f"tptp_repair: unsupported TPTP statement keyword {keyword!r} "
831
+ "(only fof, cnf and include are supported)")
832
+ i = _skip_ws_and_comments(text, i)
833
+ i = _expect(text, i, "(", "statement opening paren")
834
+ i = _skip_ws_and_comments(text, i)
835
+ name, i = _read_ident(text, i, "statement name")
836
+ i = _skip_ws_and_comments(text, i)
837
+ i = _expect(text, i, ",", "name/role separator")
838
+ i = _skip_ws_and_comments(text, i)
839
+ role, i = _read_ident(text, i, "statement role")
840
+ i = _skip_ws_and_comments(text, i)
841
+ i = _expect(text, i, ",", "role/formula separator")
842
+ i = _skip_ws_and_comments(text, i)
843
+ formula_start = i
844
+
845
+ m = _depth_zero_stop(text, i, n, stop_at_comma=True)
846
+ if m >= n:
847
+ raise TptpRepairError(
848
+ f"tptp_repair: statement {name!r} has unbalanced parentheses "
849
+ "(no closing ')' found for its formula)")
850
+ formula_end = m
851
+ if text[m] == ",":
852
+ m = _depth_zero_stop(text, m + 1, n)
853
+ if m >= n or text[m] != ")":
854
+ raise TptpRepairError(
855
+ f"tptp_repair: statement {name!r} has unbalanced parentheses "
856
+ "(no closing ')' found after its annotations)")
857
+ after = _skip_ws_and_comments(text, m + 1)
858
+ after = _expect(text, after, ".", "statement terminator")
859
+
860
+ out.append(_Statement(name=name, role=role,
861
+ formula_text=text[formula_start:formula_end],
862
+ formula_span=(formula_start, formula_end)))
863
+ i = after
864
+ return tuple(out)
865
+
866
+
867
+ @dataclass(frozen=True)
868
+ class ProblemRepairEntry:
869
+ """One statement's repair, with its ``fof(...)`` name/role preserved."""
870
+
871
+ name: str
872
+ role: str
873
+ result: RepairResult
874
+
875
+ def to_dict(self) -> dict:
876
+ return {"name": self.name, "role": self.role, "result": self.result.to_dict()}
877
+
878
+
879
+ @dataclass(frozen=True)
880
+ class ProblemRepairResult:
881
+ """Whole-file repair: every statement's :class:`ProblemRepairEntry`, plus
882
+ the reassembled file text (each statement's ``fof(``/``, name, role,``/
883
+ ``).`` wrapper and every byte between statements — comments included —
884
+ kept byte-for-byte; only each formula's own span is replaced by its
885
+ ``RepairResult.repaired_text``, and only when that repair succeeded).
886
+ """
887
+
888
+ entries: Tuple[ProblemRepairEntry, ...]
889
+ ok: bool
890
+ repaired_text: str
891
+ changed: bool
892
+
893
+ def to_dict(self) -> dict:
894
+ return {
895
+ "entries": [e.to_dict() for e in self.entries],
896
+ "ok": self.ok,
897
+ "repaired_text": self.repaired_text,
898
+ "changed": self.changed,
899
+ }
900
+
901
+
902
+ def repair_tptp_problem(text: str, *, quote_invalid_names: bool = True,
903
+ close_free_variables: bool = False) -> ProblemRepairResult:
904
+ """:func:`repair_tptp_formula`, applied statement-by-statement to a whole
905
+ TPTP problem file (one or more ``fof(name, role, formula).`` /
906
+ ``cnf(...)`` statements).
907
+
908
+ Each statement is repaired independently (see :func:`_split_tptp_statements`
909
+ for why a whole-file Lark parse cannot be used here: one broken formula
910
+ must not block the rest), and only its formula span is rewritten in the
911
+ reassembled ``repaired_text`` — the wrapper, name, role, and every
912
+ comment/blank line survive untouched. ``ok`` is the conjunction of every
913
+ per-statement ``RepairResult.ok``; ``changed`` the disjunction.
914
+
915
+ Raises:
916
+ TptpRepairError: the file's statement STRUCTURE (not a formula's
917
+ content) is malformed — an unrecognised keyword, a missing
918
+ ``,``/``.``, or unbalanced parentheses that prevent even locating
919
+ where a formula ends. Distinct from a per-formula syntax issue,
920
+ which is reported per-entry instead.
921
+ """
922
+ statements = _split_tptp_statements(text)
923
+ entries = []
924
+ pieces = []
925
+ cursor = 0
926
+ all_ok = True
927
+ any_changed = False
928
+ for stmt in statements:
929
+ result = repair_tptp_formula(
930
+ stmt.formula_text, quote_invalid_names=quote_invalid_names,
931
+ close_free_variables=close_free_variables)
932
+ entries.append(ProblemRepairEntry(stmt.name, stmt.role, result))
933
+ all_ok = all_ok and result.ok
934
+ any_changed = any_changed or result.changed
935
+ start, end = stmt.formula_span
936
+ pieces.append(text[cursor:start])
937
+ pieces.append(result.repaired_text)
938
+ cursor = end
939
+ pieces.append(text[cursor:])
940
+ return ProblemRepairResult(entries=tuple(entries), ok=all_ok,
941
+ repaired_text="".join(pieces), changed=any_changed)