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,1376 @@
1
+ """Sound first-order resolution theorem prover (refutation-based).
2
+
3
+ A self-contained, Prover9-free decision procedure for first-order entailment and
4
+ validity, built on the project's existing normal-form and unification machinery.
5
+ It is *sound* — it never reports a theorem that is not one — but, since
6
+ first-order resolution is only semi-decidable, it is deliberately *incomplete*
7
+ under a step bound: when the bound is hit it conservatively reports "not proved".
8
+
9
+ Pipeline:
10
+
11
+ 1. Clausal form (:func:`to_clauses`). A formula is ``skolemize``-d (prenex NNF,
12
+ existentials replaced by Skolem terms, universal prefix retained), the ∀
13
+ prefix is dropped (``_prenex_split``), the matrix is put into CNF (``_cnf``)
14
+ and split into clauses (``_clauses``). Each literal is a positive ``Atom`` or
15
+ a ``Not(Atom)``; each clause is a ``frozenset`` of literals; the clause set is
16
+ a ``set`` of such frozensets. Variables are implicitly universally quantified.
17
+ The truth constants ``$true`` and ``$false`` are constants, not letters: a
18
+ clause with ``$true`` (or ``¬$false``) is true and is dropped, and a literal
19
+ ``$false`` (or ``¬$true``) is removed from its clause, so a clause of nothing
20
+ else is the empty clause.
21
+
22
+ 2. Standardize apart (:func:`_standardize_apart`). Before two clauses are
23
+ resolved their variables are renamed to fresh disjoint names so the clauses
24
+ share no variable. This is required for soundness.
25
+
26
+ 3. Binary resolution (:func:`_resolvents`). For a literal ``L`` in one clause and
27
+ a complementary literal ``M`` in the other (one ``Atom A``, the other
28
+ ``Not(B)`` with ``A``/``B`` the same predicate and arity), the atoms are
29
+ unified; on success with mgu σ the resolvent is σ applied to
30
+ ``(C1 ∖ L) ∪ (C2 ∖ M)``.
31
+
32
+ 4. Factoring (:func:`_factors`). Within a clause, two literals of the same
33
+ polarity that unify are merged under their mgu, yielding a factored clause —
34
+ needed for completeness on some inputs.
35
+
36
+ 5. Redundancy elimination (:func:`_is_tautology`, :func:`_subsumes`). A clause
37
+ containing a literal and its exact complement on the same atom is a
38
+ tautology and is never kept. A clause D is discarded the moment some kept
39
+ clause C subsumes it (``∃σ`` — binding only C's variables via one-sided
40
+ matching, :func:`_match` — with ``Cσ ⊆ D``); conversely, keeping a new
41
+ clause D retires every previously kept clause that D subsumes. This keeps
42
+ the given-clause set free of redundant clauses without weakening what is
43
+ ultimately derivable — see :func:`refute` for why completeness survives.
44
+
45
+ 6. Equality (:func:`_paramodulants_cross`,
46
+ :func:`_reflexivity_resolvents`, :func:`_demodulate_to_fixpoint`). Equality
47
+ (``=``) is, by itself, just an ordinary uninterpreted binary predicate to
48
+ every mechanism above — nothing here or elsewhere in this module injects
49
+ reflexivity/symmetry/transitivity/congruence AXIOMS. Instead the module
50
+ adds equality's INFERENCE RULES directly:
51
+
52
+ - Paramodulation: from a positive equality literal ``s ≈ t`` in one clause
53
+ and a literal ``L`` containing a subterm at position ``p`` in another
54
+ (or the same) clause, if ``s`` unifies with that subterm (mgu σ), infer
55
+ the clauses' remainders plus ``L[p ↦ t]`` — all under σ. Unconditionally
56
+ SOUND in either direction (from ``s`` into occurrences that look like
57
+ ``s``, or symmetrically from ``t`` into occurrences that look like
58
+ ``t``); a simple term order (:func:`_term_gt` — weight = term size, ties
59
+ broken lexicographically by ``key_text()``, see its docstring)
60
+ prunes the search by allowing paramodulation only from the
61
+ order-greater side when the order strictly decides, trying both
62
+ directions only when it does not (which, under this concrete order,
63
+ only happens for two occurrences of the very same term). Paramodulation
64
+ is ONLY generated cross-clause (:func:`_paramodulants_cross`,
65
+ standardized apart like :func:`_resolvents`) — a clause paramodulating
66
+ into itself goes through the same path with a renamed copy via the
67
+ given/kept loop. A shared-instance "self-paramodulation" shortcut
68
+ (dropping both the consumed equation and the target literal from ONE
69
+ instantiation) is deliberately absent: it is UNSOUND — for
70
+ ``{u ≈ v, L[u]}`` it would infer ``{L[v]}``, false in any model that
71
+ satisfies the clause via ``L[u]`` alone while ``u ≠ v`` (adversarial
72
+ review, Tier 3: the shortcut produced confirmed false-positive
73
+ refutations and was removed together with its checker rule).
74
+ - Reflexivity resolution (:func:`_reflexivity_resolvents`): a literal
75
+ ``¬(u ≈ v)`` where ``u``, ``v`` unify is dropped from its clause under
76
+ the unifier — the mechanism that closes goals like ``⊢ c=c`` (whose
77
+ negation clausifies to ``{¬(c=c)}``).
78
+ - Demodulation (:func:`_demodulate_to_fixpoint`): a SIMPLIFICATION, not a
79
+ search-widening inference. A kept UNIT clause ``{l ≈ r}`` is oriented by
80
+ the same term order and used as a rewrite rule ``l → r`` (only when the
81
+ order strictly says so; an unoriented unit equation contributes no
82
+ rule). Newly generated clauses are rewritten to a fixpoint using
83
+ one-sided MATCHING (:func:`_match_term` — not unification: the rule's
84
+ variables bind, the target's are held fixed) before being tested for
85
+ redundancy/kept — every individual rewrite is its own accounted step
86
+ against ``max_steps`` (see :func:`refute`), never a free/silent
87
+ rewrite. Only NEWLY GENERATED clauses are demodulated (forward
88
+ demodulation); already-kept clauses are never retroactively
89
+ re-simplified by a later rule (backward demodulation is out of scope).
90
+
91
+ Honesty note, as promised by this module's general incompleteness stance:
92
+ paramodulation + factoring + resolution is refutation-complete for
93
+ first-order logic WITH equality in principle (this is the classical
94
+ paramodulation completeness theorem), but this implementation does not
95
+ pursue the additional refinements (selection functions, the "basic"
96
+ restriction, splitting, a genuine reduction/simplification ORDER with the
97
+ substitution- and subterm-compatibility closure a real Knuth–Bendix or
98
+ lexicographic path order provides) that a serious equality prover needs
99
+ to be practically complete. This stays SOUND unconditionally; completeness
100
+ for equality problems is emphatically NOT guaranteed beyond what small,
101
+ textbook-sized problems reach within ``max_steps`` — it is a didactic
102
+ equality prover, not a competitive one.
103
+
104
+ 7. Saturation (:func:`refute`). Resolution, factoring, and the equality rules
105
+ above are iterated, filtered through the redundancy elimination in step 5
106
+ (applied after demodulation simplifies each new candidate), until the
107
+ empty clause ``frozenset()`` appears (⇒ unsatisfiable / refuted) or no new,
108
+ non-redundant clause is produced (⇒ saturated) or a step bound is reached
109
+ (⇒ undecided, reported conservatively as not refuted).
110
+
111
+ Public API: :func:`to_clauses`, :func:`refute`, :func:`prove`,
112
+ :func:`is_valid_resolution`.
113
+ """
114
+
115
+ import time
116
+ from typing import Optional
117
+
118
+ from .._deadline import instant as _instant, remaining_ms as _remaining_ms, run_until as _run_until
119
+ from ..fol._free_parameters import parameterize
120
+ from ..fol._identifiers import symbol_names
121
+ from ..fol._msfl_nodes import key_text, sort_axioms
122
+ from ..fol._truth_constants import truth_value
123
+ from ..fol.nodes import (
124
+ Node, Atom, Not, Implies, Quantifier, Variable, Constant, Number, Function,
125
+ Measure, SortedConstant, Cardinality, SortedCardinality, to_fol,
126
+ )
127
+ from ..fol.normalforms import skolemize, _prenex_split, _cnf, _clauses
128
+ from ..fol.unification import unify, apply_subst
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Free variables and universal closure
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def _free_var_names(node: Node, bound: frozenset = frozenset()) -> set:
136
+ """Return the set of names of variables occurring free in node.
137
+
138
+ A :class:`Variable` is free unless its name is bound by an enclosing
139
+ :class:`Quantifier`. Recurses structurally; the quantifier's bound name is
140
+ added to ``bound`` for its body. The input is not mutated.
141
+ """
142
+ if isinstance(node, Variable):
143
+ return set() if node.name in bound else {node.name}
144
+ if isinstance(node, (Constant, Number)):
145
+ return set()
146
+ if isinstance(node, Quantifier):
147
+ return _free_var_names(node.formula, bound | {node.variable.name})
148
+ names = set()
149
+ for child in node._child_nodes():
150
+ names |= _free_var_names(child, bound)
151
+ return names
152
+
153
+
154
+ def _universal_closure(node: Node) -> Node:
155
+ """Return node wrapped in ∀ for each of its free variables.
156
+
157
+ Closing a source formula over its free variables BEFORE skolemisation is a
158
+ soundness requirement: ``skolemize`` is only satisfiability-preserving for
159
+ sentences. If a free variable were left in the matrix, the per-clause
160
+ "implicitly universally quantified" reading used by resolution would
161
+ decouple it from existentials in its scope — e.g. ``¬(P(x) → ∀y P(y))``
162
+ would skolemise to ``P(x) ∧ ¬P(sk0)`` with a free ``x`` and a Skolem
163
+ *constant* ``sk0``, which spuriously resolve to the empty clause. Closing
164
+ first makes the existential a Skolem *function* of the universal, so the
165
+ occurs-check correctly blocks the unsound resolution. The names are sorted so
166
+ the closure is deterministic. The input is not mutated.
167
+ """
168
+ free = sorted(_free_var_names(node))
169
+ result = node
170
+ for name in reversed(free):
171
+ result = Quantifier("∀", Variable(name), result)
172
+ return result
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Clausal form
177
+ # ---------------------------------------------------------------------------
178
+
179
+ def _symbol_names(node: Node) -> set:
180
+ """Return every constant, function, and predicate symbol name in node.
181
+
182
+ Variables are excluded (they are renamed separately). Used to detect which
183
+ symbols skolemisation introduced, so they can be made globally unique.
184
+ A :class:`SortedConstant` is a constant like any other: ``c:S`` is the
185
+ symbol ``c``, and it must not look "introduced" once the many-sorted
186
+ reduction turns it into the plain ``c``.
187
+ """
188
+ names = set()
189
+ for n in node.walk():
190
+ if isinstance(n, (Constant, SortedConstant)):
191
+ names.add(n.name)
192
+ elif isinstance(n, Function):
193
+ names.add(n.name)
194
+ elif isinstance(n, Atom):
195
+ names.add(n.predicate)
196
+ return names
197
+
198
+
199
+ def _rename_symbols(node: Node, mapping: dict) -> Node:
200
+ """Return node with each Constant/Function name remapped via ``mapping``.
201
+
202
+ Only Constant and Function *symbol names* are rewritten (predicates and
203
+ variables are untouched). This makes the Skolem symbols introduced by one
204
+ source formula disjoint from those of another, so two independently
205
+ skolemised sources cannot share a Skolem name (e.g. both producing ``sk0``)
206
+ and spuriously resolve. Recursion is fully structural (``map_children``), so
207
+ it works on any node — including the quantifier-prefixed, still-connective
208
+ formula produced by skolemisation, before prenex-splitting. The input is not
209
+ mutated.
210
+ """
211
+ if isinstance(node, Constant):
212
+ return Constant(mapping.get(node.name, node.name))
213
+ if isinstance(node, Function):
214
+ return Function(mapping.get(node.name, node.name),
215
+ [_rename_symbols(a, mapping) for a in node.args])
216
+ return node.map_children(lambda c: _rename_symbols(c, mapping))
217
+
218
+
219
+ def to_clauses(formula: Node, sk_counter: list = None, avoid=()) -> set:
220
+ """Return the clausal form of a single formula as a set of frozensets.
221
+
222
+ The formula is first universally closed over its free variables (the convention
223
+ of a clausal form, whose variables are all universal; :func:`prove` hands it
224
+ formulas without a free variable, see there), then
225
+ skolemised (prenex NNF, existentials → Skolem terms, ∀ prefix kept). The
226
+ Skolem symbols that skolemisation introduced are renamed to globally unique
227
+ names — using the shared mutable cursor ``sk_counter`` when one is supplied —
228
+ so that two independently clausified source formulas can never share a Skolem
229
+ symbol (which would be UNSOUND: e.g. ``∃x P(x)`` and the negated conclusion of
230
+ ``∀y P(y)`` both skolemise to ``sk0`` and would spuriously resolve to the
231
+ empty clause). A Skolem symbol is also fresh against every name of the formula
232
+ and every name in ``avoid``: a source formula of a problem passes the names of
233
+ ALL the formulas of the problem, or a Skolem constant could take the spelling
234
+ of a constant that only another formula has. The universal prefix is then
235
+ dropped, the matrix is converted
236
+ to CNF and split into clauses. Each literal is a positive :class:`Atom` or a
237
+ :class:`Not` wrapping an Atom; each clause is a ``frozenset`` of literals; the
238
+ result is a ``set`` of those frozensets. The remaining variables are
239
+ implicitly universally quantified. The input node is not mutated.
240
+ """
241
+ closed = _universal_closure(formula)
242
+ sk = skolemize(closed)
243
+ # The symbols the formula already has. Skolemisation reduces the many-sorted
244
+ # nodes first (a sorted constant ``c:S`` becomes the plain ``c``, a sort
245
+ # becomes a guard predicate), so the names are read off the reduced formula
246
+ # as well as off the source: whatever is in ``sk`` and in neither was
247
+ # introduced by skolemisation itself. A sorted constant therefore keeps its
248
+ # name -- it is the same constant in every formula it occurs in.
249
+ reduced = to_fol(closed)
250
+ before = _symbol_names(closed) | _symbol_names(reduced)
251
+ if sk_counter is None:
252
+ sk_counter = [0]
253
+ # Symbols present after skolemisation but not before are Skolem symbols.
254
+ introduced = _symbol_names(sk) - before
255
+ mapping = {}
256
+ if introduced:
257
+ # A Skolem symbol must not have the spelling of ANY name the problem carries, of
258
+ # any kind (a constant, a function, a predicate, a sort, a variable).
259
+ taken = set(symbol_names(closed, reduced)) | set(avoid)
260
+ for name in sorted(introduced):
261
+ candidate = f"_sk{sk_counter[0]}"
262
+ sk_counter[0] += 1
263
+ while candidate in taken:
264
+ candidate = f"_sk{sk_counter[0]}"
265
+ sk_counter[0] += 1
266
+ mapping[name] = candidate
267
+ if mapping:
268
+ sk = _rename_symbols(sk, mapping)
269
+ _, matrix = _prenex_split(sk)
270
+ cnf = _cnf(matrix)
271
+ result = set()
272
+ for clause in _clauses(cnf):
273
+ simplified = _drop_truth_constants(clause)
274
+ if simplified is not None:
275
+ result.add(frozenset(simplified))
276
+ return result
277
+
278
+
279
+ def _drop_truth_constants(literals):
280
+ """The clause ``literals`` with the truth constants read as constants, or ``None``.
281
+
282
+ ``$true`` and ``¬$false`` hold in every interpretation, so a clause that has one
283
+ is true and ``None`` is returned (the clause is dropped). ``$false`` and ``¬$true``
284
+ hold in none, so they are removed from the clause; a clause that has nothing else
285
+ is the EMPTY clause, which refutes the set. Every other literal is kept as it is.
286
+ """
287
+ kept = []
288
+ for literal in literals:
289
+ if isinstance(literal, Not):
290
+ constant = truth_value(literal.formula)
291
+ constant = None if constant is None else not constant
292
+ else:
293
+ constant = truth_value(literal)
294
+ if constant is True:
295
+ return None
296
+ if constant is None:
297
+ kept.append(literal)
298
+ return kept
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Literal helpers
303
+ # ---------------------------------------------------------------------------
304
+
305
+ def _atom_of(literal: Node) -> Atom:
306
+ """Return the underlying Atom of a literal (Atom or Not(Atom))."""
307
+ if isinstance(literal, Not):
308
+ return literal.formula
309
+ return literal
310
+
311
+
312
+ def _is_positive(literal: Node) -> bool:
313
+ """Return True iff the literal is a positive Atom (not wrapped in Not)."""
314
+ return isinstance(literal, Atom)
315
+
316
+
317
+ def _subst_literal(literal: Node, subst: dict) -> Node:
318
+ """Apply a substitution to a literal, preserving its polarity."""
319
+ if isinstance(literal, Not):
320
+ return Not(apply_subst(literal.formula, subst))
321
+ return apply_subst(literal, subst)
322
+
323
+
324
+ def _rename_term(node: Node, mapping: dict) -> Node:
325
+ """Return node with each Variable replaced via a one-level name mapping.
326
+
327
+ Unlike :func:`apply_subst`, this does NOT follow chains: a variable name is
328
+ looked up exactly once in ``mapping``. That makes it safe for standardizing
329
+ apart, where a fresh name may itself appear as a key (e.g. renaming a clause
330
+ whose variables are already ``_r0`` while ``_r0`` is being mapped to a new
331
+ fresh variable), which would otherwise loop. It is also how a one-sided
332
+ matcher is applied (:func:`_demodulate_once`): the images of a matcher are
333
+ terms of the target and are never looked up again.
334
+ """
335
+ if isinstance(node, Variable):
336
+ return mapping.get(node.name, node)
337
+ if isinstance(node, (Constant, Number)):
338
+ return node
339
+ if isinstance(node, Function):
340
+ return Function(node.name, [_rename_term(a, mapping) for a in node.args])
341
+ if isinstance(node, Measure):
342
+ # μ(entity, dimension) is an ordinary binary term (the exports lower it
343
+ # to the uninterpreted function measure/2), so renaming recurses into
344
+ # both argument slots exactly as for Function.
345
+ return Measure(_rename_term(node.entity, mapping),
346
+ _rename_term(node.dimension, mapping))
347
+ if isinstance(node, Atom):
348
+ return Atom(node.predicate, [_rename_term(a, mapping) for a in node.args])
349
+ if isinstance(node, Not):
350
+ return Not(_rename_term(node.formula, mapping))
351
+ raise TypeError(f"_rename_term: unsupported node type {type(node).__name__}")
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Standardizing variables apart
356
+ # ---------------------------------------------------------------------------
357
+
358
+ def _lit_key(literal) -> str:
359
+ """Canonical sort key for a literal (its surface form with every constant written by
360
+ its bare name, ``key_text``): the saturation loop
361
+ must visit literals and clauses in a content-determined order so a proof
362
+ search is reproducible run to run (frozenset iteration is hash-randomised).
363
+ The quotes that the text of a formula puts around a constant such as ``k2`` play no part
364
+ in the order, so the search visits the same literals in the same order as it did before
365
+ a constant could be written in quotes."""
366
+ return key_text(literal)
367
+
368
+
369
+ def _clause_vars(clause: frozenset) -> set:
370
+ """Return the set of variable names occurring anywhere in a clause."""
371
+ names = set()
372
+ for literal in clause:
373
+ for node in _atom_of(literal).walk():
374
+ if isinstance(node, Variable):
375
+ names.add(node.name)
376
+ return names
377
+
378
+
379
+ def _rename_clause(clause: frozenset, counter: list) -> frozenset:
380
+ """Return a copy of clause with every variable renamed to a fresh name.
381
+
382
+ ``counter`` is a single-element list used as a shared mutable cursor so that
383
+ successive renamings across the whole run draw disjoint names (``_r0``,
384
+ ``_r1`` …). The mapping is consistent within the clause, so distinct
385
+ occurrences of the same variable stay shared.
386
+ """
387
+ mapping = {}
388
+ # Sorted: variable numbering must be a function of the clause CONTENT, not
389
+ # of set iteration order — otherwise two runs of the same proof search build
390
+ # alpha-variant (differently named) clauses, the kept-set dedup stops
391
+ # recognising repeats, and whether a goal closes within max_steps becomes
392
+ # hash-seed-dependent (an irreproducible verdict).
393
+ for name in sorted(_clause_vars(clause)):
394
+ mapping[name] = Variable(f"_r{counter[0]}")
395
+ counter[0] += 1
396
+ if not mapping:
397
+ return clause
398
+ return frozenset(_rename_term(lit, mapping) for lit in clause)
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # Redundancy elimination: tautology deletion and clause subsumption
403
+ # ---------------------------------------------------------------------------
404
+
405
+ def _is_tautology(clause: frozenset) -> bool:
406
+ """Return True iff clause contains a literal and its exact complement.
407
+
408
+ A clause is a tautology (true in every interpretation, hence useless as a
409
+ resolution premise) when it contains both a positive occurrence and a
410
+ negative occurrence of the SAME atom — same predicate, same arguments,
411
+ checked by structural equality, not merely unifiability. ``P(x) ∨ ¬P(y)``
412
+ is NOT caught (x and y are different atoms syntactically, and identifying
413
+ them would require instantiation this check does not perform); ``P(x) ∨
414
+ ¬P(x)`` is. The empty clause is never a tautology.
415
+ """
416
+ positive = {_atom_of(l) for l in clause if _is_positive(l)}
417
+ negative = {_atom_of(l) for l in clause if not _is_positive(l)}
418
+ return bool(positive & negative)
419
+
420
+
421
+ def _match_term(pattern: Node, target: Node, subst: dict):
422
+ """One-sided structural match of pattern against target, extending subst.
423
+
424
+ Unlike :func:`~unicode_logic_kit.fol.unification.unify`, which may bind
425
+ variables on EITHER side, this binds only PATTERN's variables; TARGET is
426
+ held fixed and its own variables (if any) are treated as opaque constants
427
+ — never instantiated. That asymmetry is exactly what clause subsumption
428
+ needs: the subsuming clause's variables range over all its instances, but
429
+ the subsumed clause is the fixed fact being checked against, not a term
430
+ free to unify. A repeated pattern variable must match the same target
431
+ subterm everywhere (checked via structural equality against the existing
432
+ binding). Returns the extended substitution, or None if no match exists.
433
+ No occurs-check is needed: nothing is ever built from a pattern variable's
434
+ own binding, so no cyclic term can arise.
435
+ """
436
+ if isinstance(pattern, Variable):
437
+ if pattern.name in subst:
438
+ return subst if subst[pattern.name] == target else None
439
+ extended = dict(subst)
440
+ extended[pattern.name] = target
441
+ return extended
442
+ if isinstance(pattern, Function):
443
+ if (not isinstance(target, Function)
444
+ or pattern.name != target.name
445
+ or len(pattern.args) != len(target.args)):
446
+ return None
447
+ for p_arg, t_arg in zip(pattern.args, target.args):
448
+ subst = _match_term(p_arg, t_arg, subst)
449
+ if subst is None:
450
+ return None
451
+ return subst
452
+ if isinstance(pattern, Measure):
453
+ if not isinstance(target, Measure):
454
+ return None
455
+ subst = _match_term(pattern.entity, target.entity, subst)
456
+ if subst is None:
457
+ return None
458
+ return _match_term(pattern.dimension, target.dimension, subst)
459
+ # Constant / Number (or any other leaf term): match iff structurally
460
+ # identical. Dataclass equality already requires equal type, so e.g. a
461
+ # Constant pattern can never match a Function or Variable target here.
462
+ return subst if pattern == target else None
463
+
464
+
465
+ def _match(pattern_atom: Atom, target_atom: Atom, subst: dict = None):
466
+ """One-sided match of two atoms: bind pattern_atom's variables against
467
+ target_atom's structure (see :func:`_match_term`). Same predicate and
468
+ arity are required; arguments are matched pairwise, threading the
469
+ substitution left to right. Returns the substitution, or None on failure.
470
+ ``subst`` defaults to the empty substitution.
471
+ """
472
+ if subst is None:
473
+ subst = {}
474
+ if (pattern_atom.predicate != target_atom.predicate
475
+ or len(pattern_atom.args) != len(target_atom.args)):
476
+ return None
477
+ for p_arg, t_arg in zip(pattern_atom.args, target_atom.args):
478
+ subst = _match_term(p_arg, t_arg, subst)
479
+ if subst is None:
480
+ return None
481
+ return subst
482
+
483
+
484
+ def _match_literal(pattern_lit: Node, target_lit: Node, subst: dict):
485
+ """One-sided match of two literals: same polarity, then :func:`_match` on
486
+ their atoms. Returns the extended substitution, or None on failure."""
487
+ if _is_positive(pattern_lit) != _is_positive(target_lit):
488
+ return None
489
+ return _match(_atom_of(pattern_lit), _atom_of(target_lit), subst)
490
+
491
+
492
+ def _subsumes(pattern_clause: frozenset, target_clause: frozenset) -> bool:
493
+ """Return True iff pattern_clause subsumes target_clause.
494
+
495
+ Clause subsumption: ``∃σ`` (binding only pattern_clause's variables) with
496
+ ``σ(pattern_clause) ⊆ target_clause`` as a set of literals — i.e. every
497
+ literal of pattern_clause can be assigned, under one SHARED substitution,
498
+ to some literal of target_clause. The assignment need not be injective:
499
+ two distinct pattern literals may map to the same target literal (e.g.
500
+ ``{P(x), P(y)}`` subsumes ``{P(a)}`` via x, y ↦ a both landing on the sole
501
+ target literal). Decided by backtracking search over literal assignments.
502
+
503
+ Only the boolean answer is observable here (not which σ was found), and
504
+ that answer is a pure function of the two clauses' CONTENT regardless of
505
+ what order literals are tried internally — an existential search visits
506
+ every candidate assignment either way — so, unlike the resolvent/factor
507
+ generation order elsewhere in this module, no content-based sort is
508
+ needed for :func:`refute`'s determinism guarantee. Internally, target
509
+ literals are bucketed by ``(predicate, polarity)`` so each pattern
510
+ literal only ever considers candidates it could possibly match (a
511
+ predicate/polarity mismatch is rejected by an O(1) dict lookup instead of
512
+ a doomed call into :func:`_match_literal`), and pattern literals are tried
513
+ rarest-candidate-first so an unmatchable pattern literal fails fast
514
+ instead of being discovered deep in the recursion.
515
+
516
+ A subsumed clause is logically redundant: it is entailed by the subsuming
517
+ clause alone (an instance of a clause already follows from that clause),
518
+ so discarding it loses no logical content.
519
+ """
520
+ pattern_lits = list(pattern_clause)
521
+ if not pattern_lits:
522
+ return True # the empty clause is a subset of every clause
523
+
524
+ buckets = {}
525
+ for lit in target_clause:
526
+ key = (_atom_of(lit).predicate, _is_positive(lit))
527
+ buckets.setdefault(key, []).append(lit)
528
+
529
+ def candidates(lit):
530
+ return buckets.get((_atom_of(lit).predicate, _is_positive(lit)), ())
531
+
532
+ # Necessary condition, checked once up front: every pattern literal needs
533
+ # SOME same-predicate-and-polarity candidate, or no assignment can exist.
534
+ if any(not candidates(lit) for lit in pattern_lits):
535
+ return False
536
+ pattern_lits.sort(key=lambda lit: len(candidates(lit)))
537
+
538
+ def backtrack(i: int, subst: dict) -> bool:
539
+ if i == len(pattern_lits):
540
+ return True
541
+ lit = pattern_lits[i]
542
+ for target_lit in candidates(lit):
543
+ extended = _match_literal(lit, target_lit, subst)
544
+ if extended is not None and backtrack(i + 1, extended):
545
+ return True
546
+ return False
547
+
548
+ return backtrack(0, {})
549
+
550
+
551
+ # ---------------------------------------------------------------------------
552
+ # Binary resolution
553
+ # ---------------------------------------------------------------------------
554
+
555
+ def _resolvents(clause1: frozenset, clause2: frozenset) -> list:
556
+ """Return every binary resolvent of two (already standardized-apart) clauses.
557
+
558
+ For each positive/negative complementary pair — one literal an ``Atom A`` in
559
+ one clause, the other a ``Not(B)`` in the other clause with ``A``/``B`` the
560
+ same predicate and arity — the atoms are unified. On success with mgu σ the
561
+ resolvent is σ applied to ``(clause1 ∖ L) ∪ (clause2 ∖ M)``, as a frozenset.
562
+ """
563
+ results = []
564
+ # Deterministic literal order (see _rename_clause): resolvent GENERATION
565
+ # order feeds the agenda, so it must not depend on frozenset hash order.
566
+ for lit1 in sorted(clause1, key=_lit_key):
567
+ for lit2 in sorted(clause2, key=_lit_key):
568
+ if _is_positive(lit1) == _is_positive(lit2):
569
+ continue # need complementary polarity
570
+ atom1 = _atom_of(lit1)
571
+ atom2 = _atom_of(lit2)
572
+ subst = unify(atom1, atom2)
573
+ if subst is None:
574
+ continue
575
+ rest1 = (lit for lit in clause1 if lit != lit1)
576
+ rest2 = (lit for lit in clause2 if lit != lit2)
577
+ resolvent = frozenset(
578
+ [_subst_literal(lit, subst) for lit in rest1]
579
+ + [_subst_literal(lit, subst) for lit in rest2]
580
+ )
581
+ results.append(resolvent)
582
+ return results
583
+
584
+
585
+ # ---------------------------------------------------------------------------
586
+ # Factoring
587
+ # ---------------------------------------------------------------------------
588
+
589
+ def _factors(clause: frozenset) -> list:
590
+ """Return every factor of a clause.
591
+
592
+ For each pair of same-polarity literals whose atoms unify, the clause is
593
+ instantiated by their mgu σ (merging the two literals) and the σ-image of the
594
+ whole clause is returned as a frozenset. Needed for completeness on inputs
595
+ where two literals must be identified before the empty clause can appear.
596
+ """
597
+ results = []
598
+ literals = sorted(clause, key=_lit_key)
599
+ for i in range(len(literals)):
600
+ for j in range(i + 1, len(literals)):
601
+ lit_i, lit_j = literals[i], literals[j]
602
+ if _is_positive(lit_i) != _is_positive(lit_j):
603
+ continue # factoring needs equal polarity
604
+ subst = unify(_atom_of(lit_i), _atom_of(lit_j))
605
+ if subst is None:
606
+ continue
607
+ factored = frozenset(_subst_literal(lit, subst) for lit in clause)
608
+ results.append(factored)
609
+ return results
610
+
611
+
612
+ # ---------------------------------------------------------------------------
613
+ # Equality: a simple term order, subterm positions, paramodulation,
614
+ # reflexivity resolution, and demodulation.
615
+ # ---------------------------------------------------------------------------
616
+
617
+ def _is_equality_atom(atom: Atom) -> bool:
618
+ """Return True iff atom is a binary ``=`` atom (a positive OR negative
619
+ equality literal wraps one of these — this only tests the atom itself,
620
+ call on ``_atom_of(literal)``)."""
621
+ return atom.predicate == "=" and len(atom.args) == 2
622
+
623
+
624
+ def _term_weight(term: Node) -> int:
625
+ """The module's term order, part 1: WEIGHT, defined as term size.
626
+
627
+ A leaf (:class:`Variable`, :class:`Constant`, :class:`Number`) has
628
+ weight 1; a :class:`Function` application has weight ``1 +`` the sum of
629
+ its arguments' weights. :class:`Measure` and other non-Function compound
630
+ terms are treated as opaque leaves (weight 1) — this module's equality
631
+ reasoning does not descend into them (see :func:`_subterm_positions`).
632
+ """
633
+ if isinstance(term, Function):
634
+ return 1 + sum(_term_weight(a) for a in term.args)
635
+ return 1
636
+
637
+
638
+ def _term_order_key(term: Node):
639
+ """The module's term order, part 2: ties in weight are broken
640
+ LEXICOGRAPHICALLY by the term's rendering with every constant written by its bare name
641
+ (``key_text``, plain Python string comparison: the quotes of a quoted constant play no
642
+ part). Returns the ``(weight, rendering)`` pair compared by :func:`_term_gt`.
643
+ """
644
+ return (_term_weight(term), key_text(term))
645
+
646
+
647
+ def _term_gt(s: Node, t: Node) -> bool:
648
+ """Return True iff ``s`` is STRICTLY greater than ``t`` under this
649
+ module's term order: compare :func:`_term_order_key` pairs (weight
650
+ first, then the lexicographic tie-break). This order is total up to
651
+ genuine term equality — two DISTINCT terms are, barring a rendering
652
+ collision, always strictly ordered one way or the other, so
653
+ "incomparable" (neither ``_term_gt(s, t)`` nor ``_term_gt(t, s)``) only
654
+ arises when ``s`` and ``t`` are, for the order's purposes, the same term.
655
+
656
+ Soundness note (why no substitution-compatibility closure is needed):
657
+ every USE of this order in this module — orienting a paramodulation
658
+ direction, or confirming a demodulation rewrite — compares two already
659
+ fully- or partially-INSTANTIATED terms directly (e.g. ``lσ`` versus
660
+ ``rσ`` for the concrete σ actually used), never an abstract rule "before"
661
+ substitution. A real term-rewriting system needs its order to be stable
662
+ under substitution and monotone under context (the standard KBO/LPO
663
+ closure properties) to prove a whole many-rule system terminates; this
664
+ module sidesteps that requirement by re-checking the concrete inequality
665
+ at each individual rewrite instead of trusting it to persist abstractly.
666
+ A single demodulation step is still guaranteed to terminate the fixpoint
667
+ loop that applies it repeatedly (:func:`_demodulate_to_fixpoint`),
668
+ because replacing a subterm by a strictly lighter one strictly decreases
669
+ the WEIGHT of every ancestor up to the literal's atom (weight is
670
+ additive over immediate children), hence the clause's total weight — a
671
+ bounded-below natural number, so no infinite descending chain exists.
672
+ """
673
+ return _term_order_key(s) > _term_order_key(t)
674
+
675
+
676
+ def _paramodulation_directions(u: Node, v: Node):
677
+ """Return the list of ``(from, to)`` direction pairs licensed for the
678
+ equation ``u ≈ v`` by the term order: paramodulate only from the
679
+ order-GREATER side into the smaller one when :func:`_term_gt` strictly
680
+ decides; if neither side is greater (only ``u`` and ``v`` being the same
681
+ term under the order, see its docstring), try both directions. This is a
682
+ pure SEARCH-SPACE restriction — paramodulation is unconditionally sound
683
+ in either direction regardless of what the order says (see the module
684
+ docstring); dropping a direction here only ever costs completeness, never
685
+ soundness.
686
+ """
687
+ if _term_gt(u, v):
688
+ return [(u, v)]
689
+ if _term_gt(v, u):
690
+ return [(v, u)]
691
+ return [(u, v), (v, u)]
692
+
693
+
694
+ def _subterm_positions(atom: Atom):
695
+ """Yield every position (a non-empty tuple of argument indices) that
696
+ addresses a non-Variable subterm reachable from atom's arguments, in
697
+ pre-order, left to right, descending into :class:`Function` arguments.
698
+
699
+ A position addresses a Function application itself (before descending
700
+ into its arguments) as well as every Constant/Number leaf; a Variable
701
+ position is never yielded — paramodulating INTO a bare variable
702
+ occurrence is a standard, deliberate restriction (redundant with
703
+ instantiation, and a large source of unproductive branching for no
704
+ completeness gain worth having in a didactic prover — see the module
705
+ docstring's honesty note). Non-Function compound terms (e.g.
706
+ :class:`Measure`) are treated as opaque leaves — reachable as a position
707
+ themselves, but not descended into — equality reasoning here is scoped
708
+ to the ordinary Function/Atom term language.
709
+ """
710
+ def walk(term, prefix):
711
+ if isinstance(term, Function):
712
+ yield prefix
713
+ for i, arg in enumerate(term.args):
714
+ yield from walk(arg, prefix + (i,))
715
+ elif not isinstance(term, Variable):
716
+ yield prefix
717
+ for i, arg in enumerate(atom.args):
718
+ yield from walk(arg, (i,))
719
+
720
+
721
+ def _term_at(node: Node, position) -> Node:
722
+ """Walk position (a tuple of argument indices, as yielded by
723
+ :func:`_subterm_positions`) from node down to the addressed subterm."""
724
+ for i in position:
725
+ node = node.args[i]
726
+ return node
727
+
728
+
729
+ def _replace_at(node: Node, position, replacement: Node) -> Node:
730
+ """Return a copy of node (an :class:`Atom` or :class:`Function`) with
731
+ the subterm at position replaced by replacement. ``position == ()``
732
+ replaces node itself."""
733
+ if not position:
734
+ return replacement
735
+ i, rest = position[0], position[1:]
736
+ new_args = list(node.args)
737
+ new_args[i] = _replace_at(node.args[i], rest, replacement)
738
+ if isinstance(node, Atom):
739
+ return Atom(node.predicate, new_args)
740
+ return Function(node.name, new_args)
741
+
742
+
743
+ def _paramodulate_target(eq_clause, eq_lit, frm: Node, to: Node, target_clause, tgt_lit):
744
+ """Return every one-step paramodulant of using ``eq_lit`` (``frm ≈ to``,
745
+ one already-chosen direction) from eq_clause to rewrite tgt_lit's atom
746
+ within target_clause, at every unifiable subterm position of tgt_lit.
747
+
748
+ Core of :func:`_paramodulants_cross` — kept clause-set-agnostic (it just
749
+ returns ``(new_target_literal, sigma)`` pairs for the caller to
750
+ assemble), which is also what :func:`_demodulate_once`'s cousin logic
751
+ mirrors on the matching side.
752
+ """
753
+ results = []
754
+ tgt_atom = _atom_of(tgt_lit)
755
+ for pos in _subterm_positions(tgt_atom):
756
+ subterm = _term_at(tgt_atom, pos)
757
+ sigma = unify(frm, subterm)
758
+ if sigma is None:
759
+ continue
760
+ new_atom = _replace_at(tgt_atom, pos, to)
761
+ new_lit = new_atom if _is_positive(tgt_lit) else Not(new_atom)
762
+ results.append((new_lit, sigma))
763
+ return results
764
+
765
+
766
+ def _paramodulants_cross(eq_clause: frozenset, target_clause: frozenset) -> list:
767
+ """Return every cross-clause paramodulant using a positive equality
768
+ literal of eq_clause to rewrite a literal of target_clause.
769
+
770
+ Mirrors :func:`_resolvents`'s calling convention: both clauses must
771
+ already be standardized apart by the caller (a soundness requirement —
772
+ the two clauses' variables must not be conflated), and ``target_clause``
773
+ may be a differently-renamed copy of the very same logical clause as
774
+ ``eq_clause`` (the caller's given/kept-list loop already relies on this
775
+ for binary resolution's own "resolve a clause against itself" case; the
776
+ same renaming makes cross-clause paramodulation of a clause against
777
+ itself sound too). For each positive equality literal ``s ≈ t`` in
778
+ eq_clause and each direction :func:`_paramodulation_directions` allows,
779
+ every literal of target_clause is tried at every subterm position (see
780
+ :func:`_subterm_positions`); on a successful unification with mgu σ, the
781
+ paramodulant is σ applied to ``(eq_clause ∖ {s≈t}) ∪ (target_clause ∖
782
+ {L}) ∪ {L[p ↦ t]}``.
783
+ """
784
+ results = []
785
+ eq_lits = [l for l in sorted(eq_clause, key=_lit_key)
786
+ if _is_positive(l) and _is_equality_atom(_atom_of(l))]
787
+ if not eq_lits:
788
+ return results
789
+ target_lits = sorted(target_clause, key=_lit_key)
790
+ for eq_lit in eq_lits:
791
+ u, v = _atom_of(eq_lit).args
792
+ for frm, to in _paramodulation_directions(u, v):
793
+ for tgt_lit in target_lits:
794
+ for new_lit, sigma in _paramodulate_target(
795
+ eq_clause, eq_lit, frm, to, target_clause, tgt_lit):
796
+ rest_eq = [l for l in eq_clause if l != eq_lit]
797
+ rest_tgt = [l for l in target_clause if l != tgt_lit]
798
+ results.append(frozenset(
799
+ [_subst_literal(l, sigma) for l in rest_eq]
800
+ + [_subst_literal(l, sigma) for l in rest_tgt]
801
+ + [_subst_literal(new_lit, sigma)]
802
+ ))
803
+ return results
804
+
805
+
806
+ def _reflexivity_resolvents(clause: frozenset) -> list:
807
+ """Return every reflexivity resolvent of clause: for each NEGATIVE
808
+ equality literal ``¬(u ≈ v)`` whose two sides unify (mgu σ), the literal
809
+ is dropped and σ applied to the rest — the mechanism that closes goals
810
+ such as ``⊢ c=c`` (whose negation clausifies to ``{¬(c=c)}``, unified by
811
+ the trivial mgu ``{}``) or, more generally, any clause carrying a
812
+ negated equation between two terms that are really the same up to
813
+ instantiation.
814
+ """
815
+ results = []
816
+ for lit in sorted(clause, key=_lit_key):
817
+ if _is_positive(lit):
818
+ continue
819
+ atom = _atom_of(lit)
820
+ if not _is_equality_atom(atom):
821
+ continue
822
+ u, v = atom.args
823
+ sigma = unify(u, v)
824
+ if sigma is None:
825
+ continue
826
+ rest = frozenset(_subst_literal(l, sigma) for l in clause if l != lit)
827
+ results.append(rest)
828
+ return results
829
+
830
+
831
+ # Demodulation fixpoint loop: defensively capped, though termination is
832
+ # already guaranteed without it (see _term_gt's soundness note) — same
833
+ # spirit as _SUBSUMPTION_PATTERN_CAP below, a belt-and-braces bound on a
834
+ # process that is mathematically guaranteed to terminate anyway.
835
+ _DEMODULATION_ITERATION_CAP = 100
836
+
837
+
838
+ def _unit_rewrite_rules(clauses) -> list:
839
+ """Return the oriented ``(l, r)`` demodulation rules licensed by the
840
+ UNIT (single-literal) positive equality clauses among clauses.
841
+
842
+ A non-unit clause ``{u≈v, Q}`` is never used as a rewrite rule: it only
843
+ asserts ``u≈v ∨ Q``, not the unconditional fact ``u≈v`` — using it to
844
+ rewrite unconditionally would be unsound. An equation whose two sides
845
+ are the same term under :func:`_term_gt` (so neither is strictly
846
+ greater) contributes no rule — rewriting a term to itself is vacuous and
847
+ an unoriented rule cannot be checked for termination.
848
+ """
849
+ rules = []
850
+ for clause in clauses:
851
+ if len(clause) != 1:
852
+ continue
853
+ (lit,) = tuple(clause)
854
+ if not _is_positive(lit):
855
+ continue
856
+ atom = _atom_of(lit)
857
+ if not _is_equality_atom(atom):
858
+ continue
859
+ u, v = atom.args
860
+ if _term_gt(u, v):
861
+ rules.append((u, v))
862
+ elif _term_gt(v, u):
863
+ rules.append((v, u))
864
+ return rules
865
+
866
+
867
+ def _demodulate_once(clause: frozenset, rules: list):
868
+ """Try one demodulation rewrite of clause using rules (oriented ``(l,
869
+ r)`` pairs, as :func:`_unit_rewrite_rules` produces). Returns the
870
+ rewritten clause, or None if no rule applies.
871
+
872
+ A rule applies at a literal's subterm (found via :func:`_subterm_positions`)
873
+ when ``l`` one-sidedly MATCHES it (:func:`_match_term` — the rule's own
874
+ variables bind, the target clause's variables are held fixed, unlike
875
+ paramodulation's full unification) AND the concrete orientation
876
+ ``subterm ≻ rσ`` holds under :func:`_term_gt` for the match's σ — checked
877
+ on the instantiated terms, not merely inherited from the rule's abstract
878
+ orientation (see :func:`_term_gt`'s soundness note). The rule and the clause
879
+ are not standardized apart, so the rule ``f(x, y) → g(x)`` matched against
880
+ ``f(y, z)`` gives ``{x: y, y: z}`` (and ``{y: y}`` or ``{x: y, y: x}`` for
881
+ other clauses): the images are terms of the clause, never looked up again, so
882
+ the matcher is applied to ``r`` in ONE simultaneous step
883
+ (:func:`_rename_term`), the instance being ``g(y)``, as the proof checkers
884
+ (:func:`~unicode_logic_kit.atp.resolution_check._apply_matcher`) compute it.
885
+ Literals and
886
+ positions are visited in a fixed, content-determined order (mirroring
887
+ every other generator in this module) so which of several possible
888
+ rewrites fires is reproducible run to run.
889
+ """
890
+ for lit in sorted(clause, key=_lit_key):
891
+ atom = _atom_of(lit)
892
+ for pos in _subterm_positions(atom):
893
+ subterm = _term_at(atom, pos)
894
+ for l, r in rules:
895
+ sigma = _match_term(l, subterm, {})
896
+ if sigma is None:
897
+ continue
898
+ r_sigma = _rename_term(r, sigma)
899
+ if not _term_gt(subterm, r_sigma):
900
+ continue
901
+ new_atom = _replace_at(atom, pos, r_sigma)
902
+ new_lit = new_atom if _is_positive(lit) else Not(new_atom)
903
+ return frozenset([new_lit] + [other for other in clause if other != lit])
904
+ return None
905
+
906
+
907
+ def _demodulate_to_fixpoint(clause: frozenset, rules: list,
908
+ cap: int = _DEMODULATION_ITERATION_CAP):
909
+ """Repeatedly apply :func:`_demodulate_once` to clause until no rule
910
+ applies (a simplification fixpoint) or cap rewrites have fired. Returns
911
+ ``(simplified_clause, rewrite_count)`` — the caller (:func:`refute`)
912
+ charges rewrite_count against the overall step budget, so demodulation
913
+ is never a free/silent rewrite (see the module docstring).
914
+ """
915
+ count = 0
916
+ while count < cap:
917
+ rewritten = _demodulate_once(clause, rules)
918
+ if rewritten is None:
919
+ break
920
+ clause = rewritten
921
+ count += 1
922
+ return clause, count
923
+
924
+
925
+ # ---------------------------------------------------------------------------
926
+ # Saturation
927
+ # ---------------------------------------------------------------------------
928
+
929
+ # A clause acting as a subsumption PATTERN (the candidate subsumer, on either
930
+ # side of the check) is only considered when it has at most this many
931
+ # literals. Subsumption by a short clause — unit and binary clauses above all
932
+ # — is where almost all practical redundancy in a resolution search lives
933
+ # (an instantiated fact or rule absorbing padded/weakened copies of itself,
934
+ # exactly the shape :func:`refute`'s docstring measurable-case test uses).
935
+ # Larger clauses generally originate from problem-specific structure that is
936
+ # rarely literally repeated, so extending the search to them buys little
937
+ # extra pruning while its worst-case cost (a per-clause O(kept-set) scan) is
938
+ # what it costs regardless of pattern size. Honesty note: this makes forward
939
+ # and backward subsumption a BOUNDED, not exhaustive, redundancy check — a
940
+ # clause subsumed only by a longer pattern may survive. That never affects
941
+ # soundness or completeness (see the completeness argument below); it only
942
+ # means some redundant clauses are not pruned, exactly like hitting the step
943
+ # bound leaves some resolvents unexplored.
944
+ _SUBSUMPTION_PATTERN_CAP = 3
945
+
946
+
947
+ def refute(clauses, max_steps: int = 10000, timeout: Optional[float] = None) -> bool:
948
+ """Return True iff the clause set is unsatisfiable (empty clause derivable).
949
+
950
+ ``timeout`` (milliseconds, default none) is a second bound next to ``max_steps``: the
951
+ clock is read for every candidate clause and for every kept clause the given clause is
952
+ paired with, and once it has run out the call returns False ("not refuted within the
953
+ bound") within one such pairing's work. The call as a whole is also cut off at the
954
+ deadline, so the preparation of a very large clause set (sorting, the subsumption of
955
+ the seed clauses) cannot outlast it either.
956
+
957
+ Runs given-clause saturation: resolution between the new clause and every
958
+ previously kept clause, plus factoring of the new clause, PLUS (see the
959
+ module docstring's "Equality" section) reflexivity resolution and
960
+ self-paramodulation of the new clause, and cross-clause paramodulation
961
+ between it and every kept clause in both roles (equation-supplier /
962
+ rewrite-target) — with clause pairs standardized apart before each
963
+ cross-clause step, exactly as for resolution. Every newly generated
964
+ candidate is demodulated to a simplification fixpoint (using the
965
+ currently kept UNIT equations as oriented rewrite rules) before being
966
+ tested for redundancy and possibly kept — each individual demodulation
967
+ rewrite is charged against ``max_steps`` too, just like every other
968
+ inference here. Returns True as soon as the empty clause (``frozenset()``)
969
+ is derived; False if the set saturates with no new clause; and False
970
+ (conservatively, "not refuted within the bound") if ``max_steps``
971
+ inference/demodulation steps are taken first. Soundness rests on the
972
+ empty clause genuinely witnessing unsatisfiability, so True is only ever
973
+ returned when ``frozenset()`` is actually derived — paramodulation,
974
+ reflexivity resolution, and demodulation are all unconditionally sound
975
+ (see the module docstring), so adding them costs nothing on that front;
976
+ completeness for equality problems is explicitly NOT claimed beyond
977
+ what small problems reach within the bound (same module docstring).
978
+
979
+ Two redundancy eliminations are applied throughout (tautology deletion and
980
+ subsumption, see :func:`_is_tautology` / :func:`_subsumes`):
981
+
982
+ - A tautologous clause (containing a literal and its exact complement) is
983
+ never kept — not even as a seed clause.
984
+ - Forward subsumption: a newly generated clause D is discarded outright if
985
+ some already-kept clause C subsumes it (only attempted when
986
+ ``len(C) <= len(D)``, the necessary case this search targets, AND
987
+ ``len(C) <= _SUBSUMPTION_PATTERN_CAP`` — see that constant's docstring
988
+ for why the subsuming side is deliberately bounded).
989
+ - Backward subsumption: keeping a new clause D retires every already-kept
990
+ clause that D subsumes (same restrictions — D itself must be short
991
+ enough to act as a pattern), including agenda entries not yet
992
+ processed — the agenda-pop loop below checks membership in ``kept``
993
+ and silently skips an entry removed this way.
994
+
995
+ Completeness is preserved FOR THE EQUALITY-FREE FRAGMENT: resolution +
996
+ factoring is refutation-complete there (Robinson's theorem — any
997
+ unsatisfiable equality-free clause set has a derivation of the empty
998
+ clause by resolution and factoring alone), and both eliminations only
999
+ ever drop a clause that is redundant given what remains kept — this
1000
+ argument is UNCHANGED by adding the equality rules (they only ever add
1001
+ more ways to derive a clause, never remove one that resolution/factoring
1002
+ alone would have produced). For clause sets that use equality, the
1003
+ module docstring's honesty note applies: paramodulation is sound but
1004
+ this implementation does not claim the additional completeness
1005
+ refinements a serious equality prover needs. A tautology
1006
+ is valid in every model, so it can never itself contribute to deriving the
1007
+ empty clause (which certifies unsatisfiability) — it can only ever resolve
1008
+ away to something already derivable without it. A subsumed clause D is a
1009
+ strict logical consequence of the subsuming clause C alone (Cσ ⊆ D means
1010
+ every model of C is a model of D), so anything the saturation could
1011
+ eventually derive using D remains derivable using C in D's place; dropping
1012
+ D (or clauses D itself later subsumes) cannot make an unsatisfiable set
1013
+ appear satisfiable to the search. This is the standard redundancy-elimination
1014
+ result for resolution (tautology deletion and subsumption are both
1015
+ special cases of the general redundancy criterion under which saturation
1016
+ remains complete). The bool return, signature, and step-counting semantics
1017
+ are unchanged from the plain-saturation version.
1018
+
1019
+ ``clauses`` is any iterable of frozensets of literals; it is not mutated.
1020
+ """
1021
+ deadline = _instant(timeout)
1022
+ finished, refuted = _run_until(deadline, lambda: _saturate(clauses, max_steps, deadline))
1023
+ return bool(finished and refuted)
1024
+
1025
+
1026
+ def _saturate(clauses, max_steps: int, deadline: Optional[float]) -> bool:
1027
+ """The saturation of :func:`refute`, which runs it under ``deadline`` (a ``perf_counter``
1028
+ instant, or ``None``): it reads the clock itself for every candidate and every kept
1029
+ clause, and the whole run is cut off at the deadline besides, so the sorting and the
1030
+ subsumption of the seed clauses, which no step accounts for, cannot outlast it."""
1031
+ # Insertion-ordered working structures (kept_list mirrors the kept set):
1032
+ # processing order must be a function of the INPUT, not of hash seeds, or
1033
+ # "proved within max_steps" varies between runs of the same call. Seed
1034
+ # tautologies are filtered before dedup/sort so they never enter kept.
1035
+ seed = sorted((c for c in dict.fromkeys(clauses) if not _is_tautology(c)),
1036
+ key=lambda c: (len(c), sorted(_lit_key(l) for l in c)))
1037
+ if frozenset() in seed:
1038
+ return True
1039
+
1040
+ counter = [0]
1041
+ kept = set()
1042
+ kept_list = []
1043
+ # Kept clauses bucketed by literal count: subsumption is only ever
1044
+ # attempted between a pattern no longer than its target (len(C) <=
1045
+ # len(D)), so indexing by length lets both sweeps below visit only the
1046
+ # buckets that could possibly participate, instead of the whole kept set
1047
+ # — the necessary optimisation once kept grows into the thousands.
1048
+ kept_by_length = {}
1049
+ agenda = []
1050
+ steps = 0
1051
+
1052
+ def _forward_subsumed(clause) -> bool:
1053
+ """True iff some short kept clause (no longer than clause) subsumes it."""
1054
+ target_len = len(clause)
1055
+ for bucket_len, bucket in kept_by_length.items():
1056
+ if bucket_len <= target_len and bucket_len <= _SUBSUMPTION_PATTERN_CAP:
1057
+ for c in bucket:
1058
+ if _subsumes(c, clause):
1059
+ return True
1060
+ return False
1061
+
1062
+ def _keep(clause):
1063
+ # Backward subsumption FIRST: retire every currently kept clause that
1064
+ # the new clause subsumes (only those no shorter than the new
1065
+ # clause). ``clause`` is not yet indexed, so no self-comparison risk.
1066
+ # Skipped entirely when the new clause itself is too long to be a
1067
+ # capped subsuming pattern (see _SUBSUMPTION_PATTERN_CAP below).
1068
+ pattern_len = len(clause)
1069
+ if pattern_len <= _SUBSUMPTION_PATTERN_CAP:
1070
+ for bucket_len in list(kept_by_length):
1071
+ if bucket_len < pattern_len:
1072
+ continue
1073
+ bucket = kept_by_length[bucket_len]
1074
+ subsumed = [c for c in bucket if _subsumes(clause, c)]
1075
+ for other in subsumed:
1076
+ kept.discard(other)
1077
+ kept_list.remove(other)
1078
+ bucket.remove(other)
1079
+ if not bucket:
1080
+ del kept_by_length[bucket_len]
1081
+ kept.add(clause)
1082
+ kept_list.append(clause)
1083
+ kept_by_length.setdefault(pattern_len, []).append(clause)
1084
+ agenda.append(clause)
1085
+
1086
+ def _consider(clause) -> bool:
1087
+ """Try to add a newly generated clause; True iff it is the empty
1088
+ clause (the caller must report refutation immediately)."""
1089
+ if clause == frozenset():
1090
+ return True
1091
+ if not _is_tautology(clause) and clause not in kept and not _forward_subsumed(clause):
1092
+ _keep(clause)
1093
+ return False
1094
+
1095
+ def _process(candidate) -> bool:
1096
+ """Account one freshly generated candidate clause against the step
1097
+ budget, demodulate it to a simplification fixpoint under the
1098
+ CURRENTLY kept unit equations (forward demodulation only — see the
1099
+ module docstring; ``kept_list`` at call time is exactly "currently
1100
+ kept"), and try to keep the result. Returns True iff the empty
1101
+ clause was reached. The caller must still check ``steps >=
1102
+ max_steps`` right after calling this, exactly like every existing
1103
+ per-candidate site below — demodulation's own rewrites are charged
1104
+ into the same nonlocal ``steps`` counter, so a candidate that takes
1105
+ many rewrites to simplify can itself exhaust the budget.
1106
+ """
1107
+ nonlocal steps
1108
+ steps += 1
1109
+ if deadline is not None and time.perf_counter() > deadline:
1110
+ steps = max_steps # the callers' step check then ends the search
1111
+ return False
1112
+ rules = _unit_rewrite_rules(kept_list)
1113
+ simplified, rewrite_count = _demodulate_to_fixpoint(
1114
+ candidate, rules, cap=min(_DEMODULATION_ITERATION_CAP, max(0, max_steps - steps)))
1115
+ steps += rewrite_count
1116
+ return _consider(simplified)
1117
+
1118
+ for clause in seed:
1119
+ # Empty-clause seeds already returned above; ordinary seeds only need
1120
+ # forward subsumption (nothing kept yet can be backward-subsumed by a
1121
+ # clause not yet added, so _keep's own sweep is what matters here).
1122
+ # Seeds are NOT demodulated (see the module docstring) — only clauses
1123
+ # generated during saturation pass through _process.
1124
+ if clause not in kept and not _forward_subsumed(clause):
1125
+ _keep(clause)
1126
+
1127
+ while agenda:
1128
+ if deadline is not None and time.perf_counter() > deadline:
1129
+ return False
1130
+ given = agenda.pop(0)
1131
+ if given not in kept:
1132
+ continue # removed by backward subsumption after being queued
1133
+
1134
+ # Factor the given clause against itself.
1135
+ for factor in _factors(given):
1136
+ if _process(factor):
1137
+ return True
1138
+ if steps >= max_steps:
1139
+ return False
1140
+
1141
+ # Reflexivity-resolve the given clause against itself (shared
1142
+ # variables, no renaming — see its docstring). Paramodulation of a
1143
+ # clause into itself deliberately has NO shared-variable shortcut
1144
+ # (unsound — see the module docstring); it happens below via the
1145
+ # renamed-copy cross path only.
1146
+ for candidate in _reflexivity_resolvents(given):
1147
+ if _process(candidate):
1148
+ return True
1149
+ if steps >= max_steps:
1150
+ return False
1151
+
1152
+ # Resolve/paramodulate the given clause against every kept clause
1153
+ # (including itself, via renaming).
1154
+ for other in list(kept_list):
1155
+ if other not in kept:
1156
+ continue # backward-subsumed by an earlier resolvent this round
1157
+ if deadline is not None and time.perf_counter() > deadline:
1158
+ return False
1159
+ r_given = _rename_clause(given, counter)
1160
+ r_other = _rename_clause(other, counter)
1161
+ for resolvent in _resolvents(r_given, r_other):
1162
+ if _process(resolvent):
1163
+ return True
1164
+ if steps >= max_steps:
1165
+ return False
1166
+ # Paramodulation is directional (which clause supplies the
1167
+ # equation versus the rewrite target), unlike resolution's
1168
+ # symmetric literal scan, so both roles are tried explicitly.
1169
+ for candidate in _paramodulants_cross(r_given, r_other):
1170
+ if _process(candidate):
1171
+ return True
1172
+ if steps >= max_steps:
1173
+ return False
1174
+ for candidate in _paramodulants_cross(r_other, r_given):
1175
+ if _process(candidate):
1176
+ return True
1177
+ if steps >= max_steps:
1178
+ return False
1179
+
1180
+ return False # saturated without deriving the empty clause
1181
+
1182
+
1183
+ # ---------------------------------------------------------------------------
1184
+ # Entailment and validity
1185
+ # ---------------------------------------------------------------------------
1186
+
1187
+ def _translate_modal_inputs(premises, conclusion: Node):
1188
+ """Return ``(lowered, first_order)`` for a modal entailment, or None.
1189
+
1190
+ ``lowered`` is the classical FOL image; ``first_order`` says the quantified
1191
+ (qml) route produced it, so :func:`prove` can scale its step budget to the
1192
+ larger image. ``None`` means the input was classical all along.
1193
+
1194
+ When any input carries a modal/temporal/hybrid operator, the whole LOCAL
1195
+ consequence ``premises ⊢ conclusion`` is folded into one implication sharing
1196
+ a single free world variable and lowered with ``standard_translation`` — the
1197
+ same local reading :func:`~unicode_logic_kit.atp.modal_tableau.modal_prove`
1198
+ decides. The universal closure that :func:`prove` applies afterwards then
1199
+ quantifies that ONE world over the implication as a whole, which is exactly
1200
+ K-validity of the local consequence. Returns ``None`` for classical input.
1201
+
1202
+ A QUANTIFIED modal input (object quantifiers mixed with modalities) is
1203
+ lowered with the first-order shallow embedding instead
1204
+ (:func:`unicode_logic_kit.fol.qml.qml_translate` via its validity formula),
1205
+ under the same K frame and the ``constant``-domain regime — the FO-modal
1206
+ default of ``qml_is_valid``. For other frames or domain regimes
1207
+ (varying/increasing/decreasing) call ``qml_is_valid`` directly.
1208
+
1209
+ A SORTED constant ``c:S`` is an element of ``S`` at every world (a constant is a rigid
1210
+ designator), and the standard translation's guard atom ``S(c, w)`` does not say so. The
1211
+ propositional route therefore lowers the problem to ``membership → image``, with the
1212
+ rigid, unguarded membership ``∀v0 S(c, v0)`` of every sorted constant of the input taken
1213
+ from :func:`~unicode_logic_kit.fol.modal_translation.frame_axioms` — so ``□Human(carl:Human)``
1214
+ is proved, ``◇Human(carl:Human)`` is not (a world without successor) and
1215
+ ``□Mortal(carl:Human)`` is not. The quantified route already carries the same fact among
1216
+ ``qml``'s axioms.
1217
+
1218
+ Counterfactuals are guarded first: ``standard_translation`` predates them and
1219
+ its generic error would not name the sphere tools.
1220
+
1221
+ Raises:
1222
+ NotImplementedError: from ``standard_translation`` / ``qml`` on the
1223
+ genuinely non-first-order residue (Until/Since, hybrid nominals
1224
+ under quantifiers), with their documented reasons; or here for
1225
+ ``□→``/``◇→`` with a pointer at the sphere semantics.
1226
+ """
1227
+ from .modal_tableau import has_modal, _contains_counterfactual
1228
+ inputs = list(premises) + [conclusion]
1229
+ if not any(has_modal(f) for f in inputs):
1230
+ return None
1231
+ for f in inputs:
1232
+ if _contains_counterfactual(f):
1233
+ raise NotImplementedError(
1234
+ "resolution: the counterfactuals □→/◇→ have no first-order "
1235
+ "standard translation (they read a similarity ordering, not an "
1236
+ "accessibility relation); use cf_valid / cf_satisfies or "
1237
+ "isabelle_decide_counterfactual.")
1238
+ combined = conclusion
1239
+ for p in reversed(list(premises)):
1240
+ combined = Implies(p, combined)
1241
+ from ..fol.nodes import Quantifier, SortedQuantifier
1242
+ if any(isinstance(n, (Quantifier, SortedQuantifier)) for n in combined.walk()):
1243
+ # First-order modal logic: the propositional standard translation cannot
1244
+ # express object domains, but the FO shallow embedding can — same K
1245
+ # reading, constant domains (qml_is_valid's default).
1246
+ from ..fol.qml import _validity_formula
1247
+ return (_validity_formula(combined, "constant", "K"), True)
1248
+ from ..fol.modal_translation import standard_translation, frame_axioms
1249
+ image = standard_translation(combined)
1250
+ # The membership axioms are what frame_axioms returns for a formula that mentions no
1251
+ # relation and exactly these sorted constants; they join the image as hypotheses (never
1252
+ # conjoined onto it), in the translation's own vocabulary.
1253
+ constants = tuple(n for n in combined.walk() if isinstance(n, SortedConstant))
1254
+ if constants:
1255
+ for axiom in reversed(frame_axioms(Atom("P", constants))):
1256
+ image = Implies(axiom, image)
1257
+ return (image, False)
1258
+
1259
+
1260
+ def _refuse_cardinality(formulas) -> None:
1261
+ """Refuse a problem that holds a cardinality term, by name.
1262
+
1263
+ ``|{v : φ}|`` is a natural number that is counted in a structure. It is not a term of
1264
+ first-order logic, so it has no clause form, and reading it as an uninterpreted term would
1265
+ answer another question (``|{x : P(x)}| = |{x : Q(x)}|`` would not follow from
1266
+ ``∀x (P(x) ↔ Q(x))``).
1267
+
1268
+ Raises:
1269
+ NotImplementedError: a cardinality term occurs in one of ``formulas``.
1270
+ """
1271
+ for formula in formulas:
1272
+ for node in formula.walk():
1273
+ if isinstance(node, (Cardinality, SortedCardinality)):
1274
+ raise NotImplementedError(
1275
+ f"atp.resolution: the cardinality {node.to_unicode_str()} has no clause "
1276
+ "form. A cardinality |{v : φ}| is a natural number that is counted in a "
1277
+ "structure, not a term of first-order logic, so resolution does not decide "
1278
+ "a problem that holds one. A counting quantifier (∃≥n x φ, ∃≤n x φ, "
1279
+ "∃=n x φ) states a bound that resolution reads.")
1280
+
1281
+
1282
+ def prove(premises, conclusion: Node, max_steps: int = 10000,
1283
+ timeout: Optional[float] = None) -> bool:
1284
+ """Return True iff ``premises`` entail ``conclusion`` (premises ⊨ conclusion).
1285
+
1286
+ ``timeout`` (milliseconds, default none) bounds the whole call: the lowering of a
1287
+ modal input, the clausification of every source formula (a normal form can be
1288
+ exponentially larger than its formula, and that work is cut off at the deadline like
1289
+ any other) and then the saturation, which is handed what is left of it (see
1290
+ :func:`refute`). A call that ran out of time returns False, "not proved within the
1291
+ bound".
1292
+
1293
+ Decided by refutation. A variable that is free in a premise or in the
1294
+ conclusion is a PARAMETER of the problem: one unknown element, the same in
1295
+ every premise and in the conclusion (the consequence relation of the textbooks,
1296
+ ``Γ ⊨ φ`` iff every structure AND assignment that satisfies ``Γ`` satisfies
1297
+ ``φ``). It is replaced by a constant that no symbol of the problem has
1298
+ (:func:`~unicode_logic_kit.fol._free_parameters.parameterize`) before anything
1299
+ else is done, so ``P(x) ⊢ P(alpha)`` is not proved (universe ``{0, 1}``,
1300
+ ``x`` ↦ 1, ``alpha`` ↦ 0, ``P`` = ``{1}``), while ``P(x) ⊢ ∃y P(y)`` and
1301
+ ``∀y P(y) ⊢ P(x)`` are. A premise is never closed universally. For a problem
1302
+ without a premise the reading is the universal closure of the conclusion. The
1303
+ negation of the conclusion then has no free variable, so a parameter of the
1304
+ conclusion stays one fixed element under the negation. Each source formula is
1305
+ clausified independently and every clause is renamed apart, so the variable
1306
+ names from one source cannot collide with another's, and a Skolem symbol is
1307
+ fresh against every name of the whole problem. The clause sets
1308
+ are unioned and saturation is run. Returns True iff the empty clause is
1309
+ derived; False if the union saturates without it; and False (conservatively,
1310
+ "not proved within the bound") if ``max_steps`` is reached first — never
1311
+ reporting a non-theorem as proved.
1312
+
1313
+ Many-sorted input is read with the guard reading of
1314
+ :func:`~unicode_logic_kit.fol.nodes.to_fol` and gets the background facts that
1315
+ reading needs, as ordinary premises: :func:`~unicode_logic_kit.fol.nodes.sort_axioms`
1316
+ of the premises and the conclusion -- every sort is non-empty, and a sorted
1317
+ constant ``c:S`` lies in ``S``. A sorted constant is the same constant in
1318
+ every formula (``P(carl:S), ∀x (P(x) → Q(x)) ⊢ Q(carl:S)`` is proved). The
1319
+ facts are premises, never part of the negated conclusion. Still incomplete,
1320
+ like everything here: ``False`` means "not proved within the bound".
1321
+
1322
+ Raises:
1323
+ NotImplementedError: a premise or the conclusion holds a cardinality term
1324
+ ``|{v : φ}|``, which has no clause form (the ``resolution`` backend answers
1325
+ ``unknown`` / ``unsupported``).
1326
+ """
1327
+ deadline = _instant(timeout)
1328
+ premises = list(premises)
1329
+ _refuse_cardinality(premises + [conclusion])
1330
+ finished, translated = _run_until(
1331
+ deadline, lambda: _translate_modal_inputs(premises, conclusion))
1332
+ if not finished:
1333
+ return False
1334
+ if translated is not None:
1335
+ lowered, first_order = translated
1336
+ # The FO shallow embedding's image (guard predicates + domain/frame
1337
+ # axioms as hypotheses) is an order of magnitude larger than the
1338
+ # propositional ST image; scale the step budget so the textbook
1339
+ # quantified-modal validities (Barcan / converse Barcan under constant
1340
+ # domains, ~200k steps) close under the default budget. False remains
1341
+ # "not proved within the bound", as everywhere in this module.
1342
+ return prove([], lowered, max_steps=max_steps * (20 if first_order else 1),
1343
+ timeout=_remaining_ms(deadline))
1344
+
1345
+ def clausify() -> set:
1346
+ counter = [0]
1347
+ sk_counter = [0]
1348
+ clause_set = set()
1349
+ closed, _ = parameterize(premises + [conclusion])
1350
+ given, goal = closed[:-1], closed[-1]
1351
+ sources = list(given)
1352
+ sources.extend(sort_axioms(*given, goal))
1353
+ sources.append(Not(goal))
1354
+ problem_names = symbol_names(*sources)
1355
+ for source in sources:
1356
+ for clause in to_clauses(source, sk_counter=sk_counter, avoid=problem_names):
1357
+ clause_set.add(_rename_clause(clause, counter))
1358
+ return clause_set
1359
+
1360
+ finished, clause_set = _run_until(deadline, clausify)
1361
+ if not finished or clause_set is None:
1362
+ return False
1363
+ return refute(clause_set, max_steps=max_steps, timeout=_remaining_ms(deadline))
1364
+
1365
+
1366
+ def is_valid_resolution(formula: Node, max_steps: int = 10000,
1367
+ timeout: Optional[int] = None) -> bool:
1368
+ """Return True iff ``formula`` is valid (its negation is refutable).
1369
+
1370
+ Equivalent to ``prove([], formula)``: a formula is valid exactly when
1371
+ ¬formula is unsatisfiable, which resolution decides by deriving the empty
1372
+ clause from the clauses of ¬formula. Incomplete under the bound: a return of
1373
+ False means "not shown valid within ``max_steps``", never a false claim of
1374
+ invalidity-as-validity.
1375
+ """
1376
+ return prove([], formula, max_steps=max_steps, timeout=timeout)