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,921 @@
1
+ """Analytic (semantic) tableaux — a refutation method that also yields countermodels.
2
+
3
+ A tableau decomposes a set of formulas with the standard signed-free rules: the
4
+ *α* (non-branching) rules break a conjunctive formula into its parts, the *β*
5
+ (branching) rules split a disjunctive one, the *δ* rule witnesses an existential with
6
+ a fresh constant, and the *γ* rule instantiates a universal at the available terms. A
7
+ branch **closes** when it contains both ``φ`` and ``¬φ`` (or ``⊥``); the set is jointly
8
+ **unsatisfiable** iff *every* branch closes. An *open* saturated branch is, by
9
+ contrast, a model — so a failed refutation hands back a countermodel for free.
10
+
11
+ The truth constants ``$true`` and ``$false`` (``⊤`` and ``⊥`` in the unicode syntax)
12
+ are constants, not letters: a branch that holds ``$false`` or ``¬$true`` closes on
13
+ its own, and ``$true`` and ``¬$false`` hold in every interpretation, so they are
14
+ dropped (they neither close a branch nor appear in the model an open branch gives).
15
+
16
+ This gives a fourth proof method alongside resolution, Fitch, and the sequent
17
+ calculus: ``is_valid_tableau(φ)`` builds a tableau for ``¬φ`` (valid iff it closes),
18
+ and ``tableau_model`` returns the open branch's literals as a satisfying assignment.
19
+
20
+ Propositional tableaux are decidable and complete; the first-order rules are run under
21
+ a step bound (``γ``-instantiation is only semi-decidable), so — like the resolution
22
+ prover — a non-closing first-order tableau within the bound is reported as "open"
23
+ without claiming satisfiability. The bounds are the step budget (``max_steps`` rule
24
+ applications and closure tests in all, so no branch can be longer than that), the
25
+ per-branch term pool (``max_terms``) and an optional wall-clock ``timeout``. The search
26
+ is a loop over an explicit stack of pending branches, so the LENGTH of a branch does not
27
+ depend on the interpreter's recursion limit: a branch of any length up to ``max_steps`` is
28
+ searched. The NESTING of a formula does: the helpers that walk a formula (substitution of a
29
+ witness, the collection of its terms, hashing) recurse once per level, so a formula nested
30
+ deeper than they can walk within the interpreter's recursion limit (hundreds of levels)
31
+ ends the search like a bound: "no closed tableau", never a :class:`RecursionError` and never
32
+ a claim of satisfiability (:func:`nesting_depth` measures it).
33
+
34
+ Many-sorted input is searched through its guard image: ``∀x:S φ`` is ``∀x (S(x) → φ)``,
35
+ and the facts that reading needs (no sort is empty, a sorted constant ``c:S`` lies in
36
+ ``S``) join the formulas as further members of the refuted set — see :func:`tableau_closed`.
37
+
38
+ Public API: :func:`tableau_closed`, :func:`is_valid_tableau`, :func:`prove_tableau`,
39
+ :func:`tableau_model`, and — for a recorded, independently-checkable proof object —
40
+ :func:`prove_tableau_detailed` / :class:`TableauProof` (checked by
41
+ :mod:`unicode_logic_kit.atp.tableau_check`).
42
+ """
43
+
44
+ import time
45
+ from dataclasses import dataclass
46
+ from typing import Dict, List, Optional, Tuple
47
+
48
+ from ..fol._atom_keys import AtomKeys
49
+ from ..fol._identifiers import symbol_names
50
+ from ..fol._msfl_nodes import key_text
51
+ from ..fol.nodes import (
52
+ Node, Atom, Not, And, Or, Xor, Implies, Iff, Quantifier, Variable, Constant, Number, Function,
53
+ Contrast, Count, Cardinality,
54
+ SortedQuantifier, SortedConstant, SortedCount, SortedCardinality, to_fol, sort_axioms,
55
+ )
56
+ from ..fol._truth_constants import is_true_constant, is_false_constant
57
+ from .fitch import FALSUM, is_falsum, _subst_var, _q_kind, _free_vars
58
+
59
+
60
+ def _neg(f: Node) -> Node:
61
+ """Return the complementary formula of ``f`` (``¬φ`` ↔ ``φ``)."""
62
+ return f.formula if isinstance(f, Not) else Not(f)
63
+
64
+
65
+ def _any_modal(formulas) -> bool:
66
+ """True iff any formula carries a modal/temporal/epistemic/deontic operator.
67
+
68
+ Such formulas have no classical tableau rule; they are routed to the labelled
69
+ modal tableau (:mod:`unicode_logic_kit.atp.modal_tableau`) instead of raising.
70
+ """
71
+ from .modal_tableau import has_modal
72
+ return any(has_modal(f) for f in formulas)
73
+
74
+
75
+ def _reject_exotic(formulas, entry: str) -> None:
76
+ """Reject non-classical node families with a pointer at the right tool.
77
+
78
+ Without this pre-dispatch check these nodes surfaced as a bare
79
+ ``ValueError: tableau: no rule for X`` from deep inside the rule dispatcher —
80
+ the same class of gap the modal routing above already closes. Each family has
81
+ its own decision procedure; none has a sound classical tableau rule.
82
+ """
83
+ from ..fol._linear_nodes import Tensor, With, OPlus, LinearImplies, OfCourse, One
84
+ from ..fol._lambek_nodes import Product, Under, Over
85
+ from ..fol._team_nodes import Dependence, SlashedExists
86
+ from ..fol._so_nodes import SecondOrderQuantifier
87
+ from ..semantics._modal_reject import FUZZY_TYPES
88
+ hints = (
89
+ ((Tensor, With, OPlus, LinearImplies, OfCourse, One),
90
+ "a linear-logic (ILL) connective; decide derivability with "
91
+ "atp.linear.ill_prove / ill_derivable"),
92
+ ((Product, Under, Over),
93
+ "a Lambek-calculus connective; decide derivability with "
94
+ "atp.lambek.lambek_prove / lambek_derivable"),
95
+ ((Dependence, SlashedExists),
96
+ "team-semantic (dependence/IF logic); evaluate with team_satisfies / "
97
+ "team_models"),
98
+ ((SecondOrderQuantifier,),
99
+ "second-order; use the sequent calculus's SO rules, satisfies_so, or "
100
+ "hol.secondorder"),
101
+ (FUZZY_TYPES,
102
+ "a Łukasiewicz connective; evaluate with semantics.fuzzy.evaluate or "
103
+ "decide with atp.z3_fuzzy.fuzzy_is_valid"),
104
+ ((SortedCardinality,),
105
+ "a many-sorted set-cardinality term, which is second-order; evaluate it "
106
+ "with semantics.tarski.satisfies / the finite model finder, or export to "
107
+ "HOL via hol.secondorder"),
108
+ )
109
+ for f in formulas:
110
+ for sub in f.walk():
111
+ for types, hint in hints:
112
+ if isinstance(sub, types):
113
+ raise NotImplementedError(
114
+ f"{entry}: {type(sub).__name__} is {hint}. No sound "
115
+ "classical proof rule exists for it here.")
116
+
117
+
118
+ def _ground_terms(node: Node, acc: set) -> None:
119
+ """Collect the closed (variable-free) constant/number/function terms in ``node``."""
120
+ if isinstance(node, (Constant, Number)):
121
+ acc.add(node)
122
+ elif isinstance(node, Function):
123
+ if not _free_vars(node):
124
+ acc.add(node)
125
+ for a in node.args:
126
+ _ground_terms(a, acc)
127
+ elif isinstance(node, Atom):
128
+ for a in node.args:
129
+ _ground_terms(a, acc)
130
+ else:
131
+ for child in node._child_nodes():
132
+ _ground_terms(child, acc)
133
+
134
+
135
+ def _terms_of(formula: Node, existing: Tuple[Node, ...], cap: int) -> Tuple[Node, ...]:
136
+ """Return ``existing`` extended with ``formula``'s ground terms, capped at ``cap``."""
137
+ if len(existing) >= cap:
138
+ return existing
139
+ acc: set = set()
140
+ _ground_terms(formula, acc)
141
+ result = list(existing)
142
+ # The order is by the bare names of the constants (key_text), so that the quotes of a
143
+ # quoted constant play no part in which term is tried first.
144
+ for t in sorted(acc, key=key_text):
145
+ if t not in result:
146
+ result.append(t)
147
+ if len(result) >= cap:
148
+ break
149
+ return tuple(result)
150
+
151
+
152
+ def _is_literal(f: Node) -> bool:
153
+ """True iff ``f`` is an atom, a negated atom, or ⊥ (no rule applies)."""
154
+ if is_falsum(f):
155
+ return True
156
+ if isinstance(f, Atom):
157
+ return True
158
+ if isinstance(f, Not) and isinstance(f.formula, Atom):
159
+ return True
160
+ return False
161
+
162
+
163
+ def _closes_alone(f: Node) -> bool:
164
+ """True iff the literal ``f`` closes its branch with no partner: ⊥, the truth
165
+ constant ``$false`` (``is_falsum`` reads both), or ``¬$true``."""
166
+ return is_falsum(f) or (isinstance(f, Not) and is_true_constant(f.formula))
167
+
168
+
169
+ def _holds_always(f: Node) -> bool:
170
+ """True iff the literal ``f`` holds in every interpretation and so adds nothing
171
+ to a branch: the truth constant ``$true`` or ``¬$false``. It is dropped, never
172
+ recorded as a literal (so it can neither close a branch nor appear in a model)."""
173
+ return is_true_constant(f) or (isinstance(f, Not) and is_false_constant(f.formula))
174
+
175
+
176
+ class _Ctx:
177
+ """Search context: a step budget, a wall-clock deadline, a fresh-constant source
178
+ and a term-pool cap.
179
+
180
+ ``avoid`` holds every name the problem carries (:func:`symbol_names` of ALL the
181
+ formulas the search starts from): a generated constant never has one of them.
182
+ """
183
+
184
+ def __init__(self, max_steps: int, max_terms: int, timeout: Optional[int] = None,
185
+ avoid=frozenset()):
186
+ self.budget = [max_steps]
187
+ self.max_terms = max_terms
188
+ self._fresh = [0]
189
+ self._avoid = frozenset(avoid)
190
+ self.open_branch: Optional[frozenset] = None
191
+ # ``timeout`` is in milliseconds, counted from the moment the search starts.
192
+ self.deadline = None if timeout is None else time.perf_counter() + timeout / 1000.0
193
+
194
+ def spend(self) -> bool:
195
+ """Charge one step; False once the step budget is gone or the deadline has passed.
196
+
197
+ The deadline is read at every step, so the search ends within one step's work
198
+ of it. After it has passed the budget is emptied, which makes every pending
199
+ branch return at once.
200
+ """
201
+ if self.budget[0] <= 0:
202
+ return False
203
+ if self.deadline is not None and time.perf_counter() > self.deadline:
204
+ self.budget[0] = 0
205
+ return False
206
+ self.budget[0] -= 1
207
+ return True
208
+
209
+ def fresh_const(self) -> Constant:
210
+ """The next generated constant: ``_t0``, ``_t1``, … skipping every name of the problem.
211
+
212
+ A user constant spelled ``_t0`` is not the witness of an existential: the witness
213
+ of ``∃x P(x)`` is an element nothing else is known about, so it must not be
214
+ a symbol that another formula already talks about.
215
+ """
216
+ while True:
217
+ name = f"_t{self._fresh[0]}"
218
+ self._fresh[0] += 1
219
+ if name not in self._avoid:
220
+ return Constant(name)
221
+
222
+
223
+ def _rule(f: Node):
224
+ """Classify ``f`` and return its expansion.
225
+
226
+ Returns one of:
227
+ ``("alpha", [comp, …])`` — add all components to the branch;
228
+ ``("beta", [[…], […]])`` — split the branch (each list a new branch's adds);
229
+ ``("delta", var, body, neg)`` — witness with a fresh constant;
230
+ ``("gamma", var, body, neg)`` — universal, instantiate at terms.
231
+ """
232
+ if isinstance(f, And):
233
+ return ("alpha", [f.left, f.right])
234
+ if isinstance(f, Contrast):
235
+ # Concession is truth-functionally conjunction (Contrast's own contract).
236
+ return ("alpha", [f.left, f.right])
237
+ if isinstance(f, Or):
238
+ return ("beta", [[f.left], [f.right]])
239
+ if isinstance(f, Implies):
240
+ return ("beta", [[Not(f.left)], [f.right]])
241
+ if isinstance(f, Iff):
242
+ return ("beta", [[f.left, f.right], [Not(f.left), Not(f.right)]])
243
+ if isinstance(f, Xor):
244
+ return ("beta", [[f.left, Not(f.right)], [Not(f.left), f.right]])
245
+ if isinstance(f, Count):
246
+ # The distinct-witnesses expansion is plain FOL, which this tableau's
247
+ # quantifier rules handle — the same lowering to_z3/to_prover9 use.
248
+ return ("alpha", [f._expand()])
249
+ if _q_kind(f) == "∃":
250
+ return ("delta", f.variable, f.formula, False)
251
+ if _q_kind(f) == "∀":
252
+ return ("gamma", f.variable, f.formula, False)
253
+ if isinstance(f, Not):
254
+ g = f.formula
255
+ if isinstance(g, Not):
256
+ return ("alpha", [g.formula])
257
+ if isinstance(g, And):
258
+ return ("beta", [[Not(g.left)], [Not(g.right)]])
259
+ if isinstance(g, Contrast):
260
+ return ("beta", [[Not(g.left)], [Not(g.right)]])
261
+ if isinstance(g, Or):
262
+ return ("alpha", [Not(g.left), Not(g.right)])
263
+ if isinstance(g, Implies):
264
+ return ("alpha", [g.left, Not(g.right)])
265
+ if isinstance(g, Iff):
266
+ return ("beta", [[g.left, Not(g.right)], [Not(g.left), g.right]])
267
+ if isinstance(g, Xor):
268
+ return ("beta", [[g.left, g.right], [Not(g.left), Not(g.right)]])
269
+ if isinstance(g, Count):
270
+ return ("alpha", [Not(g._expand())])
271
+ if _q_kind(g) == "∀":
272
+ return ("delta", g.variable, g.formula, True)
273
+ if _q_kind(g) == "∃":
274
+ return ("gamma", g.variable, g.formula, True)
275
+ if isinstance(f, (Cardinality,)) or (
276
+ isinstance(f, Not) and isinstance(f.formula, Cardinality)):
277
+ raise NotImplementedError(
278
+ "tableau: a bare Cardinality term is not a formula, and cardinality "
279
+ "comparisons are not first-order — evaluate them with "
280
+ "semantics.tarski.satisfies / the finite model finder, or export to "
281
+ "HOL via hol.secondorder.")
282
+ raise ValueError(f"tableau: no rule for {type(f).__name__} {f.to_unicode_str()}")
283
+
284
+
285
+ def _instance(var: Variable, body: Node, neg: bool, term: Node) -> Node:
286
+ """Return ``body[var:=term]``, negated when the source was ¬∃ / ∀ on the right."""
287
+ inst = _subst_var(body, var, term)
288
+ return Not(inst) if neg else inst
289
+
290
+
291
+ def _close(work: Tuple[Node, ...], lits: frozenset,
292
+ gammas: Tuple[Tuple, ...], terms: Tuple[Node, ...],
293
+ used: frozenset, ctx: "_Ctx") -> bool:
294
+ """Return True iff this branch (and all its splits) close.
295
+
296
+ The search is depth-first and runs in a loop: one pass of it is one rule application
297
+ (or one closure test) and costs one step of ``ctx``. A branching rule puts its second
298
+ alternative on a stack of pending branches and goes on with the first; a branch that
299
+ closes takes the next pending one; the first branch that cannot close (saturated, the
300
+ term pool full, or the steps or the deadline used up) ends the whole search with False.
301
+ Nothing recurses, so how deep a branch may grow is bounded by the step budget alone,
302
+ never by the interpreter's recursion limit.
303
+ """
304
+ pending: List[Tuple] = [] # the alternatives still to close, the newest last
305
+ while True:
306
+ if not ctx.spend():
307
+ return False
308
+
309
+ closed = False
310
+ if work:
311
+ f, rest = work[0], work[1:]
312
+
313
+ if _is_literal(f):
314
+ if _closes_alone(f) or _neg(f) in lits:
315
+ closed = True
316
+ else:
317
+ if not (_holds_always(f) or f in lits):
318
+ # A new literal may introduce ground terms a universal can instantiate at.
319
+ lits = lits | {f}
320
+ terms = _terms_of(f, terms, ctx.max_terms)
321
+ work = rest
322
+ continue
323
+ else:
324
+ rule = _rule(f)
325
+ kind = rule[0]
326
+ if kind == "alpha":
327
+ work = tuple(rule[1]) + rest
328
+ continue
329
+ if kind == "beta":
330
+ left, right = rule[1]
331
+ pending.append((tuple(right) + rest, lits, gammas, terms, used))
332
+ work = tuple(left) + rest
333
+ continue
334
+ if kind == "delta":
335
+ _, var, body, neg = rule
336
+ if len(terms) >= ctx.max_terms:
337
+ # Term-pool cap reached: give up on this branch (sound but incomplete).
338
+ if ctx.open_branch is None:
339
+ ctx.open_branch = lits
340
+ return False
341
+ c = ctx.fresh_const()
342
+ work = (_instance(var, body, neg, c),) + rest
343
+ terms = terms + (c,)
344
+ continue
345
+ if kind == "gamma":
346
+ _, var, body, neg = rule
347
+ key = f
348
+ gammas = gammas + ((key, var, body, neg),)
349
+ pool = terms if terms else (ctx.fresh_const(),)
350
+ insts = tuple(_instance(var, body, neg, t) for t in pool)
351
+ used = used | {(key, t) for t in pool}
352
+ terms = terms if terms else pool
353
+ work = insts + rest
354
+ continue
355
+ raise AssertionError(kind)
356
+ else:
357
+ # No compound work left: re-instantiate a universal at a term it has not used.
358
+ instantiated = False
359
+ for key, var, body, neg in gammas:
360
+ for t in terms:
361
+ if (key, t) not in used:
362
+ work = (_instance(var, body, neg, t),)
363
+ used = used | {(key, t)}
364
+ instantiated = True
365
+ break
366
+ if instantiated:
367
+ break
368
+ if instantiated:
369
+ continue
370
+ # Saturated and not closed: an OPEN branch — record it as a (counter)model.
371
+ if ctx.open_branch is None:
372
+ ctx.open_branch = lits
373
+ return False
374
+
375
+ # This branch closed: go on with the next alternative, if there is one.
376
+ if closed:
377
+ if not pending:
378
+ return True
379
+ work, lits, gammas, terms, used = pending.pop()
380
+
381
+
382
+ def _initial_terms(formulas, cap: int) -> Tuple[Node, ...]:
383
+ """The γ-instantiation seed: initial ground terms plus the input's free variables.
384
+
385
+ A FREE variable of the input is a PARAMETER of the problem: one unknown element, the
386
+ same in every formula (the assignment-wise consequence relation, ``Γ ⊨ φ`` iff every
387
+ structure AND assignment that satisfies ``Γ`` satisfies ``φ``), so it is treated as a
388
+ constant (``valid ∀a.φ ⟺ unsat ¬φ[a := fresh constant]``). That is how the
389
+ resolution prover, the finite model finder, Z3 and cvc5 read it too: ``P(x) ⊢ P(alpha)``
390
+ is not valid, ``P(x) ⊢ ∃y P(y)`` is. Without this a
391
+ γ-formula was never instantiated at a free variable and e.g.
392
+ ``¬∃x P(x) → ¬P(a)`` (free ``a``) was silently left unproved.
393
+ Bound occurrences never reach this seed: it runs on the top-level input only,
394
+ and the free-variable set excludes them by definition.
395
+ """
396
+ terms: Tuple[Node, ...] = ()
397
+ for f in formulas:
398
+ terms = _terms_of(f, terms, cap)
399
+ free: set = set()
400
+ for f in formulas:
401
+ free |= _free_vars(f) # a set of Variable NODES
402
+ for v in sorted(free, key=lambda n: n.name):
403
+ if len(terms) >= cap:
404
+ break
405
+ if v not in terms:
406
+ terms = terms + (v,)
407
+ return terms
408
+
409
+
410
+ _SORTED_NODES = (SortedQuantifier, SortedConstant, SortedCount, SortedCardinality)
411
+
412
+
413
+ def _lower_sorted(formulas) -> List[Node]:
414
+ """The formulas a tableau works on: ``formulas`` themselves, or their guard images.
415
+
416
+ A many-sorted formula has no tableau rule of its own. It is read as the one-universe
417
+ reading of the kit says (:func:`~unicode_logic_kit.fol.nodes.to_fol`): ``∀x:S φ`` is
418
+ ``∀x (S(x) → φ)``, ``∃x:S φ`` is ``∃x (S(x) ∧ φ)`` and ``c:S`` is the constant ``c``.
419
+ That image forgets two facts the reading needs: no sort is empty and a sorted
420
+ constant lies in its sort. They are :func:`~unicode_logic_kit.fol.nodes.sort_axioms` of
421
+ ALL the formulas, appended to the image as further members of the set the tableau
422
+ refutes. The set is unsatisfiable under the definition exactly when the tableau of
423
+ these roots closes; the axioms are never part of a negated conclusion, because the
424
+ conclusion's negation is one member of the set and they are others.
425
+
426
+ Formulas without a sorted node are returned as they are, so an unsorted problem is
427
+ searched exactly as it always was.
428
+ """
429
+ formulas = list(formulas)
430
+ if not any(isinstance(node, _SORTED_NODES) for f in formulas for node in f.walk()):
431
+ return formulas
432
+ return [to_fol(f) for f in formulas] + list(sort_axioms(*formulas))
433
+
434
+
435
+ def nesting_depth(*formulas: Node) -> int:
436
+ """The deepest nesting of the nodes of ``formulas``: the most nodes on one path from a root down.
437
+
438
+ A proposition ``A`` is 1, ``¬A`` is 2, and ``P(x)`` is 2 (the atom and its argument).
439
+ Computed with a stack of its own, so it answers for a formula of any depth. A
440
+ formula nested deeper than the interpreter's recursion limit lets the tableau's
441
+ recursive helpers (the substitution of a term for a bound variable, the
442
+ collection of ground terms, the hashing of a formula) raise
443
+ :class:`RecursionError`; the search then ends as "no closed tableau" and this is
444
+ the number that names the reason.
445
+ """
446
+ deepest = 0
447
+ pending = [(formula, 1) for formula in formulas]
448
+ while pending:
449
+ node, depth = pending.pop()
450
+ if depth > deepest:
451
+ deepest = depth
452
+ pending.extend((child, depth + 1) for child in node._child_nodes())
453
+ return deepest
454
+
455
+
456
+ def tableau_closed(formulas, max_steps: int = 20000, max_terms: int = 8,
457
+ timeout: Optional[int] = None) -> bool:
458
+ """Return True iff ``formulas`` are jointly unsatisfiable (every branch closes).
459
+
460
+ Sound; complete and decidable for the propositional fragment. First-order
461
+ ``γ``-instantiation is bounded by ``max_terms`` (the size of the per-branch term
462
+ pool) and ``max_steps``, so a False on a first-order input is "no closed tableau
463
+ within the bounds", never a claim of satisfiability. ``max_steps`` also bounds how
464
+ long a branch can grow (every rule application on it is a step); the interpreter's
465
+ recursion limit does not, because the search does not recurse over a branch. A
466
+ formula nested deeper than the helpers that walk it can follow within that limit
467
+ (see :func:`nesting_depth`) is a bound too: the call returns False, it never raises
468
+ :class:`RecursionError`. ``timeout``
469
+ (milliseconds, default none) is one more bound, checked at every step: the search
470
+ returns False within one step's work of it.
471
+
472
+ Many-sorted formulas are read with the guard reading of
473
+ :func:`~unicode_logic_kit.fol.nodes.to_fol` and gain the background facts that reading
474
+ needs, as further members of the set: every sort is non-empty and a sorted constant
475
+ ``c:S`` lies in ``S`` (:func:`~unicode_logic_kit.fol.nodes.sort_axioms`). So
476
+ ``∀x:Human Mortal(x), ¬Mortal(socrates:Human)`` is unsatisfiable and
477
+ ``∀x:Human Mortal(x), ¬Mortal(socrates)`` is not.
478
+
479
+ Modal/temporal/epistemic/deontic formulas have no classical rule; they are routed
480
+ to the labelled modal tableau (over the system **K** by default — for other frames
481
+ call :mod:`unicode_logic_kit.atp.modal_tableau` directly).
482
+ """
483
+ formulas = list(formulas)
484
+ try:
485
+ _reject_exotic(formulas, "tableau_closed")
486
+ if _any_modal(formulas):
487
+ from .modal_tableau import modal_tableau_closed
488
+ return modal_tableau_closed(formulas, timeout=timeout)
489
+ formulas = _lower_sorted(formulas)
490
+ ctx = _Ctx(max_steps, max_terms, timeout, symbol_names(*formulas))
491
+ return _close(tuple(formulas), frozenset(), (),
492
+ _initial_terms(formulas, max_terms), frozenset(), ctx)
493
+ except RecursionError:
494
+ return False
495
+
496
+
497
+ def is_valid_tableau(formula: Node, max_steps: int = 20000, max_terms: int = 8,
498
+ timeout: Optional[int] = None) -> bool:
499
+ """Return True iff ``formula`` is valid — its negation's tableau closes.
500
+
501
+ A modal formula is decided over the system **K** by the labelled modal tableau;
502
+ use :func:`unicode_logic_kit.atp.modal_tableau.is_modal_valid` for other frames.
503
+ ``timeout`` is as for :func:`tableau_closed`.
504
+ """
505
+ try:
506
+ _reject_exotic([formula], "is_valid_tableau")
507
+ if _any_modal([formula]):
508
+ from .modal_tableau import is_modal_valid
509
+ return is_modal_valid(formula, timeout=timeout)
510
+ except RecursionError:
511
+ return False
512
+ return tableau_closed([Not(formula)], max_steps, max_terms, timeout)
513
+
514
+
515
+ def prove_tableau(premises, conclusion: Node, max_steps: int = 20000, max_terms: int = 8,
516
+ timeout: Optional[int] = None) -> bool:
517
+ """Return True iff ``premises`` entail ``conclusion`` (premises + ¬conclusion close).
518
+
519
+ For modal inputs this is **local** consequence over the system **K** (see
520
+ :func:`unicode_logic_kit.atp.modal_tableau.modal_prove` for other frames).
521
+ Many-sorted input is read as in :func:`tableau_closed`; ``timeout`` too.
522
+ """
523
+ return tableau_closed(list(premises) + [Not(conclusion)], max_steps, max_terms, timeout)
524
+
525
+
526
+ def tableau_model(formulas, max_steps: int = 20000, max_terms: int = 8,
527
+ timeout: Optional[int] = None) -> Optional[dict]:
528
+ """Return a satisfying literal assignment if ``formulas`` are satisfiable, else None.
529
+
530
+ On an open (saturated) branch the literals are returned as a dict mapping each
531
+ atom's surface form to its truth value; ``None`` means the tableau closed
532
+ (unsatisfiable) within the bound. Two different atoms that print alike (the numeral
533
+ ``1`` and a constant named ``1``, a free variable ``x`` and a constant named ``x``)
534
+ would be ONE key of that dict, so an open branch that holds both is refused by name
535
+ (``NotImplementedError``) instead of being reported with one of them lost.
536
+
537
+ A modal model is a Kripke structure, not a flat literal assignment, so a modal
538
+ input is rejected here with a pointer to
539
+ :func:`unicode_logic_kit.atp.modal_tableau.modal_countermodel`, which returns a
540
+ verified :class:`~unicode_logic_kit.semantics.kripke.KripkeModel`.
541
+
542
+ For many-sorted input the assignment is over the guard image (see
543
+ :func:`tableau_closed`): the sort predicates ``S(x)`` are atoms of it like any other.
544
+ ``timeout`` is as for :func:`tableau_closed`; a formula nested too deep to walk (see
545
+ there) gives ``None`` as well.
546
+ """
547
+ formulas = list(formulas)
548
+ try:
549
+ _reject_exotic(formulas, "tableau_model")
550
+ if _any_modal(formulas):
551
+ raise NotImplementedError(
552
+ "tableau_model: a modal formula's model is a Kripke structure, not a flat "
553
+ "assignment — use unicode_logic_kit.atp.modal_tableau.modal_countermodel "
554
+ "(or modal_decide) instead.")
555
+ formulas = _lower_sorted(formulas)
556
+ ctx = _Ctx(max_steps, max_terms, timeout, symbol_names(*formulas))
557
+ closed = _close(tuple(formulas), frozenset(), (),
558
+ _initial_terms(formulas, max_terms), frozenset(), ctx)
559
+ if closed or ctx.open_branch is None:
560
+ return None
561
+ assignment = {}
562
+ keys = AtomKeys("tableau_model")
563
+ for lit in ctx.open_branch:
564
+ if isinstance(lit, Not) and isinstance(lit.formula, Atom):
565
+ assignment[keys.key(lit.formula)] = False
566
+ elif isinstance(lit, Atom):
567
+ assignment[keys.key(lit)] = True
568
+ return assignment
569
+ except RecursionError:
570
+ return None
571
+
572
+
573
+ # ---------------------------------------------------------------------------
574
+ # Tier 3: a tableau PROOF OBJECT, recorded alongside the search above with
575
+ # zero effect on it (see _close_recording's docstring — it is a separate,
576
+ # additive function; every entry point above still calls the untouched
577
+ # original _close, so their behaviour is unchanged by everything below).
578
+ # Independently checked by :mod:`unicode_logic_kit.atp.tableau_check`, which
579
+ # never imports this module's rule-application machinery (``_rule``,
580
+ # ``_close``, ``_close_recording``, ``_instance``) — only the plain data
581
+ # classes below.
582
+ # ---------------------------------------------------------------------------
583
+
584
+ @dataclass(frozen=True)
585
+ class TableauStep:
586
+ """One rule application in a recorded tableau proof — one node of its tree.
587
+
588
+ Every step has exactly one ``parent_id`` (``0`` denotes the tableau's root —
589
+ :attr:`TableauProof.root_formulas`, i.e. premises + ¬conclusion) and adds the
590
+ formula(s) in ``produced`` to the branch it extends:
591
+
592
+ - ``"alpha"`` (non-branching): ``produced`` is all of the principal formula's
593
+ own components (e.g. both conjuncts of an ``And``) — one child step.
594
+ - ``"beta"`` (branching): recorded as TWO SIBLING steps sharing the same
595
+ ``parent_id`` *and* the same ``principal_formula`` — one per branch, each
596
+ with ``branch_split=True`` and its own alternative in ``produced``.
597
+ - ``"gamma"`` (∀-instantiation): ``produced`` and ``terms`` are parallel
598
+ tuples of the same length — ``produced[i]`` is the principal formula's
599
+ matrix with its bound variable substituted by ``terms[i]``. A single step
600
+ may instantiate at several terms at once (the initial encounter of a
601
+ universal instantiates at every ground term already on the branch); a
602
+ later re-instantiation on saturation is always a single-term step.
603
+ - ``"delta"`` (∃-witness): ``produced`` is a single formula — the principal
604
+ formula's matrix substituted by the fresh witnessing constant recorded in
605
+ ``fresh_constant``.
606
+
607
+ ``step_id`` is 1-based and equal to the step's position in
608
+ :attr:`TableauProof.steps` (mirrors :class:`atp.resolution_check
609
+ .ResolutionStep`'s ``index`` convention).
610
+ """
611
+
612
+ step_id: int
613
+ parent_id: int
614
+ rule: str
615
+ principal_formula: Node
616
+ produced: Tuple[Node, ...]
617
+ branch_split: bool = False
618
+ terms: Tuple[Node, ...] = ()
619
+ fresh_constant: Optional[Node] = None
620
+
621
+ def __post_init__(self):
622
+ """Coerce ``produced``/``terms`` to tuples for hashability."""
623
+ object.__setattr__(self, "produced", tuple(self.produced))
624
+ object.__setattr__(self, "terms", tuple(self.terms))
625
+
626
+ def to_dict(self) -> dict:
627
+ return {
628
+ "step_id": self.step_id,
629
+ "parent_id": self.parent_id,
630
+ "rule": self.rule,
631
+ "principal_formula": self.principal_formula.to_dict(),
632
+ "produced": [f.to_dict() for f in self.produced],
633
+ "branch_split": self.branch_split,
634
+ "terms": [t.to_dict() for t in self.terms],
635
+ "fresh_constant": self.fresh_constant.to_dict() if self.fresh_constant is not None else None,
636
+ }
637
+
638
+
639
+ @dataclass(frozen=True)
640
+ class TableauClosure:
641
+ """One closed branch's closure pair, with the node IDs where both lie.
642
+
643
+ ``leaf_id`` is the tree node (``0``, or a :class:`TableauStep`'s ``step_id``)
644
+ at which the branch closes — it must be a genuine leaf (no step cites it as a
645
+ parent). ``literal`` is the formula whose processing triggered the closure;
646
+ ``literal_step_id`` names the branch node where it actually occurs (``0`` for
647
+ a root formula, else a step whose ``produced`` contains it). When ``literal``
648
+ is ⊥ itself the branch is self-closing and ``complement``/``complement_step_id``
649
+ are both ``None``; otherwise ``complement`` is ``literal``'s complementary
650
+ formula and ``complement_step_id`` names where IT occurs on the same branch.
651
+ """
652
+
653
+ leaf_id: int
654
+ literal: Node
655
+ literal_step_id: int
656
+ complement: Optional[Node] = None
657
+ complement_step_id: Optional[int] = None
658
+
659
+ def to_dict(self) -> dict:
660
+ return {
661
+ "leaf_id": self.leaf_id,
662
+ "literal": self.literal.to_dict(),
663
+ "literal_step_id": self.literal_step_id,
664
+ "complement": self.complement.to_dict() if self.complement is not None else None,
665
+ "complement_step_id": self.complement_step_id,
666
+ }
667
+
668
+
669
+ @dataclass(frozen=True)
670
+ class TableauProof:
671
+ """A full recorded tableau proof: the root formulas, the rule-application
672
+ tree, and every closed branch's closure pair.
673
+
674
+ This records what the search DID — it is not itself a certificate that the
675
+ search was sound. Call :func:`unicode_logic_kit.atp.tableau_check
676
+ .check_tableau_proof` to verify one independently before trusting it.
677
+ """
678
+
679
+ root_formulas: Tuple[Node, ...]
680
+ steps: Tuple[TableauStep, ...] = ()
681
+ closures: Tuple[TableauClosure, ...] = ()
682
+
683
+ def __post_init__(self):
684
+ """Coerce ``root_formulas``/``steps``/``closures`` to tuples for hashability."""
685
+ object.__setattr__(self, "root_formulas", tuple(self.root_formulas))
686
+ object.__setattr__(self, "steps", tuple(self.steps))
687
+ object.__setattr__(self, "closures", tuple(self.closures))
688
+
689
+ def to_dict(self) -> dict:
690
+ return {
691
+ "root_formulas": [f.to_dict() for f in self.root_formulas],
692
+ "steps": [s.to_dict() for s in self.steps],
693
+ "closures": [c.to_dict() for c in self.closures],
694
+ }
695
+
696
+
697
+ class _Recorder:
698
+ """Builds a :class:`TableauProof` tree alongside :func:`_close_recording`.
699
+
700
+ Pure bookkeeping: every method here only records what the search already
701
+ decided (see :func:`_close_recording`'s docstring) — nothing here feeds back
702
+ into a decision, so recording cannot change the search's order or result.
703
+ """
704
+
705
+ def __init__(self):
706
+ self._next_id = 1
707
+ self.steps: List[TableauStep] = []
708
+ self.steps_by_id: Dict[int, TableauStep] = {}
709
+ self.closures: List[TableauClosure] = []
710
+
711
+ def add(self, parent_id: int, rule: str, principal: Node, produced,
712
+ branch_split: bool = False, terms=(), fresh_constant: Optional[Node] = None) -> int:
713
+ """Append a new :class:`TableauStep`, returning its fresh ``step_id``."""
714
+ step_id = self._next_id
715
+ self._next_id += 1
716
+ step = TableauStep(step_id, parent_id, rule, principal, tuple(produced),
717
+ branch_split, tuple(terms), fresh_constant)
718
+ self.steps.append(step)
719
+ self.steps_by_id[step_id] = step
720
+ return step_id
721
+
722
+ def _origin(self, formula: Node, node_id: int, root_formulas) -> int:
723
+ """The nearest branch node (searching leaf-to-root) whose ``produced``
724
+ (or, at ``0``, ``root_formulas``) contains ``formula``."""
725
+ cur = node_id
726
+ while cur != 0:
727
+ step = self.steps_by_id[cur]
728
+ if any(formula == p for p in step.produced):
729
+ return cur
730
+ cur = step.parent_id
731
+ return 0
732
+
733
+ def close(self, node_id: int, root_formulas, literal: Node,
734
+ complement: Optional[Node] = None) -> None:
735
+ """Record a branch closure at ``node_id`` (see :class:`TableauClosure`)."""
736
+ literal_id = self._origin(literal, node_id, root_formulas)
737
+ complement_id = self._origin(complement, node_id, root_formulas) if complement is not None else None
738
+ self.closures.append(TableauClosure(node_id, literal, literal_id, complement, complement_id))
739
+
740
+
741
+ def _close_recording(work: Tuple[Node, ...], lits: frozenset,
742
+ gammas: Tuple[Tuple, ...], terms: Tuple[Node, ...],
743
+ used: frozenset, ctx: "_Ctx", rec: "_Recorder",
744
+ node_id: int, root_formulas: Tuple[Node, ...]) -> bool:
745
+ """A recording twin of :func:`_close` — the engine behind :func:`prove_tableau_detailed`.
746
+
747
+ Line-for-line the same search as :func:`_close` — same term pools, the same
748
+ ``ctx.fresh_const()`` call sequence, the same budget accounting, the same
749
+ (deterministic, backtracking-free) control flow — with a :class:`_Recorder`
750
+ call added at every point :func:`_close` makes a decision, so the two never
751
+ diverge in VERDICT for the same inputs. Kept as a wholly separate function
752
+ (rather than adding recorder hooks to ``_close`` itself) specifically so
753
+ :func:`prove_tableau` and every other existing entry point above keep
754
+ calling the untouched original: this function has zero effect on their
755
+ behaviour, by construction, not merely by argument.
756
+
757
+ Because the search is deterministic and never backtracks (every choice —
758
+ which fresh constant, which pool terms, which unused ``(key, t)`` pair to
759
+ reinstantiate — is made once, immediately, never retried), a top-level
760
+ ``True`` return means every :class:`TableauStep`/:class:`TableauClosure`
761
+ recorded during the whole call genuinely lies on the closed proof: nothing
762
+ here is spliced out afterwards, and nothing is recorded that a failed
763
+ sub-call later discards (a ``False`` anywhere ends the whole search, which is why
764
+ :func:`prove_tableau_detailed` simply discards the whole recorder when the top call
765
+ returns ``False``).
766
+
767
+ Like :func:`_close` it runs in a loop with a stack of pending branches instead of
768
+ recursing; a branching rule records both of its steps before the first alternative is
769
+ searched, as the recursive search did, so the step numbers of the proof are the same.
770
+ """
771
+ pending: List[Tuple] = [] # the alternatives still to close, the newest last
772
+ while True:
773
+ if not ctx.spend():
774
+ return False
775
+
776
+ closed = False
777
+ if work:
778
+ f, rest = work[0], work[1:]
779
+
780
+ if _is_literal(f):
781
+ if _closes_alone(f):
782
+ rec.close(node_id, root_formulas, f)
783
+ closed = True
784
+ elif _neg(f) in lits:
785
+ rec.close(node_id, root_formulas, f, _neg(f))
786
+ closed = True
787
+ else:
788
+ if not (_holds_always(f) or f in lits):
789
+ lits = lits | {f}
790
+ terms = _terms_of(f, terms, ctx.max_terms)
791
+ work = rest
792
+ continue
793
+ else:
794
+ rule = _rule(f)
795
+ kind = rule[0]
796
+ if kind == "alpha":
797
+ node_id = rec.add(node_id, "alpha", f, rule[1])
798
+ work = tuple(rule[1]) + rest
799
+ continue
800
+ if kind == "beta":
801
+ left, right = rule[1]
802
+ left_id = rec.add(node_id, "beta", f, left, branch_split=True)
803
+ right_id = rec.add(node_id, "beta", f, right, branch_split=True)
804
+ pending.append((tuple(right) + rest, lits, gammas, terms, used, right_id))
805
+ work = tuple(left) + rest
806
+ node_id = left_id
807
+ continue
808
+ if kind == "delta":
809
+ _, var, body, neg = rule
810
+ if len(terms) >= ctx.max_terms:
811
+ # Term-pool cap reached: give up on this branch (sound but incomplete) —
812
+ # mirrors _close exactly; this path is never on a path that ends up True.
813
+ if ctx.open_branch is None:
814
+ ctx.open_branch = lits
815
+ return False
816
+ c = ctx.fresh_const()
817
+ inst = _instance(var, body, neg, c)
818
+ node_id = rec.add(node_id, "delta", f, (inst,), fresh_constant=c)
819
+ work = (inst,) + rest
820
+ terms = terms + (c,)
821
+ continue
822
+ if kind == "gamma":
823
+ _, var, body, neg = rule
824
+ key = f
825
+ gammas = gammas + ((key, var, body, neg),)
826
+ pool = terms if terms else (ctx.fresh_const(),)
827
+ insts = tuple(_instance(var, body, neg, t) for t in pool)
828
+ used = used | {(key, t) for t in pool}
829
+ terms = terms if terms else pool
830
+ node_id = rec.add(node_id, "gamma", f, insts, terms=pool)
831
+ work = insts + rest
832
+ continue
833
+ raise AssertionError(kind)
834
+ else:
835
+ # No compound work left: re-instantiate a universal at a term it has not used.
836
+ instantiated = False
837
+ for key, var, body, neg in gammas:
838
+ for t in terms:
839
+ if (key, t) not in used:
840
+ inst = _instance(var, body, neg, t)
841
+ node_id = rec.add(node_id, "gamma", key, (inst,), terms=(t,))
842
+ work = (inst,)
843
+ used = used | {(key, t)}
844
+ instantiated = True
845
+ break
846
+ if instantiated:
847
+ break
848
+ if instantiated:
849
+ continue
850
+ # Saturated and not closed: an OPEN branch — never reached on a path that ends up True.
851
+ if ctx.open_branch is None:
852
+ ctx.open_branch = lits
853
+ return False
854
+
855
+ # This branch closed: go on with the next alternative, if there is one.
856
+ if closed:
857
+ if not pending:
858
+ return True
859
+ work, lits, gammas, terms, used, node_id = pending.pop()
860
+
861
+
862
+ def _search_detailed(premises, conclusion: Node, max_steps: int = 20000,
863
+ max_terms: int = 8, timeout: Optional[int] = None):
864
+ """:func:`prove_tableau_detailed`, returning ``(proof, nesting)``.
865
+
866
+ ``nesting`` is ``None`` unless the search ended because a formula was nested deeper
867
+ than its helpers can walk within the interpreter's recursion limit; it is then the
868
+ :func:`nesting_depth` of the problem, and ``proof`` is ``None``.
869
+ """
870
+ formulas = list(premises) + [Not(conclusion)]
871
+ try:
872
+ _reject_exotic(formulas, "prove_tableau_detailed")
873
+ if _any_modal(formulas):
874
+ raise NotImplementedError(
875
+ "prove_tableau_detailed: modal tableaux do not build a detailed proof "
876
+ "object here — use modal_tableau.modal_prove for the plain verdict.")
877
+ formulas = _lower_sorted(formulas)
878
+ root_formulas = tuple(formulas)
879
+ ctx = _Ctx(max_steps, max_terms, timeout, symbol_names(*formulas))
880
+ rec = _Recorder()
881
+ closed = _close_recording(
882
+ root_formulas, frozenset(), (), _initial_terms(formulas, max_terms),
883
+ frozenset(), ctx, rec, 0, root_formulas)
884
+ if not closed:
885
+ return None, None
886
+ return TableauProof(root_formulas, tuple(rec.steps), tuple(rec.closures)), None
887
+ except RecursionError:
888
+ return None, nesting_depth(*formulas)
889
+
890
+
891
+ def prove_tableau_detailed(premises, conclusion: Node, max_steps: int = 20000,
892
+ max_terms: int = 8,
893
+ timeout: Optional[int] = None) -> Optional["TableauProof"]:
894
+ """Return a :class:`TableauProof` if a closed tableau is found within budget, else ``None``.
895
+
896
+ Builds the SAME tableau :func:`prove_tableau` would — :func:`_close_recording`
897
+ mirrors :func:`_close` exactly (see its docstring) — plus a proof object
898
+ recording every rule application and every branch's closure pair.
899
+
900
+ ``None`` is NEVER a verdict of invalidity, exactly as for :func:`prove_tableau`:
901
+ it only means no closed tableau was found within ``max_steps``/``max_terms``/
902
+ ``timeout`` (first-order γ-instantiation is merely semi-decidable; ``timeout`` is in
903
+ milliseconds, default none, and is checked at every step). The length of a branch is
904
+ bounded by ``max_steps``, not by the interpreter's recursion limit; a formula nested
905
+ deeper than the helpers that walk it can follow (see :func:`nesting_depth`) is a bound
906
+ too and gives ``None``, never :class:`RecursionError`. Call
907
+ :func:`unicode_logic_kit.atp.tableau_check.check_tableau_proof` to independently
908
+ verify a returned proof before trusting it — this function's own bookkeeping is
909
+ not a soundness guarantee.
910
+
911
+ For many-sorted input the proof's root formulas are the guard images of the premises
912
+ and of the negated conclusion followed by the background facts of
913
+ :func:`~unicode_logic_kit.fol.nodes.sort_axioms` (see :func:`tableau_closed`); the
914
+ checker derives the same roots from the premises and the conclusion.
915
+
916
+ Raises the same ``NotImplementedError`` as :func:`prove_tableau` for a
917
+ non-classical node family. A modal input is also rejected here (with a
918
+ pointer to :mod:`unicode_logic_kit.atp.modal_tableau`) since the labelled modal
919
+ tableau does not build a detailed proof object of this shape.
920
+ """
921
+ return _search_detailed(premises, conclusion, max_steps, max_terms, timeout)[0]