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,551 @@
1
+ """Recursive backtracking proof *search* for Fitch natural deduction.
2
+
3
+ Where :mod:`unicode_logic_kit.atp.fitch` *checks* a hand-written proof, this module
4
+ *finds* one: a goal-directed, depth-bounded backtracking search that builds an
5
+ actual :class:`~unicode_logic_kit.atp.fitch.Proof` for the classical propositional
6
+ and first-order fragment.
7
+
8
+ **Soundness is free.** Whatever the search assembles is handed to ``check_proof``
9
+ before it is returned, so a bug in the search or the proof-assembly can only make
10
+ it *fail to find* a proof — never return an unsound one. Like
11
+ :func:`resolution.prove`, the search is sound and, under its depth bound,
12
+ *incomplete*: ``find_fitch_proof`` returning ``None`` means "no proof found within
13
+ the bound", never "not a theorem".
14
+
15
+ Strategy (classical natural deduction):
16
+
17
+ 1. **Forward saturation** — cheap eliminations (∧E, →E, ↔E, ¬¬E, ⊥I, and bounded
18
+ ∀E instantiation) are applied to a fixpoint, so anything directly derivable is
19
+ found before any branching.
20
+ 2. **Goal-directed introduction** — for a goal with a main connective, the matching
21
+ introduction rule (∧I, →I via an assumption box, ¬I, ∨I, ↔I, ∀I via an
22
+ eigenvariable box, ∃I via a witness term).
23
+ 3. **Elimination by case split** — ∨E over an available disjunction, ∃E over an
24
+ available existential (with a fresh eigenvariable).
25
+ 4. **Reductio (RAA)** — assume ¬goal and derive ⊥; this is what makes the search
26
+ classically complete for the propositional fragment (it proves goals, such as
27
+ ``P ∨ ¬P``, that no introduction rule reaches).
28
+
29
+ Public API: :func:`find_fitch_proof`, :func:`fitch_prove`, :func:`is_valid_fitch`.
30
+ """
31
+
32
+ from typing import Dict, List, Optional, Tuple
33
+
34
+ from ..fol.nodes import (
35
+ Node, Atom, Not, And, Or, Implies, Iff, Quantifier, Variable, Constant, Function,
36
+ )
37
+ from ..fol._msfl_nodes import key_text
38
+ from ..fol._truth_constants import is_true_constant
39
+ from .fitch import (
40
+ Proof, Subproof, Line, premise, assume, line, flag,
41
+ FALSUM, is_falsum, verify_proof, _canon_q, _subst_var, _free_vars, _q_kind, _is_term,
42
+ )
43
+
44
+
45
+ # A search result is an abstract derivation term — a tagged tuple describing HOW a
46
+ # formula is obtained, with no line numbers yet (the assembler adds those):
47
+ # ("ref", f) — cite an in-scope line holding f
48
+ # ("rule", tag, f, [deriv...], extra) — a flat rule (∧I/∧E/∨I/→E/↔E/¬E/⊥I/⊥E/∀E/∃I)
49
+ # ("impI", A, B, body) — →I: box [assume A ⊢ B]
50
+ # ("notI", A, body) — ¬I: box [assume A ⊢ ⊥]
51
+ # ("raa", goal, body) — RAA: box [assume ¬goal ⊢ ⊥]
52
+ # ("iffI", A, B, body1, body2) — ↔I: boxes [A⊢B], [B⊢A]
53
+ # ("orE", disj_deriv, A, B, body1, body2, goal) — ∨E
54
+ # ("allI", x, phi, e, body) — ∀I: flag box [e ⊢ phi[x:=e]]
55
+ # ("exE", ex_deriv, e, phi_e, body, goal) — ∃E: box [assume phi_e flag e ⊢ goal]
56
+
57
+ Deriv = tuple
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Terms and fresh variables
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def _ground_terms(node: Node, acc: set) -> None:
65
+ """Collect closed (variable-free) constant/function terms occurring in ``node``."""
66
+ if isinstance(node, (Constant,)):
67
+ acc.add(node)
68
+ elif isinstance(node, Function):
69
+ if not _free_vars(node):
70
+ acc.add(node)
71
+ for a in node.args:
72
+ _ground_terms(a, acc)
73
+ elif isinstance(node, Atom):
74
+ for a in node.args:
75
+ _ground_terms(a, acc)
76
+ else:
77
+ for child in node._child_nodes():
78
+ _ground_terms(child, acc)
79
+
80
+
81
+ def _all_names(node: Node, acc: set) -> None:
82
+ """Collect every variable name (free or bound) occurring in ``node``."""
83
+ if isinstance(node, Variable):
84
+ acc.add(node.name)
85
+ if isinstance(node, Quantifier):
86
+ acc.add(node.variable.name)
87
+ for child in node._child_nodes():
88
+ _all_names(child, acc)
89
+
90
+
91
+ class _Search:
92
+ """Holds the immutable problem context (term pool, fresh-name source, budget)."""
93
+
94
+ def __init__(self, premises: List[Node], goal: Node, max_depth: int):
95
+ self.max_depth = max_depth
96
+ self.budget = [60000] # per-attempt step backstop
97
+ names: set = set()
98
+ terms: set = set()
99
+ for f in list(premises) + [goal]:
100
+ _all_names(f, names)
101
+ _ground_terms(f, terms)
102
+ if not terms:
103
+ terms.add(Constant("a")) # a witness/instantiation term must exist
104
+ self.used_names = names
105
+ # ordered by the bare names of the constants, so that the quotes of a quoted constant
106
+ # play no part in which term is tried first
107
+ self.base_terms = sorted(terms, key=key_text)
108
+ self._eigen = [0]
109
+
110
+ def fresh_var(self) -> Variable:
111
+ """Return a globally fresh eigenvariable not occurring anywhere in the problem."""
112
+ while True:
113
+ name = f"_e{self._eigen[0]}"
114
+ self._eigen[0] += 1
115
+ if name not in self.used_names:
116
+ self.used_names.add(name)
117
+ return Variable(name)
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Forward saturation
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def _saturate(derivs: Dict[Node, Deriv], terms: List[Node]) -> None:
125
+ """Close ``derivs`` under cheap eliminations (mutates it in place)."""
126
+ pool = terms
127
+ changed = True
128
+ rounds = 0
129
+ while changed and rounds < 40:
130
+ changed = False
131
+ rounds += 1
132
+ for f in list(derivs):
133
+ if isinstance(f, And):
134
+ for part in (f.left, f.right):
135
+ if part not in derivs:
136
+ derivs[part] = ("rule", "∧E", part, [derivs[f]], ())
137
+ changed = True
138
+ elif isinstance(f, Not) and isinstance(f.formula, Not):
139
+ inner = f.formula.formula
140
+ if inner not in derivs:
141
+ derivs[inner] = ("rule", "¬E", inner, [derivs[f]], ())
142
+ changed = True
143
+ elif isinstance(f, Iff):
144
+ if f.left in derivs and f.right not in derivs:
145
+ derivs[f.right] = ("rule", "↔E", f.right, [derivs[f], derivs[f.left]], ())
146
+ changed = True
147
+ if f.right in derivs and f.left not in derivs:
148
+ derivs[f.left] = ("rule", "↔E", f.left, [derivs[f], derivs[f.right]], ())
149
+ changed = True
150
+ elif isinstance(f, Implies):
151
+ if f.left in derivs and f.right not in derivs:
152
+ derivs[f.right] = ("rule", "→E", f.right, [derivs[f], derivs[f.left]], ())
153
+ changed = True
154
+ elif _q_kind(f) == "∀":
155
+ for t in pool:
156
+ inst = _subst_var(f.formula, f.variable, t)
157
+ if inst not in derivs:
158
+ derivs[inst] = ("rule", "∀E", inst, [derivs[f]], (t,))
159
+ changed = True
160
+ if FALSUM not in derivs:
161
+ for f in list(derivs):
162
+ if Not(f) in derivs:
163
+ derivs[FALSUM] = ("rule", "⊥I", FALSUM, [derivs[f], derivs[Not(f)]], ())
164
+ changed = True
165
+ break
166
+ if FALSUM not in derivs:
167
+ # A fact that IS a falsum (the truth constant `$false`) puts a contradiction
168
+ # in scope on its own: ⊥E turns it into the search's own ⊥.
169
+ for f in list(derivs):
170
+ if is_falsum(f):
171
+ derivs[FALSUM] = ("rule", "⊥E", FALSUM, [derivs[f]], ())
172
+ changed = True
173
+ break
174
+
175
+
176
+ def _extend(derivs: Dict[Node, Deriv], formula: Node) -> Dict[Node, Deriv]:
177
+ """Return a copy of ``derivs`` with ``formula`` added as a base (Ref) fact."""
178
+ new = dict(derivs)
179
+ if formula not in new:
180
+ new[formula] = ("ref", formula)
181
+ return new
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # The search
186
+ # ---------------------------------------------------------------------------
187
+
188
+ def _search(derivs: Dict[Node, Deriv], goal: Node, depth: int,
189
+ ctx: "_Search", terms: List[Node]) -> Optional[Deriv]:
190
+ """Find an abstract derivation of ``goal`` from ``derivs``, or None within bounds.
191
+
192
+ ``terms`` is the instantiation/witness term set in scope on this branch — the
193
+ problem's ground terms plus the eigenvariables of enclosing ∀I / ∃E boxes. It is
194
+ threaded (not global) so the universal-instantiation pool stays bounded per path.
195
+ """
196
+ if ctx.budget[0] <= 0:
197
+ return None
198
+ ctx.budget[0] -= 1
199
+
200
+ derivs = dict(derivs)
201
+ _saturate(derivs, terms)
202
+ if goal in derivs:
203
+ return derivs[goal]
204
+ if is_true_constant(goal):
205
+ return ("rule", "⊤I", goal, [], ()) # the truth constant needs no premise
206
+ # Ex falso quodlibet: once a contradiction is in scope, any goal follows (the
207
+ # search's own ⊥ is the goal it is looking for; the truth constant `$false` is an
208
+ # ordinary goal that ⊥E reaches like any other).
209
+ if FALSUM in derivs and goal != FALSUM:
210
+ return ("rule", "⊥E", goal, [derivs[FALSUM]], ())
211
+ if depth <= 0:
212
+ return None
213
+
214
+ found = _try_intro(derivs, goal, depth, ctx, terms)
215
+ if found is not None:
216
+ return found
217
+
218
+ # Elimination by case split on an available disjunction.
219
+ for f in list(derivs):
220
+ if isinstance(f, Or):
221
+ d1 = _search(_extend(derivs, f.left), goal, depth - 1, ctx, terms)
222
+ if d1 is None:
223
+ continue
224
+ d2 = _search(_extend(derivs, f.right), goal, depth - 1, ctx, terms)
225
+ if d2 is None:
226
+ continue
227
+ return ("orE", derivs[f], f.left, f.right, d1, d2, goal)
228
+
229
+ # ∃-elimination over an available existential (fresh eigenvariable).
230
+ for f in list(derivs):
231
+ if _q_kind(f) == "∃":
232
+ e = ctx.fresh_var()
233
+ phi_e = _subst_var(f.formula, f.variable, e)
234
+ body = _search(_extend(derivs, phi_e), goal, depth - 1, ctx, terms + [e])
235
+ if body is not None and e not in _free_vars(goal) and e not in _free_vars(f):
236
+ return ("exE", derivs[f], e, phi_e, body, goal)
237
+
238
+ # Backward chaining: prove ``goal`` via an available implication X → goal.
239
+ for f in list(derivs):
240
+ if isinstance(f, Implies) and f.right == goal and f.left != goal:
241
+ d = _search(derivs, f.left, depth - 1, ctx, terms)
242
+ if d is not None:
243
+ return ("rule", "→E", goal, [derivs[f], d], ())
244
+
245
+ # Reductio ad absurdum (classical completeness for the propositional fragment).
246
+ if goal != FALSUM:
247
+ body = _search(_extend(derivs, Not(goal)), FALSUM, depth - 1, ctx, terms)
248
+ if body is not None:
249
+ return ("raa", goal, body)
250
+
251
+ return None
252
+
253
+
254
+ def _try_intro(derivs: Dict[Node, Deriv], goal: Node, depth: int,
255
+ ctx: "_Search", terms: List[Node]) -> Optional[Deriv]:
256
+ """Try the introduction rule for ``goal``'s main connective."""
257
+ if isinstance(goal, And):
258
+ d1 = _search(derivs, goal.left, depth - 1, ctx, terms)
259
+ if d1 is None:
260
+ return None
261
+ d2 = _search(derivs, goal.right, depth - 1, ctx, terms)
262
+ if d2 is None:
263
+ return None
264
+ return ("rule", "∧I", goal, [d1, d2], ())
265
+
266
+ if isinstance(goal, Implies):
267
+ body = _search(_extend(derivs, goal.left), goal.right, depth - 1, ctx, terms)
268
+ if body is not None:
269
+ return ("impI", goal.left, goal.right, body)
270
+ return None
271
+
272
+ if isinstance(goal, Not):
273
+ body = _search(_extend(derivs, goal.formula), FALSUM, depth - 1, ctx, terms)
274
+ if body is not None:
275
+ return ("notI", goal.formula, body)
276
+ return None
277
+
278
+ if isinstance(goal, Iff):
279
+ d1 = _search(_extend(derivs, goal.left), goal.right, depth - 1, ctx, terms)
280
+ if d1 is None:
281
+ return None
282
+ d2 = _search(_extend(derivs, goal.right), goal.left, depth - 1, ctx, terms)
283
+ if d2 is None:
284
+ return None
285
+ return ("iffI", goal.left, goal.right, d1, d2)
286
+
287
+ if isinstance(goal, Or):
288
+ d = _search(derivs, goal.left, depth - 1, ctx, terms)
289
+ if d is not None:
290
+ return ("rule", "∨I", goal, [d], ())
291
+ d = _search(derivs, goal.right, depth - 1, ctx, terms)
292
+ if d is not None:
293
+ return ("rule", "∨I", goal, [d], ())
294
+ return None
295
+
296
+ if _q_kind(goal) == "∀":
297
+ e = ctx.fresh_var()
298
+ phi_e = _subst_var(goal.formula, goal.variable, e)
299
+ body = _search(derivs, phi_e, depth - 1, ctx, terms + [e])
300
+ if body is not None:
301
+ return ("allI", goal.variable, goal.formula, e, body)
302
+ return None
303
+
304
+ if _q_kind(goal) == "∃":
305
+ for t in terms:
306
+ witness = _subst_var(goal.formula, goal.variable, t)
307
+ d = _search(derivs, witness, depth - 1, ctx, terms)
308
+ if d is not None:
309
+ return ("rule", "∃I", goal, [d], (t,))
310
+ return None
311
+
312
+ if goal == FALSUM:
313
+ # Prove ⊥ from a fact f by proving its negation, or from ¬g by proving g.
314
+ for f in list(derivs):
315
+ if isinstance(f, Not):
316
+ proven = _search(derivs, f.formula, depth - 1, ctx, terms)
317
+ if proven is not None:
318
+ return ("rule", "⊥I", FALSUM, [proven, derivs[f]], ())
319
+ else:
320
+ neg = _search(derivs, Not(f), depth - 1, ctx, terms)
321
+ if neg is not None:
322
+ return ("rule", "⊥I", FALSUM, [derivs[f], neg], ())
323
+ return None
324
+
325
+ return None
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Assembly: abstract derivation -> a numbered Fitch Proof
330
+ # ---------------------------------------------------------------------------
331
+
332
+ class _Asm:
333
+ """Mutable assembly context: a shared line counter + the in-scope fact→line map."""
334
+
335
+ def __init__(self, counter: List[int], available: Dict[Node, int]):
336
+ self.counter = counter
337
+ self.available = available
338
+ self.out: List = []
339
+
340
+ def fresh(self) -> int:
341
+ self.counter[0] += 1
342
+ return self.counter[0]
343
+
344
+
345
+ def _emit(deriv: Deriv, asm: "_Asm") -> int:
346
+ """Emit the lines for ``deriv`` into ``asm`` and return the line number of its result."""
347
+ tag = deriv[0]
348
+
349
+ if tag == "ref":
350
+ return asm.available[deriv[1]]
351
+
352
+ if tag == "rule":
353
+ _, rule, formula, subs, extra = deriv
354
+ if formula in asm.available:
355
+ return asm.available[formula]
356
+ cites = [_emit(s, asm) for s in subs]
357
+ num = asm.fresh()
358
+ asm.out.append(line(num, formula, rule, *cites, extra=extra))
359
+ asm.available[formula] = num
360
+ return num
361
+
362
+ if tag == "impI":
363
+ _, ante, cons, body = deriv
364
+ span = _emit_box(asm, ante, None, body, cons)
365
+ num = asm.fresh()
366
+ asm.out.append(line(num, Implies(ante, cons), "→I", span))
367
+ asm.available[Implies(ante, cons)] = num
368
+ return num
369
+
370
+ if tag == "notI":
371
+ _, phi, body = deriv
372
+ span = _emit_box(asm, phi, None, body, FALSUM)
373
+ num = asm.fresh()
374
+ asm.out.append(line(num, Not(phi), "¬I", span))
375
+ asm.available[Not(phi)] = num
376
+ return num
377
+
378
+ if tag == "raa":
379
+ _, goal, body = deriv
380
+ span = _emit_box(asm, Not(goal), None, body, FALSUM)
381
+ num = asm.fresh()
382
+ asm.out.append(line(num, goal, "RAA", span))
383
+ asm.available[goal] = num
384
+ return num
385
+
386
+ if tag == "iffI":
387
+ _, a, b, body1, body2 = deriv
388
+ span1 = _emit_box(asm, a, None, body1, b)
389
+ span2 = _emit_box(asm, b, None, body2, a)
390
+ num = asm.fresh()
391
+ asm.out.append(line(num, Iff(a, b), "↔I", span1, span2))
392
+ asm.available[Iff(a, b)] = num
393
+ return num
394
+
395
+ if tag == "orE":
396
+ _, disj_deriv, a, b, body1, body2, goal = deriv
397
+ disj_line = _emit(disj_deriv, asm)
398
+ span1 = _emit_box(asm, a, None, body1, goal)
399
+ span2 = _emit_box(asm, b, None, body2, goal)
400
+ num = asm.fresh()
401
+ asm.out.append(line(num, goal, "∨E", disj_line, span1, span2))
402
+ asm.available[goal] = num
403
+ return num
404
+
405
+ if tag == "allI":
406
+ _, x, phi, e, body = deriv
407
+ phi_e = _subst_var(phi, x, e)
408
+ span = _emit_box(asm, None, e, body, phi_e)
409
+ num = asm.fresh()
410
+ asm.out.append(line(num, Quantifier("∀", x, phi), "∀I", span))
411
+ asm.available[Quantifier("∀", x, phi)] = num
412
+ return num
413
+
414
+ if tag == "exE":
415
+ _, ex_deriv, e, phi_e, body, goal = deriv
416
+ ex_line = _emit(ex_deriv, asm)
417
+ span = _emit_box(asm, phi_e, e, body, goal)
418
+ num = asm.fresh()
419
+ asm.out.append(line(num, goal, "∃E", ex_line, span))
420
+ asm.available[goal] = num
421
+ return num
422
+
423
+ raise AssertionError(f"unknown derivation tag {tag!r}")
424
+
425
+
426
+ def _emit_box(asm: "_Asm", assumption: Optional[Node], flag_var: Optional[Variable],
427
+ body: Deriv, body_goal: Node) -> Tuple[int, int]:
428
+ """Emit a subproof box and append it to ``asm.out``; return its (start, end) span."""
429
+ child = _Asm(asm.counter, dict(asm.available))
430
+ head_num = child.fresh()
431
+ if assumption is None and flag_var is not None:
432
+ head = flag(head_num, flag_var) # ∀I: pure eigenvariable box
433
+ else:
434
+ head = assume(head_num, assumption) # →I/¬I/RAA/∨E/↔I (and ∃E, with a flag)
435
+ child.available[assumption] = head_num
436
+
437
+ result = _emit(body, child)
438
+ # The box's last line must be body_goal; if it is not, reiterate it.
439
+ if not (child.out and isinstance(child.out[-1], Line)
440
+ and child.out[-1].formula == body_goal):
441
+ rnum = child.fresh()
442
+ child.out.append(line(rnum, body_goal, "Reit", result))
443
+
444
+ end_num = child.counter[0]
445
+ box = Subproof(assumption=head, body=tuple(child.out), flag=flag_var)
446
+ asm.out.append(box)
447
+ return (head_num, end_num)
448
+
449
+
450
+ def _assemble(premises: List[Node], goal: Node, deriv: Deriv) -> Proof:
451
+ """Turn an abstract derivation into a numbered :class:`Proof`."""
452
+ premise_lines = [premise(i, f) for i, f in enumerate(premises, start=1)]
453
+ available: Dict[Node, int] = {}
454
+ for i, f in enumerate(premises, start=1):
455
+ available.setdefault(f, i)
456
+ counter = [len(premises)]
457
+ asm = _Asm(counter, available)
458
+ result = _emit(deriv, asm)
459
+ # Ensure the proof's last top-level line is the goal.
460
+ if not (asm.out and isinstance(asm.out[-1], Line) and asm.out[-1].formula == goal):
461
+ num = asm.fresh()
462
+ asm.out.append(line(num, goal, "Reit", result))
463
+ return Proof(premises=tuple(premise_lines), steps=tuple(asm.out), logic="fol")
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # Public API
468
+ # ---------------------------------------------------------------------------
469
+
470
+ def find_fitch_proof(premises, conclusion: Node, max_depth: int = 8) -> Optional[Proof]:
471
+ """Search for a Fitch proof of ``premises ⊢ conclusion``; return it or ``None``.
472
+
473
+ Goal-directed backtracking over the classical propositional + first-order rules.
474
+ The returned proof is re-validated by ``check_proof`` before it is handed back,
475
+ so the result is always a genuinely valid proof (or ``None``). ``None`` means "no
476
+ proof found within ``max_depth``", never "not a theorem" — the search is sound
477
+ but, under its depth bound, incomplete (first-order provability is undecidable).
478
+
479
+ ``Contrast`` and ``Count`` are reduced to their classical readings up front
480
+ (∧ and the distinct-witnesses expansion), so the returned proof derives the
481
+ reduced formula. A MODAL input is rejected with a pointer — this search has
482
+ no modal rules, and silently treating a modal operator as an opaque atom
483
+ used to return a misleading ``None`` for valid entailments like
484
+ ``□(P→Q), □P ⊢ □Q``; :func:`fitch_prove` / :func:`is_valid_fitch` instead
485
+ route modal input to the modal tableau automatically.
486
+
487
+ Raises:
488
+ NotImplementedError: on modal/temporal/hybrid/counterfactual input, and on
489
+ substructural (ILL/Lambek), team-semantic, second-order, or Łukasiewicz
490
+ input — each with a pointer at its own decision procedure (an opaque-atom
491
+ treatment would silently return None for genuine theorems like the
492
+ ILL axiom ``A ⊸ A``).
493
+ """
494
+ from ..fol._msfl_nodes import _reduce_nl_nodes
495
+ from .modal_tableau import has_modal
496
+ from .tableau import _reject_exotic
497
+ _reject_exotic(list(premises) + [conclusion], "find_fitch_proof")
498
+ premises = [_reduce_nl_nodes(p) for p in premises]
499
+ conclusion = _reduce_nl_nodes(conclusion)
500
+ if any(has_modal(f) for f in premises + [conclusion]):
501
+ raise NotImplementedError(
502
+ "find_fitch_proof: no proof search over the modal natural-deduction "
503
+ "rules (a modal operator treated as an opaque atom would silently "
504
+ "yield None for valid modal entailments). Decide validity with "
505
+ "fitch_prove / is_valid_fitch (which route to the modal tableau), or "
506
+ "check a hand-written modal Fitch proof with check_proof.")
507
+ premises = [_canon_q(p) for p in premises]
508
+ goal = _canon_q(conclusion)
509
+ base = {p: ("ref", p) for p in premises}
510
+ # Iterative deepening: try increasing depth bounds so a shallow proof is found
511
+ # before the search explores deep, fruitless branches. Each level gets a fresh
512
+ # step budget and eigenvariable counter.
513
+ for depth in range(1, max_depth + 1):
514
+ ctx = _Search(premises, goal, depth)
515
+ deriv = _search(base, goal, depth, ctx, ctx.base_terms)
516
+ if deriv is not None:
517
+ proof = _assemble(premises, goal, deriv)
518
+ if verify_proof(proof).ok:
519
+ return proof
520
+ return None
521
+
522
+
523
+ def fitch_prove(premises, conclusion: Node, max_depth: int = 8) -> bool:
524
+ """Return True iff ``premises ⊢ conclusion`` is established (sound, bounded).
525
+
526
+ Classical input is decided by the Fitch proof search. MODAL input is routed
527
+ to the modal tableau's local consequence over the system **K**
528
+ (:func:`~unicode_logic_kit.atp.modal_tableau.modal_prove`) — previously a
529
+ modal operator was treated as an opaque atom and e.g. the K-axiom instance
530
+ ``□(P→Q), □P ⊢ □Q`` silently came back False. Either path is sound: a True
531
+ is a verified Fitch proof or a closed modal tableau.
532
+ """
533
+ from .modal_tableau import has_modal, modal_prove
534
+ from .tableau import _reject_exotic
535
+ premises = list(premises)
536
+ # Substructural / team / SO / fuzzy input must not degrade to "opaque atom →
537
+ # silent False" (the exact bug class the modal routing below already fixed:
538
+ # is_valid_fitch(A ⊸ A) returned False for a genuine ILL theorem).
539
+ _reject_exotic(premises + [conclusion], "fitch_prove")
540
+ if any(has_modal(f) for f in premises + [conclusion]):
541
+ return modal_prove(premises, conclusion)
542
+ return find_fitch_proof(premises, conclusion, max_depth) is not None
543
+
544
+
545
+ def is_valid_fitch(formula: Node, max_depth: int = 8) -> bool:
546
+ """Return True iff ``formula`` is provable from no premises (a found-theorem check).
547
+
548
+ Modal input is decided by the modal tableau over **K** (see
549
+ :func:`fitch_prove`); classical input by the Fitch search.
550
+ """
551
+ return fitch_prove([], formula, max_depth)