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,1114 @@
1
+ """Independent first-order resolution *proof checker* (not a searcher).
2
+
3
+ Where :mod:`unicode_logic_kit.atp.resolution` *searches* for a refutation (given
4
+ clause saturation, unification-driven), this module *certifies* one that was
5
+ produced elsewhere: an external searcher — a research engine comparing
6
+ calculi, a human, another prover — hands over a :class:`ResolutionDerivation`
7
+ (a flat list of clauses each justified as an input clause, a binary resolvent,
8
+ or a factor of an earlier clause) and :func:`verify_resolution_proof` decides,
9
+ independently, whether every step genuinely follows. This is the same
10
+ division of labour as :mod:`unicode_logic_kit.atp.fitch` (``check_proof``) and
11
+ :mod:`unicode_logic_kit.atp.sequent` (``check_sequent_proof``): a derivation is
12
+ only as trustworthy as its checker, so the checker must never share code with
13
+ whatever produced the derivation.
14
+
15
+ Clauses are represented exactly as :func:`unicode_logic_kit.atp.resolution.to_clauses`
16
+ produces them: a clause is a ``frozenset`` of literals, each literal a kit
17
+ :class:`~unicode_logic_kit.fol.nodes.Atom` (positive) or
18
+ :class:`~unicode_logic_kit.fol.nodes.Not` wrapping one (negative); variables are
19
+ implicitly universally quantified over the whole clause. Steps are 1-indexed in
20
+ derivation order — ``step.index`` must equal its 1-based position in
21
+ ``derivation.steps`` — mirroring :mod:`atp.fitch`'s ``1..N`` line-numbering
22
+ convention.
23
+
24
+ Checking semantics (soundness is the only job; the searcher's search *strategy*
25
+ — which pair it picked, in what order — is irrelevant and not reconstructed):
26
+
27
+ - ``"input"`` (0 parents): the step's clause must be a *variant* (see below) of
28
+ one of ``derivation.inputs``.
29
+ - ``"resolve"`` (2 parents ``i, j``, both ``< step.index``): the two parent
30
+ clauses are standardized apart (deterministic disjoint renaming — required
31
+ for soundness, since two clauses may share a variable name that names
32
+ logically unrelated individuals), and the step is accepted iff *some* pair
33
+ of complementary-polarity literals has unifiable atoms whose mgu, applied to
34
+ the union of the two remainder clauses, is a variant of the stated clause.
35
+ - ``"factor"`` (1 parent ``i``): accepted iff *some* pair of same-polarity
36
+ literals in the parent unifies, and the mgu applied to the whole clause is a
37
+ variant of the stated clause.
38
+ - ``"paramodulate"`` (2 parents ``i, j`` — ``i`` the equation, ``j`` the
39
+ rewrite target): unlike ``"resolve"``/``"factor"``, which search over EVERY
40
+ candidate literal pair, this rule and the two below require the
41
+ derivation to name its literals EXPLICITLY (``step.eq_literal``,
42
+ ``step.target_literal``, as they appear in the cited parents' OWN clauses —
43
+ not in some checker-internal renamed form) plus ``step.direction`` (``"lr"``
44
+ uses the equation's first argument as the rewrite source, ``"rl"`` the
45
+ second) and ``step.position`` (a tuple of argument indices addressing the
46
+ rewritten subterm of ``target_literal``'s atom). Nothing about the
47
+ UNIFIER is trusted or stored: the checker re-derives it from scratch by
48
+ unifying the equation's stated side against the subterm it extracts at
49
+ ``step.position``, and only accepts the step if that mgu, applied
50
+ everywhere, reproduces a variant of the stated clause. The two parents are
51
+ standardized apart first, exactly as for ``"resolve"``.
52
+ There is deliberately NO ``"self_paramodulate"`` rule: a shared-instance
53
+ shortcut (equation and target from ONE clause instantiation, both dropped)
54
+ is unsound — {u ≈ v, L[u]} would certify {L[v]}, false in a model that
55
+ satisfies the clause via L[u] alone with u ≠ v. A clause paramodulating
56
+ into itself must be certified as ``"paramodulate"`` with the clause cited
57
+ as BOTH parents (the checker standardizes the two citations apart).
58
+ - ``"reflexivity"`` (1 parent ``i``): ``step.eq_literal`` must be a NEGATIVE
59
+ equality literal ``¬(u=v)`` of the cited clause whose two sides unify (own
60
+ ``_unify``); the mgu applied to the clause minus that literal must be a
61
+ variant of the stated clause.
62
+ - ``"demodulate"`` (2 parents ``i, j`` — ``i`` the target, ``j`` a UNIT
63
+ equation clause): ``step.eq_literal`` must be the sole literal of the
64
+ cited unit clause ``j`` (a non-unit clause can never license an
65
+ unconditional rewrite — it only asserts a disjunction). Unlike
66
+ ``"paramodulate"``, the rewrite uses one-sided MATCHING, not unification
67
+ (the equation's stated side must literally instantiate, via its own free
68
+ variables only, the subterm at ``step.position`` — the target's variables
69
+ are never bound; the matcher is applied to the other side in ONE
70
+ simultaneous step, :func:`_apply_matcher`, because its images are terms of
71
+ the target and may be spelled like the equation's own variables), and the
72
+ orientation is re-checked with this module's own
73
+ term order (``_term_gt`` — see :mod:`atp.resolution`'s module docstring for
74
+ the order's definition): a direction that does not strictly decrease under
75
+ it is rejected, independent of what the derivation claims. No standardizing
76
+ apart is needed here (see the module's own note on that, near ``_match_term``).
77
+ - ``"truth_constants"`` (1 parent ``i``): the stated clause is the cited clause
78
+ with some of its literals removed, and every removed literal is ``$false`` or
79
+ ``¬$true`` (false in every interpretation), so ``{$false}`` gives the empty
80
+ clause and ``{P, $false}`` gives ``{P}``; removing any other literal is
81
+ rejected.
82
+ - The empty step list is ``ok=True, refuted=False`` (a derivation may verify
83
+ ok without proving anything); ``refuted`` is True iff some *successfully
84
+ verified* step's clause is empty, tracked independently of whether a later
85
+ step fails (so a bogus tail does not retroactively hide a genuine
86
+ refutation reached earlier).
87
+
88
+ Two deliberate independence requirements (the checker's raison d'être — an
89
+ external searcher must be certifiable *without trusting it*, and in particular
90
+ without trusting any inference machinery it might also use):
91
+
92
+ 1. **Unification is reimplemented from scratch here** (:func:`_unify` /
93
+ :func:`_apply`, Robinson's algorithm with an occurs-check) instead of
94
+ importing :mod:`unicode_logic_kit.fol.unification`. A searcher built on top of
95
+ the kit's own ``unify`` must not be checked by the very same unification
96
+ code — a bug shared between searcher and checker would be invisible to
97
+ both. This is checker independence in the sense of Sicherheitsanforderungen
98
+ wie FA-5.2 des Zielprojekts: der Prüfer teilt keinen Code mit dem Sucher
99
+ ("the checker shares no code with the searcher"). ``tests/test_resolution_check.py``
100
+ holds a differential battery: on unifiable/non-unifiable atom pairs
101
+ (occurs-check both directions, function nesting, repeated variables),
102
+ :func:`_unify` and :func:`unicode_logic_kit.fol.unification.unify` must agree
103
+ on unifiability, and where both succeed each substitution must equalize its
104
+ own two atoms.
105
+ 2. **Variant (alpha-equivalence) checking is exact, not an approximation.**
106
+ Two clauses are *variants* iff there is a bijection between their literals
107
+ together with a bijection on variable names under which corresponding
108
+ literals become syntactically identical — this is implemented as an
109
+ explicit backtracking search over literal correspondences
110
+ (:func:`_is_variant`), not a canonical/sorted-form shortcut (which would be
111
+ wrong: e.g. sorting literals by a variable-name-sensitive key does not
112
+ commute with the variable renaming a real variant requires). This matters
113
+ because both ``"input"`` and the resolvent/factor of ``"resolve"``/``"factor"``
114
+ are checked *up to variant*, not up to literal `==`: a searcher's exact
115
+ choice of variable spelling must never cause a genuinely correct step to be
116
+ rejected, while a real structural mismatch (different arity, a constant
117
+ where a variable was needed, an unmatched literal) must still be caught.
118
+
119
+ Public API: :class:`ResolutionStep`, :class:`ResolutionDerivation`,
120
+ :class:`ResolutionCheckResult`, the checkers :func:`verify_resolution_proof` /
121
+ :func:`check_resolution_proof`, and :func:`render_resolution_proof`.
122
+ """
123
+
124
+ from dataclasses import dataclass
125
+ from typing import Dict, FrozenSet, Optional, Tuple
126
+
127
+ from ..fol._msfl_nodes import key_text
128
+ from ..fol.nodes import Node, Atom, Not, Variable, Constant, Number, Function
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Derivation objects
133
+ # ---------------------------------------------------------------------------
134
+
135
+ @dataclass(frozen=True)
136
+ class ResolutionStep:
137
+ """One line of a resolution derivation: a clause and how it was obtained.
138
+
139
+ ``rule`` is one of ``"input"`` (0 parents — the clause must be a variant
140
+ of one of the derivation's inputs), ``"resolve"`` (2 parents — a binary
141
+ resolvent of the two cited earlier clauses), ``"factor"`` (1 parent — a
142
+ factor of the one cited earlier clause), the three equality rules
143
+ ``"paramodulate"``/``"reflexivity"``/``"demodulate"``
144
+ (see the module docstring for each — including why a
145
+ ``"self_paramodulate"`` rule deliberately does NOT exist), or
146
+ ``"truth_constants"`` (1 parent — the cited clause with some of its literals
147
+ that are false in every interpretation, ``$false`` and ``¬$true``, removed).
148
+ ``parents``
149
+ holds the 1-based
150
+ ``index`` values of the cited earlier steps, in citation order (so for
151
+ ``"resolve"`` the pair is *not* order-sensitive — both orderings of the
152
+ complementary literal are tried by the checker; the equality rules
153
+ ARE order-sensitive — see below and the module docstring).
154
+
155
+ The four extra fields below are used only by the equality rules (and left
156
+ at their defaults — ``None``/``None``/``None``/``()`` — for
157
+ ``"input"``/``"resolve"``/``"factor"``, keeping old derivations valid
158
+ unchanged):
159
+
160
+ - ``eq_literal``: the equality-atom literal the step consumes, exactly as
161
+ it appears in its owning parent clause (parents[0] for
162
+ ``"paramodulate"``/``"reflexivity"``,
163
+ parents[1] for ``"demodulate"``) — POSITIVE for
164
+ ``"paramodulate"``/``"demodulate"``, NEGATIVE
165
+ for ``"reflexivity"``.
166
+ - ``target_literal``: the literal being rewritten (unused by
167
+ ``"reflexivity"``, which only ever removes ``eq_literal`` itself) —
168
+ for ``"paramodulate"``/``"demodulate"`` this is a literal of the OTHER
169
+ parent (parents[1]/parents[0] respectively).
170
+ - ``direction``: ``"lr"`` uses ``eq_literal``'s first argument as the
171
+ rewrite source and its second as the replacement, ``"rl"`` the reverse.
172
+ - ``position``: a tuple of argument indices addressing the rewritten
173
+ subterm within ``target_literal``'s atom (``()`` would address the
174
+ atom itself, never valid — a position always descends into at least
175
+ one argument first).
176
+
177
+ ``clause`` is coerced to a ``frozenset``, ``parents``/``position`` to
178
+ ``tuple``\\ s, so the step stays hashable.
179
+ """
180
+
181
+ index: int
182
+ clause: FrozenSet[Node]
183
+ rule: str
184
+ parents: Tuple[int, ...] = ()
185
+ eq_literal: Optional[Node] = None
186
+ target_literal: Optional[Node] = None
187
+ direction: Optional[str] = None
188
+ position: Tuple[int, ...] = ()
189
+
190
+ def __post_init__(self):
191
+ """Coerce ``clause``/``parents``/``position`` for hashability."""
192
+ object.__setattr__(self, "clause", frozenset(self.clause))
193
+ object.__setattr__(self, "parents", tuple(self.parents))
194
+ object.__setattr__(self, "position", tuple(self.position))
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class ResolutionDerivation:
199
+ """A full resolution derivation: the original clauses plus a proof of steps.
200
+
201
+ ``inputs`` is the set of clauses the ``"input"`` steps are licensed to
202
+ restate (up to variant) — typically the clausal form of the premises and
203
+ the negated conclusion, as :func:`unicode_logic_kit.atp.resolution.to_clauses`
204
+ would produce them, though the checker does not require or re-derive that
205
+ provenance; it only checks that every ``"input"`` step matches one of
206
+ them. ``steps`` is the ordered derivation body (see :class:`ResolutionStep`).
207
+ """
208
+
209
+ inputs: Tuple[FrozenSet[Node], ...] = ()
210
+ steps: Tuple[ResolutionStep, ...] = ()
211
+
212
+ def __post_init__(self):
213
+ """Coerce ``inputs`` to a tuple of frozensets and ``steps`` to a tuple."""
214
+ object.__setattr__(self, "inputs", tuple(frozenset(c) for c in self.inputs))
215
+ object.__setattr__(self, "steps", tuple(self.steps))
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class ResolutionCheckResult:
220
+ """The outcome of checking a derivation.
221
+
222
+ ``ok`` is True iff every step up to (and including) the point of failure —
223
+ i.e. every step, if there was no failure — is licensed by its rule.
224
+ ``error_index``/``error`` name the first offending step (``error`` in the
225
+ style ``"step N: reason"``) or are ``None`` on success. ``refuted`` is True
226
+ iff some *successfully verified* step derives the empty clause — computed
227
+ independently of ``ok``, so a refutation reached before a later malformed
228
+ step is still reported.
229
+ """
230
+
231
+ ok: bool
232
+ error_index: Optional[int]
233
+ error: Optional[str]
234
+ refuted: bool
235
+
236
+ def __bool__(self) -> bool:
237
+ """A ResolutionCheckResult is truthy iff the derivation checked out."""
238
+ return self.ok
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Independent unification (Robinson's algorithm, occurs-check) — see module
243
+ # docstring, independence requirement 1: this must NOT import or call
244
+ # unicode_logic_kit.fol.unification.unify. A substitution is a plain dict
245
+ # mapping a variable NAME (str) to a term Node, exactly as in that module,
246
+ # but every function below is a from-scratch reimplementation.
247
+ # ---------------------------------------------------------------------------
248
+
249
+ def _apply(node: Node, subst: Dict[str, Node]) -> Node:
250
+ """Apply ``subst`` to ``node``, following chained bindings, without mutation.
251
+
252
+ This reads a UNIFIER's output, whose bindings are triangular (``{x: y, y: a}`` says
253
+ ``x`` is ``a``), so the chain has to be followed. It is NOT how a one-sided matcher is
254
+ applied: the images of a matcher are terms of the target, and following them reads a
255
+ variable of the target as a variable of the pattern that happens to be spelled the
256
+ same (see :func:`_apply_matcher`).
257
+ """
258
+ if isinstance(node, Variable):
259
+ if node.name in subst:
260
+ return _apply(subst[node.name], subst)
261
+ return node
262
+ if isinstance(node, (Constant, Number)):
263
+ return node
264
+ if isinstance(node, Function):
265
+ return Function(node.name, [_apply(a, subst) for a in node.args])
266
+ if isinstance(node, Atom):
267
+ return Atom(node.predicate, [_apply(a, subst) for a in node.args])
268
+ raise TypeError(f"_apply: unsupported node type {type(node).__name__}")
269
+
270
+
271
+ def _apply_literal(literal: Node, subst: Dict[str, Node]) -> Node:
272
+ """Apply ``subst`` to a literal (``Atom`` or ``Not(Atom)``), preserving polarity."""
273
+ if isinstance(literal, Not):
274
+ return Not(_apply(literal.formula, subst))
275
+ return _apply(literal, subst)
276
+
277
+
278
+ def _apply_matcher(node: Node, matcher: Dict[str, Node]) -> Node:
279
+ """Apply the one-sided matcher ``matcher`` to ``node`` in ONE simultaneous step.
280
+
281
+ A matcher (:func:`_match_term`) maps each variable of the PATTERN to a subterm of the
282
+ TARGET. The target's own variables are held fixed, not bound, and nothing is standardized
283
+ apart, so an image may mention a variable spelled like one the matcher binds: the
284
+ pattern ``f(x, y)`` against ``f(y, z)`` gives ``{x: y, y: z}``, and the instance of
285
+ ``g(x)`` is ``g(y)``, with that ``y`` the target's. An image is a finished term and is
286
+ never looked up again; :func:`_apply` would read it as the pattern's ``y`` and answer
287
+ ``g(z)``, and it would not end on ``{x: y, y: x}`` or ``{y: y}`` (a matcher that
288
+ is cyclic, or that binds a variable to itself, both arise from a rule matched against a
289
+ clause that shares its variable names).
290
+
291
+ A variable the matcher does not bind is left as it is, and so is every other leaf.
292
+ """
293
+ if isinstance(node, Variable):
294
+ return matcher.get(node.name, node)
295
+ if isinstance(node, (Constant, Number)):
296
+ return node
297
+ if isinstance(node, Function):
298
+ return Function(node.name, tuple(_apply_matcher(a, matcher) for a in node.args))
299
+ if isinstance(node, Atom):
300
+ return Atom(node.predicate, tuple(_apply_matcher(a, matcher) for a in node.args))
301
+ raise TypeError(f"_apply_matcher: unsupported node type {type(node).__name__}")
302
+
303
+
304
+ def _occurs(name: str, term: Node, subst: Dict[str, Node]) -> bool:
305
+ """Return True iff variable ``name`` occurs in ``term`` after applying ``subst``.
306
+
307
+ The occurs-check: rejects a binding such as ``x ↦ f(x)`` that would build
308
+ an infinite (cyclic) term.
309
+ """
310
+ term = _apply(term, subst)
311
+ if isinstance(term, Variable):
312
+ return term.name == name
313
+ if isinstance(term, Function):
314
+ return any(_occurs(name, a, subst) for a in term.args)
315
+ return False
316
+
317
+
318
+ def _bind(name: str, term: Node, subst: Dict[str, Node]) -> Optional[Dict[str, Node]]:
319
+ """Extend ``subst`` with ``name ↦ term`` (occurs-checked); returns a new dict or None.
320
+
321
+ Binding a variable to itself is a no-op that returns ``subst`` unchanged.
322
+ """
323
+ if isinstance(term, Variable) and term.name == name:
324
+ return subst
325
+ if _occurs(name, term, subst):
326
+ return None
327
+ new_subst = dict(subst)
328
+ new_subst[name] = _apply(term, subst)
329
+ return new_subst
330
+
331
+
332
+ def _leaf_value(node: Node):
333
+ """Return the comparable payload of a leaf node (Constant name / Number value)."""
334
+ if isinstance(node, Constant):
335
+ return node.name
336
+ if isinstance(node, Number):
337
+ return node.value
338
+ return None
339
+
340
+
341
+ def _unify_args(args1, args2, subst: Dict[str, Node]) -> Optional[Dict[str, Node]]:
342
+ """Unify two equal-length argument lists left-to-right, threading ``subst``."""
343
+ for a, b in zip(args1, args2):
344
+ subst = _unify(a, b, subst)
345
+ if subst is None:
346
+ return None
347
+ return subst
348
+
349
+
350
+ def _unify(t1: Node, t2: Node, subst: Optional[Dict[str, Node]] = None) -> Optional[Dict[str, Node]]:
351
+ """Most general unifier of ``t1`` and ``t2``, or None (Robinson, occurs-checked).
352
+
353
+ Independent reimplementation of :func:`unicode_logic_kit.fol.unification.unify`
354
+ (see module docstring, independence requirement 1) — same term language
355
+ (``Variable``/``Constant``/``Number``/``Function``, plus ``Atom`` for the
356
+ literal case) and the same algorithm, but sharing no code with it.
357
+ """
358
+ if subst is None:
359
+ subst = {}
360
+
361
+ t1 = _apply(t1, subst)
362
+ t2 = _apply(t2, subst)
363
+
364
+ if isinstance(t1, Variable):
365
+ return _bind(t1.name, t2, subst)
366
+ if isinstance(t2, Variable):
367
+ return _bind(t2.name, t1, subst)
368
+
369
+ if isinstance(t1, (Constant, Number)) or isinstance(t2, (Constant, Number)):
370
+ if type(t1) is type(t2) and _leaf_value(t1) == _leaf_value(t2):
371
+ return subst
372
+ return None
373
+
374
+ if isinstance(t1, Function) and isinstance(t2, Function):
375
+ if t1.name != t2.name or len(t1.args) != len(t2.args):
376
+ return None
377
+ return _unify_args(t1.args, t2.args, subst)
378
+
379
+ if isinstance(t1, Atom) and isinstance(t2, Atom):
380
+ if t1.predicate != t2.predicate or len(t1.args) != len(t2.args):
381
+ return None
382
+ return _unify_args(t1.args, t2.args, subst)
383
+
384
+ return None
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Exact variant (alpha-equivalence) checking — see module docstring,
389
+ # independence requirement 2: a backtracking search for a literal
390
+ # correspondence plus a variable-name BIJECTION, not a canonical-form
391
+ # shortcut.
392
+ # ---------------------------------------------------------------------------
393
+
394
+ def _term_alpha_match(t1: Node, t2: Node, fwd: Dict[str, str], bwd: Dict[str, str]):
395
+ """Try to extend the variable bijection ``(fwd, bwd)`` matching ``t1`` (A-side)
396
+ onto ``t2`` (B-side); returns the extended ``(fwd, bwd)`` pair or None.
397
+
398
+ ``fwd``/``bwd`` map A-variable-name -> B-variable-name and back; neither
399
+ input dict is mutated (a matched extension is a fresh copy), so a failed
400
+ branch never corrupts an earlier partial match a backtracking caller may
401
+ still want to try again on a different literal.
402
+ """
403
+ if isinstance(t1, Variable) and isinstance(t2, Variable):
404
+ mapped = fwd.get(t1.name)
405
+ if mapped is not None:
406
+ return (fwd, bwd) if mapped == t2.name else None
407
+ if t2.name in bwd:
408
+ return None # t2 already claimed by a different A-variable: not injective
409
+ new_fwd = dict(fwd)
410
+ new_fwd[t1.name] = t2.name
411
+ new_bwd = dict(bwd)
412
+ new_bwd[t2.name] = t1.name
413
+ return new_fwd, new_bwd
414
+ if isinstance(t1, Variable) or isinstance(t2, Variable):
415
+ return None # a variable can only match a variable — constants are not variables
416
+ if isinstance(t1, Constant) and isinstance(t2, Constant):
417
+ return (fwd, bwd) if t1.name == t2.name else None
418
+ if isinstance(t1, Number) and isinstance(t2, Number):
419
+ return (fwd, bwd) if t1.value == t2.value else None
420
+ if isinstance(t1, Function) and isinstance(t2, Function):
421
+ if t1.name != t2.name or len(t1.args) != len(t2.args):
422
+ return None
423
+ for a1, a2 in zip(t1.args, t2.args):
424
+ res = _term_alpha_match(a1, a2, fwd, bwd)
425
+ if res is None:
426
+ return None
427
+ fwd, bwd = res
428
+ return fwd, bwd
429
+ return None # mismatched node kinds (e.g. Constant vs Number)
430
+
431
+
432
+ def _lit_alpha_match(l1: Node, l2: Node, fwd: Dict[str, str], bwd: Dict[str, str]):
433
+ """As :func:`_term_alpha_match`, but for a whole literal (``Atom``/``Not(Atom)``).
434
+
435
+ Polarity must match, and the underlying atoms' predicate and arity must
436
+ match, before any variable matching is attempted.
437
+ """
438
+ neg1, neg2 = isinstance(l1, Not), isinstance(l2, Not)
439
+ if neg1 != neg2:
440
+ return None
441
+ a1 = l1.formula if neg1 else l1
442
+ a2 = l2.formula if neg2 else l2
443
+ if not (isinstance(a1, Atom) and isinstance(a2, Atom)):
444
+ return None
445
+ if a1.predicate != a2.predicate or len(a1.args) != len(a2.args):
446
+ return None
447
+ for x, y in zip(a1.args, a2.args):
448
+ res = _term_alpha_match(x, y, fwd, bwd)
449
+ if res is None:
450
+ return None
451
+ fwd, bwd = res
452
+ return fwd, bwd
453
+
454
+
455
+ def _is_variant(a: FrozenSet[Node], b: FrozenSet[Node]) -> bool:
456
+ """Return True iff clause ``a`` is a variant (alpha-equivalent copy) of clause ``b``.
457
+
458
+ Backtracking search for a bijection between the literals of ``a`` and
459
+ ``b`` together with a bijection on variable names under which every
460
+ matched literal pair becomes syntactically identical. Deliberately exact
461
+ (not a sorted/canonical-form shortcut — see the module docstring): e.g.
462
+ ``{P(x,y), P(y,x)}`` IS a variant of ``{P(u,v), P(v,u)}`` (a 2-cycle
463
+ literal correspondence with a 2-variable bijection), ``{P(x,x)}`` is NOT a
464
+ variant of ``{P(x,y)}`` (no single variable can equal two distinct ones),
465
+ and ``{P(x)}`` is NOT a variant of ``{P(a)}`` (a constant is never matched
466
+ by a variable). Worst case is factorial in clause size, which is fine for
467
+ the small clauses resolution steps actually produce.
468
+ """
469
+ la, lb = list(a), list(b)
470
+ if len(la) != len(lb):
471
+ return False
472
+ used = [False] * len(lb)
473
+
474
+ def backtrack(i: int, fwd: Dict[str, str], bwd: Dict[str, str]) -> bool:
475
+ if i == len(la):
476
+ return True
477
+ for j in range(len(lb)):
478
+ if used[j]:
479
+ continue
480
+ res = _lit_alpha_match(la[i], lb[j], fwd, bwd)
481
+ if res is None:
482
+ continue
483
+ used[j] = True
484
+ if backtrack(i + 1, res[0], res[1]):
485
+ return True
486
+ used[j] = False
487
+ return False
488
+
489
+ return backtrack(0, {}, {})
490
+
491
+
492
+ # ---------------------------------------------------------------------------
493
+ # Standardizing apart (deterministic disjoint renaming of two clauses)
494
+ # ---------------------------------------------------------------------------
495
+
496
+ def _clause_var_names(clause: FrozenSet[Node]) -> set:
497
+ """Return the set of variable names occurring anywhere in ``clause``."""
498
+ names = set()
499
+ for literal in clause:
500
+ for n in literal.walk():
501
+ if isinstance(n, Variable):
502
+ names.add(n.name)
503
+ return names
504
+
505
+
506
+ def _rename_term(node: Node, mapping: Dict[str, Variable]) -> Node:
507
+ """Return ``node`` with each Variable renamed via the one-level ``mapping``."""
508
+ if isinstance(node, Variable):
509
+ return mapping.get(node.name, node)
510
+ if isinstance(node, (Constant, Number)):
511
+ return node
512
+ if isinstance(node, Function):
513
+ return Function(node.name, [_rename_term(a, mapping) for a in node.args])
514
+ if isinstance(node, Atom):
515
+ return Atom(node.predicate, [_rename_term(a, mapping) for a in node.args])
516
+ raise TypeError(f"_rename_term: unsupported node type {type(node).__name__}")
517
+
518
+
519
+ def _rename_literal(literal: Node, mapping: Dict[str, Variable]) -> Node:
520
+ """Return ``literal`` with each Variable renamed via the one-level ``mapping``."""
521
+ if isinstance(literal, Not):
522
+ return Not(_rename_term(literal.formula, mapping))
523
+ return _rename_term(literal, mapping)
524
+
525
+
526
+ def _standardize_apart_maps(ca: FrozenSet[Node], cb: FrozenSet[Node]):
527
+ """As :func:`_standardize_apart`, but also returns the two rename maps
528
+ (original variable name -> fresh :class:`Variable`) used to build the
529
+ standardized-apart clauses.
530
+
531
+ Needed by ``"paramodulate"`` (not
532
+ ``"demodulate"``, which needs no standardizing apart — see its checker):
533
+ a derivation names ``eq_literal``/``target_literal`` exactly as they
534
+ appear in the ORIGINAL (pre-rename) parent clauses, so the checker must
535
+ replay the SAME rename to find their counterparts in the standardized
536
+ clauses it works with.
537
+ """
538
+ names_a = sorted(_clause_var_names(ca))
539
+ names_b = sorted(_clause_var_names(cb))
540
+ map_a = {name: Variable(f"_L{i}") for i, name in enumerate(names_a)}
541
+ map_b = {name: Variable(f"_R{i}") for i, name in enumerate(names_b)}
542
+ new_a = frozenset(_rename_literal(lit, map_a) for lit in ca)
543
+ new_b = frozenset(_rename_literal(lit, map_b) for lit in cb)
544
+ return new_a, new_b, map_a, map_b
545
+
546
+
547
+ def _standardize_apart(ca: FrozenSet[Node], cb: FrozenSet[Node]):
548
+ """Rename the variables of ``ca`` and ``cb`` to disjoint fresh names.
549
+
550
+ A soundness requirement before resolving two clauses: each clause's
551
+ variables are implicitly universally quantified *over that clause alone*,
552
+ so a name they happen to share (e.g. both using ``x``) names logically
553
+ unrelated individuals and must not be conflated. Renaming is deterministic
554
+ (sorted by original name, prefixed ``_L``/``_R``) so the check is
555
+ reproducible; the *choice* of fresh names is otherwise immaterial since
556
+ every candidate resolvent is compared up to variant, not up to `==`. A
557
+ thin wrapper over :func:`_standardize_apart_maps` that drops the maps,
558
+ for callers (``"resolve"``) that don't need to translate a
559
+ pre-standardization literal reference.
560
+ """
561
+ new_a, new_b, _map_a, _map_b = _standardize_apart_maps(ca, cb)
562
+ return new_a, new_b
563
+
564
+
565
+ # ---------------------------------------------------------------------------
566
+ # Literal polarity
567
+ # ---------------------------------------------------------------------------
568
+
569
+ def _lit_atom_polarity(literal: Node) -> Optional[Tuple[Atom, bool]]:
570
+ """Return ``(atom, is_positive)`` for a well-formed literal, or None.
571
+
572
+ A well-formed literal is a bare ``Atom`` (positive) or a ``Not`` wrapping
573
+ exactly one (negative); anything else (e.g. a doubly-negated or non-atomic
574
+ literal) is not a legal clause literal and is returned as None so callers
575
+ can skip it rather than crash — a malformed clause simply matches nothing.
576
+ """
577
+ if isinstance(literal, Not) and isinstance(literal.formula, Atom):
578
+ return literal.formula, False
579
+ if isinstance(literal, Atom):
580
+ return literal, True
581
+ return None
582
+
583
+
584
+ def _lit_key(literal: Node) -> str:
585
+ """Canonical sort key for a literal (its surface form with every constant written by its
586
+ bare name, ``key_text``): search order over a
587
+ clause's literals must be a function of CONTENT, not of frozenset iteration
588
+ order (hash-randomised across processes), so the checker's behaviour and
589
+ :func:`render_resolution_proof`'s output are reproducible run to run. The quotes that the
590
+ text of a formula puts around a constant play no part in the order."""
591
+ return key_text(literal)
592
+
593
+
594
+ # ---------------------------------------------------------------------------
595
+ # Equality: an INDEPENDENT reimplementation of the term order, one-sided term
596
+ # matching, and subterm positions/replacement used by the four equality
597
+ # rules below. Independence requirement 1 (see the module docstring) applies
598
+ # here exactly as it does to _unify/_apply: this module must reach the SAME
599
+ # verdicts as unicode_logic_kit.atp.resolution's own _term_gt/_match_term/
600
+ # _term_at/_replace_at on any given input, but shares no code with them.
601
+ # ---------------------------------------------------------------------------
602
+
603
+ def _is_equality_atom(atom: Atom) -> bool:
604
+ """True iff atom is a binary ``=`` atom."""
605
+ return atom.predicate == "=" and len(atom.args) == 2
606
+
607
+
608
+ def _term_weight(term: Node) -> int:
609
+ """Term order, part 1 — WEIGHT (term size): 1 for a leaf
610
+ (Variable/Constant/Number), 1 + the sum of its arguments' weights for a
611
+ Function. Must be the SAME definition as
612
+ :func:`unicode_logic_kit.atp.resolution._term_weight` (both sides need to
613
+ reach the same orientation verdict on any given equation), reimplemented
614
+ independently here."""
615
+ if isinstance(term, Function):
616
+ return 1 + sum(_term_weight(a) for a in term.args)
617
+ return 1
618
+
619
+
620
+ def _term_order_key(term: Node):
621
+ """Term order, part 2 — ties in weight broken lexicographically by the
622
+ term's rendering with every constant written by its bare name (``key_text``)."""
623
+ return (_term_weight(term), key_text(term))
624
+
625
+
626
+ def _term_gt(s: Node, t: Node) -> bool:
627
+ """True iff ``s`` is STRICTLY greater than ``t`` under the module's term
628
+ order (weight, then lexicographic tie-break). See
629
+ :mod:`unicode_logic_kit.atp.resolution`'s module docstring ("Equality"
630
+ section) for the full definition and the soundness argument for why
631
+ checking this on concretely SUBSTITUTED terms (as ``"demodulate"``'s
632
+ checker does) needs no substitution-compatibility closure."""
633
+ return _term_order_key(s) > _term_order_key(t)
634
+
635
+
636
+ def _match_term(pattern: Node, target: Node, subst: Dict[str, Node]) -> Optional[Dict[str, Node]]:
637
+ """One-sided structural match of ``pattern`` against ``target``: PATTERN's
638
+ variables may bind, TARGET is held fixed (its own variables, if any, are
639
+ opaque — never instantiated). A repeated pattern variable must match the
640
+ same target subterm everywhere (checked by structural equality against
641
+ the existing binding). Returns the extended substitution, or None on
642
+ failure. The result is applied with :func:`_apply_matcher`, never with
643
+ :func:`_apply`. Used only by ``"demodulate"``'s checker, to re-derive whether
644
+ the cited equation's stated side genuinely matches the target subterm —
645
+ an independent reimplementation of the same one-sided-matching idea
646
+ :mod:`unicode_logic_kit.atp.resolution` uses for both subsumption and its
647
+ own demodulation (:func:`~unicode_logic_kit.atp.resolution._match_term`),
648
+ sharing no code with it.
649
+ """
650
+ if isinstance(pattern, Variable):
651
+ if pattern.name in subst:
652
+ return subst if subst[pattern.name] == target else None
653
+ extended = dict(subst)
654
+ extended[pattern.name] = target
655
+ return extended
656
+ if isinstance(pattern, Function):
657
+ if (not isinstance(target, Function)
658
+ or pattern.name != target.name
659
+ or len(pattern.args) != len(target.args)):
660
+ return None
661
+ for p_arg, t_arg in zip(pattern.args, target.args):
662
+ subst = _match_term(p_arg, t_arg, subst)
663
+ if subst is None:
664
+ return None
665
+ return subst
666
+ return subst if pattern == target else None
667
+
668
+
669
+ def _term_at(node: Node, position: Tuple[int, ...]) -> Node:
670
+ """Walk ``position`` (a tuple of argument indices) from ``node`` down to
671
+ the addressed subterm. Raises ``IndexError``/``AttributeError``/
672
+ ``TypeError`` on an invalid path (out-of-range index, or descending past
673
+ a leaf) — callers catch these and turn them into a proper
674
+ ``"step N: ..."`` error, exactly the outcome a TAMPERED position (one of
675
+ the derivation manipulations these checkers must catch) should produce.
676
+ """
677
+ for i in position:
678
+ node = node.args[i]
679
+ return node
680
+
681
+
682
+ def _replace_at(node: Node, position: Tuple[int, ...], replacement: Node) -> Node:
683
+ """Return a copy of ``node`` (an :class:`Atom` or :class:`Function`) with
684
+ the subterm at ``position`` replaced by ``replacement``. Raises the same
685
+ exceptions as :func:`_term_at` on an invalid path."""
686
+ if not position:
687
+ return replacement
688
+ i, rest = position[0], position[1:]
689
+ new_args = list(node.args)
690
+ new_args[i] = _replace_at(node.args[i], rest, replacement)
691
+ if isinstance(node, Atom):
692
+ return Atom(node.predicate, new_args)
693
+ return Function(node.name, new_args)
694
+
695
+
696
+ # ---------------------------------------------------------------------------
697
+ # Per-rule step checkers: each returns None (licensed) or an error string
698
+ # (WITHOUT the "step N: " prefix, added by the caller).
699
+ # ---------------------------------------------------------------------------
700
+
701
+ def _check_input_step(step: ResolutionStep, inputs: Tuple[FrozenSet[Node], ...]) -> Optional[str]:
702
+ """``"input"``: the clause must be a variant of one of ``inputs``."""
703
+ for inp in inputs:
704
+ if _is_variant(step.clause, inp):
705
+ return None
706
+ return "'input' clause is not a variant (alpha-equivalent copy) of any derivation input"
707
+
708
+
709
+ def _check_resolve_step(step: ResolutionStep, ci: FrozenSet[Node], cj: FrozenSet[Node]) -> Optional[str]:
710
+ """``"resolve"``: some complementary-polarity literal pair's mgu must give the clause."""
711
+ ci2, cj2 = _standardize_apart(ci, cj)
712
+ found_complementary_pair = False
713
+ for lit1 in sorted(ci2, key=_lit_key):
714
+ parsed1 = _lit_atom_polarity(lit1)
715
+ if parsed1 is None:
716
+ continue
717
+ atom1, pos1 = parsed1
718
+ for lit2 in sorted(cj2, key=_lit_key):
719
+ parsed2 = _lit_atom_polarity(lit2)
720
+ if parsed2 is None:
721
+ continue
722
+ atom2, pos2 = parsed2
723
+ if pos1 == pos2:
724
+ continue
725
+ found_complementary_pair = True
726
+ sigma = _unify(atom1, atom2)
727
+ if sigma is None:
728
+ continue
729
+ resolvent = frozenset(
730
+ [_apply_literal(l, sigma) for l in ci2 if l != lit1]
731
+ + [_apply_literal(l, sigma) for l in cj2 if l != lit2]
732
+ )
733
+ if _is_variant(resolvent, step.clause):
734
+ return None
735
+ if not found_complementary_pair:
736
+ return "'resolve': the parent clauses contain no complementary-polarity literal pair"
737
+ return ("'resolve': no complementary-polarity literal pair's mgu produces "
738
+ "the stated resolvent clause")
739
+
740
+
741
+ def _check_factor_step(step: ResolutionStep, ci: FrozenSet[Node]) -> Optional[str]:
742
+ """``"factor"``: some same-polarity literal pair's mgu must give the clause."""
743
+ literals = sorted(ci, key=_lit_key)
744
+ for a in range(len(literals)):
745
+ parsed_a = _lit_atom_polarity(literals[a])
746
+ if parsed_a is None:
747
+ continue
748
+ atom_a, pos_a = parsed_a
749
+ for b in range(a + 1, len(literals)):
750
+ parsed_b = _lit_atom_polarity(literals[b])
751
+ if parsed_b is None:
752
+ continue
753
+ atom_b, pos_b = parsed_b
754
+ if pos_a != pos_b:
755
+ continue
756
+ sigma = _unify(atom_a, atom_b)
757
+ if sigma is None:
758
+ continue
759
+ factored = frozenset(_apply_literal(l, sigma) for l in ci)
760
+ if _is_variant(factored, step.clause):
761
+ return None
762
+ return ("'factor': no same-polarity literal pair's mgu produces the "
763
+ "stated factored clause")
764
+
765
+
766
+ def _direction_sides(eq_atom: Atom, direction: Optional[str]):
767
+ """Return ``(from, to)`` for a ``"lr"``/``"rl"`` direction over
768
+ ``eq_atom``'s two arguments, or ``None`` for any other value (including
769
+ a missing direction) -- shared by all four equality-rule checkers below."""
770
+ if direction == "lr":
771
+ return eq_atom.args[0], eq_atom.args[1]
772
+ if direction == "rl":
773
+ return eq_atom.args[1], eq_atom.args[0]
774
+ return None
775
+
776
+
777
+ def _check_paramodulate_step(step: ResolutionStep, ci: FrozenSet[Node], cj: FrozenSet[Node]) -> Optional[str]:
778
+ """``"paramodulate"``: parents ``(i, j)`` -- ``i`` supplies a positive
779
+ equality literal (``step.eq_literal``, oriented by ``step.direction``),
780
+ ``j`` supplies the target literal (``step.target_literal``) rewritten at
781
+ ``step.position``. Re-derived from scratch: the two parents are
782
+ standardized apart (as for ``"resolve"``); the declared eq_literal/
783
+ target_literal (named as they appear in the ORIGINAL parents) are
784
+ located in the standardized clauses via the SAME rename maps; the
785
+ subterm at ``step.position`` is extracted from the target atom and
786
+ unified (own ``_unify``) against the equation's stated side; the
787
+ resulting mgu, applied to both remainders plus the rewritten literal, is
788
+ compared up to variant against the stated clause -- resolve's recipe,
789
+ generalized to a rewrite instead of a literal cancellation.
790
+ """
791
+ if step.eq_literal is None or step.target_literal is None:
792
+ return "'paramodulate' requires eq_literal and target_literal"
793
+ ci2, cj2, map_i, map_j = _standardize_apart_maps(ci, cj)
794
+ eq_lit = _rename_literal(step.eq_literal, map_i)
795
+ if eq_lit not in ci2:
796
+ return "'paramodulate': eq_literal is not present in the cited equation parent"
797
+ parsed_eq = _lit_atom_polarity(eq_lit)
798
+ if parsed_eq is None or not parsed_eq[1]:
799
+ return "'paramodulate': eq_literal must be a POSITIVE equality literal"
800
+ eq_atom, _pos = parsed_eq
801
+ if not _is_equality_atom(eq_atom):
802
+ return "'paramodulate': eq_literal is not a binary equality atom"
803
+ sides = _direction_sides(eq_atom, step.direction)
804
+ if sides is None:
805
+ return "'paramodulate' requires direction 'lr' or 'rl'"
806
+ frm, to = sides
807
+
808
+ tgt_lit = _rename_literal(step.target_literal, map_j)
809
+ if tgt_lit not in cj2:
810
+ return "'paramodulate': target_literal is not present in the cited target parent"
811
+ parsed_tgt = _lit_atom_polarity(tgt_lit)
812
+ if parsed_tgt is None:
813
+ return "'paramodulate': target_literal is not a well-formed literal"
814
+ tgt_atom, tgt_is_positive = parsed_tgt
815
+
816
+ try:
817
+ subterm = _term_at(tgt_atom, step.position)
818
+ except (IndexError, TypeError, AttributeError):
819
+ return "'paramodulate': position does not address a subterm of target_literal"
820
+
821
+ sigma = _unify(frm, subterm)
822
+ if sigma is None:
823
+ return "'paramodulate': the equation's stated side does not unify with the subterm at position"
824
+
825
+ try:
826
+ rewritten_atom = _replace_at(tgt_atom, step.position, to)
827
+ except (IndexError, TypeError, AttributeError):
828
+ return "'paramodulate': position does not address a replaceable subterm"
829
+ rewritten_lit = rewritten_atom if tgt_is_positive else Not(rewritten_atom)
830
+
831
+ expected = frozenset(
832
+ [_apply_literal(l, sigma) for l in ci2 if l != eq_lit]
833
+ + [_apply_literal(l, sigma) for l in cj2 if l != tgt_lit]
834
+ + [_apply_literal(rewritten_lit, sigma)]
835
+ )
836
+ if _is_variant(expected, step.clause):
837
+ return None
838
+ return "'paramodulate': the recomputed rewrite is not a variant of the stated clause"
839
+
840
+
841
+ def _check_reflexivity_step(step: ResolutionStep, ci: FrozenSet[Node]) -> Optional[str]:
842
+ """``"reflexivity"``: drops a NEGATIVE equality literal ``¬(u=v)`` from
843
+ the cited clause once its own two sides unify (own ``_unify``,
844
+ occurs-checked); the resulting mgu is applied to what remains. Covers
845
+ reflexivity goals such as ``⊢ c=c``, whose negation clausifies to
846
+ ``{¬(c=c)}`` (unified by the trivial mgu ``{}``).
847
+ """
848
+ if step.eq_literal is None:
849
+ return "'reflexivity' requires eq_literal"
850
+ if step.eq_literal not in ci:
851
+ return "'reflexivity': eq_literal is not present in the cited parent"
852
+ parsed = _lit_atom_polarity(step.eq_literal)
853
+ if parsed is None or parsed[1]:
854
+ return "'reflexivity': eq_literal must be a NEGATIVE equality literal"
855
+ atom, _pos = parsed
856
+ if not _is_equality_atom(atom):
857
+ return "'reflexivity': eq_literal is not a binary equality atom"
858
+ u, v = atom.args
859
+ sigma = _unify(u, v)
860
+ if sigma is None:
861
+ return "'reflexivity': the two sides of eq_literal do not unify"
862
+ expected = frozenset(_apply_literal(l, sigma) for l in ci if l != step.eq_literal)
863
+ if _is_variant(expected, step.clause):
864
+ return None
865
+ return "'reflexivity': the recomputed clause is not a variant of the stated clause"
866
+
867
+
868
+ def _check_demodulate_step(step: ResolutionStep, ci: FrozenSet[Node], cj: FrozenSet[Node]) -> Optional[str]:
869
+ """``"demodulate"``: parents ``(target i, unit-equation j)``. ``cj`` must
870
+ be a UNIT clause (a single literal) whose sole literal is
871
+ ``step.eq_literal``, a positive equality literal used as a REWRITE RULE
872
+ via one-sided MATCHING (:func:`_match_term` -- not unification: the
873
+ rule's variables bind, the target clause's variables are held fixed)
874
+ against the subterm of ``step.target_literal`` in ``ci`` at
875
+ ``step.position``. The orientation is re-checked on the CONCRETE,
876
+ substituted terms with this module's own term order (:func:`_term_gt`):
877
+ only a genuinely simplifying rewrite (subterm ≻ rσ) is accepted -- a
878
+ tampered direction against the order is rejected here, independent of
879
+ what the derivation claims. No standardizing apart is needed: one-sided
880
+ matching never binds a target-side variable, so a coincidental variable
881
+ name shared between ``ci`` and ``cj`` cannot cause capture (the same
882
+ reasoning :mod:`unicode_logic_kit.atp.resolution` relies on for its own
883
+ demodulation and for clause subsumption, neither of which standardizes
884
+ apart either). The matcher is applied to the right-hand side in ONE
885
+ simultaneous step (:func:`_apply_matcher`): its images are terms of the
886
+ target, which may be spelled like the equation's variables, so they are
887
+ not looked up again.
888
+
889
+ The non-unit restriction on ``cj`` is a soundness requirement, not a
890
+ convenience: a clause ``{u≈v, Q}`` only asserts ``u≈v ∨ Q``, not the
891
+ unconditional fact ``u≈v`` -- rewriting with it while discarding ``Q``
892
+ would be unsound.
893
+ """
894
+ if step.eq_literal is None or step.target_literal is None:
895
+ return "'demodulate' requires eq_literal and target_literal"
896
+ if len(cj) != 1:
897
+ return "'demodulate': the equation parent must be a UNIT clause (a single positive equality literal)"
898
+ if step.eq_literal not in cj:
899
+ return "'demodulate': eq_literal is not present in the cited equation parent"
900
+ parsed_eq = _lit_atom_polarity(step.eq_literal)
901
+ if parsed_eq is None or not parsed_eq[1]:
902
+ return "'demodulate': eq_literal must be a POSITIVE equality literal"
903
+ eq_atom, _pos = parsed_eq
904
+ if not _is_equality_atom(eq_atom):
905
+ return "'demodulate': eq_literal is not a binary equality atom"
906
+ sides = _direction_sides(eq_atom, step.direction)
907
+ if sides is None:
908
+ return "'demodulate' requires direction 'lr' or 'rl'"
909
+ l, r = sides
910
+
911
+ if step.target_literal not in ci:
912
+ return "'demodulate': target_literal is not present in the cited target parent"
913
+ parsed_tgt = _lit_atom_polarity(step.target_literal)
914
+ if parsed_tgt is None:
915
+ return "'demodulate': target_literal is not a well-formed literal"
916
+ tgt_atom, tgt_is_positive = parsed_tgt
917
+
918
+ try:
919
+ subterm = _term_at(tgt_atom, step.position)
920
+ except (IndexError, TypeError, AttributeError):
921
+ return "'demodulate': position does not address a subterm of target_literal"
922
+
923
+ sigma = _match_term(l, subterm, {})
924
+ if sigma is None:
925
+ return "'demodulate': the equation's stated left side does not MATCH the subterm at position"
926
+
927
+ r_sigma = _apply_matcher(r, sigma)
928
+ if not _term_gt(subterm, r_sigma):
929
+ return "'demodulate': the orientation does not strictly decrease under the documented term order"
930
+
931
+ try:
932
+ rewritten_atom = _replace_at(tgt_atom, step.position, r_sigma)
933
+ except (IndexError, TypeError, AttributeError):
934
+ return "'demodulate': position does not address a replaceable subterm"
935
+ rewritten_lit = rewritten_atom if tgt_is_positive else Not(rewritten_atom)
936
+
937
+ rest = [l2 for l2 in ci if l2 != step.target_literal]
938
+ expected = frozenset(rest + [rewritten_lit])
939
+ if _is_variant(expected, step.clause):
940
+ return None
941
+ return "'demodulate': the recomputed clause is not a variant of the stated clause"
942
+
943
+
944
+ def _is_false_constant_literal(literal: Node) -> bool:
945
+ """True iff ``literal`` holds in no interpretation: the truth constant ``$false``
946
+ (or the atom ``⊥``) or the negation of the truth constant ``$true`` (or ``⊤``)
947
+ (re-derived here from the names, like the rest of this checker's vocabulary).
948
+ ``$true`` and ``¬$false`` are NOT false: nothing may be removed because of them."""
949
+ if isinstance(literal, Not):
950
+ inner = literal.formula
951
+ return isinstance(inner, Atom) and not inner.args and inner.predicate in ("$true", "⊤")
952
+ return isinstance(literal, Atom) and not literal.args and literal.predicate in ("$false", "⊥")
953
+
954
+
955
+ def _check_truth_constants_step(step: ResolutionStep, ci: FrozenSet[Node]) -> Optional[str]:
956
+ """``"truth_constants"``: from the cited clause, drop literals that are false in
957
+ every interpretation (``$false`` and ``¬$true``).
958
+
959
+ The stated clause must be a SUBSET of the cited one and every literal missing
960
+ from it must be such a constant, so ``C ∨ $false`` gives ``C`` and nothing
961
+ else is licensed: a literal of any other atom, ``$true`` and ``¬$false`` stay
962
+ (a clause that has those is simply true, which no step has to derive).
963
+ """
964
+ if not step.clause <= ci:
965
+ return "'truth_constants': the stated clause must be a subset of the cited clause"
966
+ for literal in ci - step.clause:
967
+ if not _is_false_constant_literal(literal):
968
+ return (f"'truth_constants': {literal.to_unicode_str()!r} is not a literal "
969
+ "that is false in every interpretation ($false or ¬$true)")
970
+ return None
971
+
972
+
973
+ _RULE_ARITY = {
974
+ "input": 0, "resolve": 2, "factor": 1,
975
+ "paramodulate": 2, "reflexivity": 1, "demodulate": 2,
976
+ "truth_constants": 1,
977
+ }
978
+ # NO "self_paramodulate": the shared-instance shortcut (equation and target
979
+ # from ONE instantiation, both dropped) is UNSOUND — for {u ≈ v, L[u]} it
980
+ # would certify {L[v]}, false in a model satisfying the clause via L[u] with
981
+ # u ≠ v. The generator no longer emits it (adversarial review, Tier 3), and
982
+ # an external derivation claiming it must be REJECTED, not re-derived.
983
+
984
+
985
+ # ---------------------------------------------------------------------------
986
+ # The checker
987
+ # ---------------------------------------------------------------------------
988
+
989
+ def verify_resolution_proof(derivation: "ResolutionDerivation") -> ResolutionCheckResult:
990
+ """Check ``derivation`` and return a :class:`ResolutionCheckResult`.
991
+
992
+ Walks ``derivation.steps`` in order, verifying each step's structural
993
+ shape (index == position, parents strictly earlier, parent count matches
994
+ the rule) and then its rule-specific licensing (see the module docstring).
995
+ Stops at the first failure and reports it as ``"step N: reason"``.
996
+ ``refuted`` tracks, independently of ``ok``, whether some step verified
997
+ *before* any failure derives the empty clause.
998
+ """
999
+ clause_by_index: Dict[int, FrozenSet[Node]] = {}
1000
+ refuted = False
1001
+
1002
+ for pos, step in enumerate(derivation.steps):
1003
+ expected_index = pos + 1
1004
+ if not isinstance(step, ResolutionStep):
1005
+ return ResolutionCheckResult(
1006
+ False, None,
1007
+ f"step {expected_index}: expected a ResolutionStep, got "
1008
+ f"{type(step).__name__}",
1009
+ refuted)
1010
+ if step.index != expected_index:
1011
+ return ResolutionCheckResult(
1012
+ False, step.index,
1013
+ f"step {step.index}: index must equal its position in the "
1014
+ f"derivation (expected {expected_index})",
1015
+ refuted)
1016
+
1017
+ arity = _RULE_ARITY.get(step.rule)
1018
+ if arity is None:
1019
+ expected = ", ".join(repr(r) for r in _RULE_ARITY)
1020
+ return ResolutionCheckResult(
1021
+ False, step.index,
1022
+ f"step {step.index}: unknown rule {step.rule!r} (expected "
1023
+ f"one of {expected})",
1024
+ refuted)
1025
+ if len(step.parents) != arity:
1026
+ return ResolutionCheckResult(
1027
+ False, step.index,
1028
+ f"step {step.index}: rule {step.rule!r} takes {arity} "
1029
+ f"parent(s), got {len(step.parents)}",
1030
+ refuted)
1031
+
1032
+ for p in step.parents:
1033
+ if p not in clause_by_index or p >= step.index:
1034
+ return ResolutionCheckResult(
1035
+ False, step.index,
1036
+ f"step {step.index}: parent {p} does not refer to a "
1037
+ f"strictly earlier verified step (parents must cite "
1038
+ f"smaller indices)",
1039
+ refuted)
1040
+
1041
+ if step.rule == "input":
1042
+ err = _check_input_step(step, derivation.inputs)
1043
+ elif step.rule == "resolve":
1044
+ i, j = step.parents
1045
+ err = _check_resolve_step(step, clause_by_index[i], clause_by_index[j])
1046
+ elif step.rule == "factor":
1047
+ (i,) = step.parents
1048
+ err = _check_factor_step(step, clause_by_index[i])
1049
+ elif step.rule == "paramodulate":
1050
+ i, j = step.parents
1051
+ err = _check_paramodulate_step(step, clause_by_index[i], clause_by_index[j])
1052
+ elif step.rule == "reflexivity":
1053
+ (i,) = step.parents
1054
+ err = _check_reflexivity_step(step, clause_by_index[i])
1055
+ elif step.rule == "truth_constants":
1056
+ (i,) = step.parents
1057
+ err = _check_truth_constants_step(step, clause_by_index[i])
1058
+ else: # "demodulate"
1059
+ i, j = step.parents
1060
+ err = _check_demodulate_step(step, clause_by_index[i], clause_by_index[j])
1061
+
1062
+ if err is not None:
1063
+ return ResolutionCheckResult(False, step.index, f"step {step.index}: {err}", refuted)
1064
+
1065
+ clause_by_index[step.index] = step.clause
1066
+ if not step.clause:
1067
+ refuted = True
1068
+
1069
+ return ResolutionCheckResult(True, None, None, refuted)
1070
+
1071
+
1072
+ def check_resolution_proof(derivation: "ResolutionDerivation") -> bool:
1073
+ """Return True iff ``derivation`` is a valid resolution derivation (sound).
1074
+
1075
+ A thin bool wrapper over :func:`verify_resolution_proof`; use that for the
1076
+ failing step index and reason, or to read ``refuted``.
1077
+ """
1078
+ return verify_resolution_proof(derivation).ok
1079
+
1080
+
1081
+ # ---------------------------------------------------------------------------
1082
+ # Rendering
1083
+ # ---------------------------------------------------------------------------
1084
+
1085
+ def _render_clause(clause: FrozenSet[Node]) -> str:
1086
+ """Render a clause as its literals joined by ``∨``, or ``□`` if empty.
1087
+
1088
+ Literals are sorted by their Unicode rendering for a deterministic,
1089
+ frozenset-iteration-order-independent string.
1090
+ """
1091
+ if not clause:
1092
+ return "□"
1093
+ return " ∨ ".join(lit.to_unicode_str() for lit in sorted(clause, key=_lit_key))
1094
+
1095
+
1096
+ def render_resolution_proof(derivation: "ResolutionDerivation") -> str:
1097
+ """Render ``derivation`` as one numbered line per step.
1098
+
1099
+ Each line is ``N. <clause> [rule parents]`` — the number, the clause, and
1100
+ the justification in brackets. (The format is spelled out here rather than
1101
+ in the summary line above because autosummary cuts that line at the first
1102
+ sentence end, and a ``.`` inside an inline literal reads as one.)
1103
+
1104
+ The empty clause renders as ``□``. Parent indices are comma-joined in
1105
+ citation order; a rule with no parents (``"input"``) renders with no
1106
+ trailing citation list, e.g. ``"1. P(a) [input]"``.
1107
+ """
1108
+ lines = []
1109
+ for step in derivation.steps:
1110
+ bracket = step.rule
1111
+ if step.parents:
1112
+ bracket += " " + ",".join(str(p) for p in step.parents)
1113
+ lines.append(f"{step.index}. {_render_clause(step.clause)} [{bracket}]")
1114
+ return "\n".join(lines)