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,1547 @@
1
+ """Fitch-style natural deduction: proof objects and a sound proof *checker*.
2
+
3
+ Where :mod:`unicode_logic_kit.atp.resolution` answers *"is this entailment true?"*
4
+ with a single bool, this module checks a *human-written derivation*: a Fitch-style
5
+ natural-deduction proof with nested subproofs (hypothetical reasoning), per-line
6
+ justifications, and discharge rules. It is *sound* — ``check_proof`` returns True
7
+ only when every line genuinely follows from the cited material by the stated rule
8
+ and the proof's open assumptions really do entail its conclusion.
9
+
10
+ Two checking regimes share one proof representation:
11
+
12
+ - **Classical FOL / MSFOL** (``logic="fol"`` / ``"msfol"``) is checked by a
13
+ *syntactic* rule table: each line's rule (``"∧I"``, ``"→E"``, ``"∀E"``, …) is
14
+ verified by structural pattern-match over the frozen AST node classes, with
15
+ discharge, citation-accessibility (the "Fitch bar"), and quantifier
16
+ eigenvariable side-conditions enforced explicitly. This is the textbook
17
+ Gentzen-NK calculus in Fitch/Jaśkowski boxed-subproof notation.
18
+
19
+ - **Non-classical** logics (``"K3"``, ``"LP"``) are checked *semantically*: the
20
+ same proof structure (scope, discharge, accessibility) is verified, but each
21
+ derived line is certified by the matching decision oracle — the open
22
+ assumptions in scope must entail the line under that logic's consequence
23
+ relation. This is sound by construction and, unlike a hand-rolled rule table,
24
+ cannot accidentally license a classically-valid-but-non-classically-invalid
25
+ step (LP rejects modus ponens and explosion; K3 has no logical truths over
26
+ letters alone — only a formula built from the truth constants ``⊤`` / ``⊥``
27
+ can be one).
28
+
29
+ Soundness design (the non-obvious parts):
30
+
31
+ - The *open-assumption set* — the undischarged hypotheses in scope at a line,
32
+ including the proof's premises — is threaded to every rule. Discharge rules
33
+ (``→I``/``¬I``/``∨E``/``∀I``/``∃E``) are correct *relative to those
34
+ assumptions*, not to a local entailment between the cited lines, so that is
35
+ what the checker and the cross-check oracle see.
36
+ - ``check_proof`` certifies a *sequent*: the proof's premises ⊢ its conclusion
37
+ (the last top-level line). ``verify_proof`` returns that sequent plus the first
38
+ failing line and reason.
39
+ - ⊥ is a first-class logical constant :data:`FALSUM`, NOT an ordinary atom, so
40
+ ``⊥E`` (ex falso) and ``¬I`` are validated by the checker's own semantics and
41
+ desugared to a genuine contradiction for the Z3 cross-check.
42
+ - Citation accessibility forbids reaching into an already-closed sibling subproof
43
+ (a classic natural-deduction unsoundness): a line may cite an earlier line only
44
+ if every subproof enclosing the cited line also encloses the citing line, and a
45
+ whole subproof only once it has closed at an enclosing-or-equal level.
46
+
47
+ Public API: :class:`Justification`, :class:`Line`, :class:`Subproof`,
48
+ :class:`Proof`, :class:`ProofResult`, the authoring helpers :func:`premise`,
49
+ :func:`assume`, :func:`line`, :func:`flag` (the ∀I eigenvariable-box head), the
50
+ constant :data:`FALSUM`, the checkers :func:`check_proof` / :func:`verify_proof`,
51
+ and the renderers :func:`render_fitch` / :func:`render_latex_fitch` /
52
+ :meth:`Proof.to_html`.
53
+ """
54
+
55
+ from dataclasses import dataclass, replace
56
+ from functools import reduce
57
+ from typing import Callable, Dict, List, Optional, Tuple, Union
58
+
59
+ from ..fol.nodes import (
60
+ Node, Atom, Not, And, Or, Implies, Iff, Quantifier,
61
+ Variable, Constant, Number, Function,
62
+ SortedQuantifier, SortedConstant, LambdaVar,
63
+ Count, Cardinality, SortedCount, SortedCardinality, SlashedExists,
64
+ Box, Diamond, Knows, Believes,
65
+ EverybodyKnows, DistributedKnowledge, CommonKnowledge,
66
+ Always, Eventually, Next, Until,
67
+ Historically, Once, Previous, Since,
68
+ Obligatory, Permitted,
69
+ free_variables,
70
+ )
71
+ from ..fol.frames import (
72
+ FRAMES as _SHARED_FRAMES, UnsupportedFrameCondition,
73
+ resolve_frame, unguarded_frame_axiom,
74
+ )
75
+ from ..fol._msfl_nodes import _rename, _fresh_binder_name, key_text, subst_slash_set
76
+ from ._html import esc_html, html_page
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Falsum (⊥) as a logical constant
81
+ # ---------------------------------------------------------------------------
82
+ #
83
+ # ⊥ is represented by the reserved nullary atom Atom("⊥", ()) but is NOT treated
84
+ # as an ordinary predicate anywhere in the checker: is_falsum() recognises it, the
85
+ # ⊥I/⊥E/¬I/RAA rules give it its logical meaning, and _desugar_falsum() rewrites it
86
+ # to a genuine contradiction before the formula is handed to the Z3 cross-check
87
+ # (where a bare uninterpreted atom "⊥" would be satisfiable and silently break the
88
+ # soundness oracle). Using a reserved atom rather than a new Node subclass keeps it
89
+ # out of the renderers / parser / NODE_CLASSES — it only ever means "false" here.
90
+
91
+ _FALSUM_NAME = "⊥"
92
+ FALSUM: Atom = Atom(_FALSUM_NAME, ())
93
+
94
+ # The truth constants ``$true`` / ``$false`` (the atoms the unicode glyphs ``⊤`` /
95
+ # ``⊥`` and the TPTP reader produce). ``$false`` is a falsum exactly like ``⊥``
96
+ # (:func:`is_falsum`); ``$true`` has its own rule, ``⊤I``.
97
+ _TRUE_CONSTANT = "$true"
98
+ _FALSE_CONSTANT = "$false"
99
+ _TRUE_GLYPH = "⊤"
100
+
101
+ # A reserved propositional letter used only to desugar ⊥ into (p ∧ ¬p) for the
102
+ # classical oracle. Chosen so it cannot collide with a user predicate (uppercase
103
+ # rule requires PREDICATE start with A–Z; this name is not parseable, so it can
104
+ # only be introduced here).
105
+ _BOT_SENTINEL: Atom = Atom("⊥sentinel", ())
106
+ _BOT_CONTRADICTION: And = And(_BOT_SENTINEL, Not(_BOT_SENTINEL))
107
+
108
+
109
+ def is_falsum(node: Node) -> bool:
110
+ """Return True iff ``node`` is a falsum: the reserved atom ``⊥`` or the truth
111
+ constant ``$false`` (what the unicode glyph ``⊥`` parses to). Both are the
112
+ genuine constant, never a letter."""
113
+ return (isinstance(node, Atom) and not node.args
114
+ and node.predicate in (_FALSUM_NAME, _FALSE_CONSTANT))
115
+
116
+
117
+ def _desugar_falsum(node: Node) -> Node:
118
+ """Rewrite every ⊥ occurrence to the contradiction (p ∧ ¬p).
119
+
120
+ Used only to build the classical Z3 cross-check formula: ⊥ has no meaning to
121
+ ``to_z3`` (it would become a satisfiable uninterpreted atom), so it is replaced
122
+ by a genuinely unsatisfiable sentence that preserves the intended semantics of
123
+ the ⊥-rules. The input is not mutated.
124
+ """
125
+ if is_falsum(node):
126
+ return _BOT_CONTRADICTION
127
+ return node.map_children(_desugar_falsum)
128
+
129
+
130
+ def _classical_valid(assumptions: List[Node], conclusion: Node) -> bool:
131
+ """Return True iff ``assumptions`` classically entail ``conclusion`` (via Z3).
132
+
133
+ Builds (a₁ ∧ … ∧ aₙ) → conclusion, desugars ⊥ to a genuine contradiction, and
134
+ asks Z3 (which interprets ``=`` natively, unlike resolution). This is the
135
+ soundness oracle for the equality rules and the test cross-checks.
136
+ """
137
+ from .z3_models import is_valid
138
+ big = Implies(reduce(And, assumptions), conclusion) if assumptions else conclusion
139
+ return is_valid(_desugar_falsum(big))
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Proof objects
144
+ # ---------------------------------------------------------------------------
145
+
146
+ @dataclass(frozen=True)
147
+ class Justification:
148
+ """How a proof line was obtained: a rule tag plus the lines/subproofs it cites.
149
+
150
+ ``rule`` is the canonical rule tag (e.g. ``"∧I"``, ``"→E"``, ``"Reit"``,
151
+ ``"Premise"``, ``"Assume"``). ``cites`` is a tuple whose elements are either an
152
+ ``int`` (a cited line number) or a ``(start, end)`` ``tuple`` (a cited subproof
153
+ span, used by the discharge rules). ``extra`` carries rule-specific data, such
154
+ as the instantiation term for ``∀E`` or the witness term for ``∃I``. The
155
+ dataclass is frozen and hashable; all containers are coerced to tuples.
156
+ """
157
+
158
+ rule: str
159
+ cites: Tuple[Union[int, Tuple[int, int]], ...] = ()
160
+ extra: Tuple = ()
161
+
162
+ def __post_init__(self):
163
+ """Coerce ``cites``/``extra`` to tuples so the justification stays hashable."""
164
+ object.__setattr__(self, "cites", tuple(
165
+ tuple(c) if isinstance(c, (list, tuple)) else c for c in self.cites
166
+ ))
167
+ object.__setattr__(self, "extra", tuple(self.extra))
168
+
169
+ def to_dict(self) -> dict:
170
+ """Serialise to a JSON-compatible dict (terms in ``extra`` via Node.to_dict)."""
171
+ return {
172
+ "rule": self.rule,
173
+ "cites": [list(c) if isinstance(c, tuple) else c for c in self.cites],
174
+ "extra": [e.to_dict() if isinstance(e, Node) else e for e in self.extra],
175
+ }
176
+
177
+ @staticmethod
178
+ def from_dict(d: dict) -> "Justification":
179
+ """Deserialise a Justification produced by :meth:`to_dict`."""
180
+ cites = tuple(tuple(c) if isinstance(c, list) else c for c in d.get("cites", ()))
181
+ extra = tuple(
182
+ Node.from_dict(e) if isinstance(e, dict) and "_type" in e else e
183
+ for e in d.get("extra", ())
184
+ )
185
+ return Justification(d["rule"], cites, extra)
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Line:
190
+ """One numbered proof line: a formula and the justification that produced it."""
191
+
192
+ number: int
193
+ formula: Node
194
+ justification: Justification
195
+
196
+ def to_dict(self) -> dict:
197
+ """Serialise to a JSON-compatible dict."""
198
+ return {
199
+ "_kind": "line",
200
+ "number": self.number,
201
+ "formula": self.formula.to_dict(),
202
+ "justification": self.justification.to_dict(),
203
+ }
204
+
205
+ @staticmethod
206
+ def from_dict(d: dict) -> "Line":
207
+ """Deserialise a Line produced by :meth:`to_dict`."""
208
+ return Line(d["number"], Node.from_dict(d["formula"]),
209
+ Justification.from_dict(d["justification"]))
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class Subproof:
214
+ """A boxed subproof: an optional flagged assumption and a body of further steps.
215
+
216
+ ``assumption`` is the boxed hypothesis (a :class:`Line` with rule ``"Assume"``)
217
+ or ``None`` for a pure eigenvariable-introduction box (``∀I``). ``flag`` is the
218
+ eigenvariable introduced by the box (``∀I`` / ``∃E``) or ``None``. ``body`` is
219
+ the ordered tuple of inner steps (lines and nested subproofs). ``kind`` tags
220
+ the box (``"flagged"`` for ordinary hypothetical subproofs; reserved for future
221
+ strict/modal boxes). The subproof's *conclusion* — what discharge rules read —
222
+ is the formula of its last body line.
223
+ """
224
+
225
+ assumption: Optional[Line]
226
+ body: Tuple[Union[Line, "Subproof"], ...] = ()
227
+ kind: str = "flagged"
228
+ flag: Optional[Variable] = None
229
+
230
+ def __post_init__(self):
231
+ """Coerce ``body`` to a tuple so the subproof stays hashable."""
232
+ object.__setattr__(self, "body", tuple(self.body))
233
+
234
+ def to_dict(self) -> dict:
235
+ """Serialise to a JSON-compatible dict."""
236
+ return {
237
+ "_kind": "subproof",
238
+ "assumption": self.assumption.to_dict() if self.assumption is not None else None,
239
+ "body": [s.to_dict() for s in self.body],
240
+ "kind": self.kind,
241
+ "flag": self.flag.to_dict() if self.flag is not None else None,
242
+ }
243
+
244
+ @staticmethod
245
+ def from_dict(d: dict) -> "Subproof":
246
+ """Deserialise a Subproof produced by :meth:`to_dict`."""
247
+ assumption = Line.from_dict(d["assumption"]) if d.get("assumption") else None
248
+ body = tuple(_step_from_dict(s) for s in d.get("body", ()))
249
+ flag = Node.from_dict(d["flag"]) if d.get("flag") else None
250
+ return Subproof(assumption, body, d.get("kind", "flagged"), flag)
251
+
252
+
253
+ def _step_from_dict(d: dict) -> Union[Line, Subproof]:
254
+ """Deserialise a proof step (line or subproof) from its dict tag."""
255
+ return Subproof.from_dict(d) if d.get("_kind") == "subproof" else Line.from_dict(d)
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class Proof:
260
+ """A complete Fitch proof: premises, derivation steps, and the target logic.
261
+
262
+ ``premises`` are the top-level undischarged assumptions (lines with rule
263
+ ``"Premise"``). ``steps`` are the derivation (lines and subproofs). The proof's
264
+ *conclusion* is the formula of the last top-level :class:`Line`. ``logic``
265
+ selects the checking regime (``"fol"``/``"msfol"`` syntactic; ``"K3"``/``"LP"``
266
+ semantic).
267
+ """
268
+
269
+ premises: Tuple[Line, ...] = ()
270
+ steps: Tuple[Union[Line, Subproof], ...] = ()
271
+ logic: str = "fol"
272
+
273
+ def __post_init__(self):
274
+ """Coerce ``premises``/``steps`` to tuples so the proof stays hashable."""
275
+ object.__setattr__(self, "premises", tuple(self.premises))
276
+ object.__setattr__(self, "steps", tuple(self.steps))
277
+
278
+ def to_dict(self) -> dict:
279
+ """Serialise the whole proof to a JSON-compatible dict."""
280
+ return {
281
+ "premises": [p.to_dict() for p in self.premises],
282
+ "steps": [s.to_dict() for s in self.steps],
283
+ "logic": self.logic,
284
+ }
285
+
286
+ @staticmethod
287
+ def from_dict(d: dict) -> "Proof":
288
+ """Deserialise a Proof produced by :meth:`to_dict`."""
289
+ return Proof(
290
+ tuple(Line.from_dict(p) for p in d.get("premises", ())),
291
+ tuple(_step_from_dict(s) for s in d.get("steps", ())),
292
+ d.get("logic", "fol"),
293
+ )
294
+
295
+ def to_fitch(self, ascii: bool = False) -> str:
296
+ """Render the proof in Fitch notation (Unicode scope bars / ASCII fallback)."""
297
+ return render_fitch(self, ascii=ascii)
298
+
299
+ def to_latex_fitch(self) -> str:
300
+ """Render the proof as a LaTeX Fitch derivation (array form, no extra package)."""
301
+ return render_latex_fitch(self)
302
+
303
+ def to_html(self, title: str = "Fitch proof") -> str:
304
+ """Render as a self-contained, theme-aware HTML page.
305
+
306
+ Same idiom as :meth:`unicode_logic_kit.fol.derivation.CCGDerivation.to_html`:
307
+ a numbered line gutter, one nested bar per open subproof (the Fitch
308
+ scope bars), and a horizontal rule under the premises and under each
309
+ assumption, mirroring :func:`render_fitch`'s layout in markup.
310
+ """
311
+ return html_page(title, _html_fitch(self), _FITCH_CSS)
312
+
313
+
314
+ @dataclass(frozen=True)
315
+ class ProofResult:
316
+ """The outcome of checking a proof.
317
+
318
+ ``ok`` is True iff the proof is well-formed and every line is licensed.
319
+ ``premises`` / ``conclusion`` report the certified sequent (premises ⊢
320
+ conclusion). On failure, ``error_line`` and ``error`` name the first offending
321
+ line and the reason.
322
+ """
323
+
324
+ ok: bool
325
+ premises: Tuple[Node, ...]
326
+ conclusion: Optional[Node]
327
+ error_line: Optional[int]
328
+ error: Optional[str]
329
+ logic: str
330
+
331
+ def __bool__(self) -> bool:
332
+ """A ProofResult is truthy iff the proof checked out."""
333
+ return self.ok
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Authoring helpers
338
+ # ---------------------------------------------------------------------------
339
+
340
+ def premise(number: int, formula: Node) -> Line:
341
+ """Build a premise line (rule ``"Premise"``)."""
342
+ return Line(number, formula, Justification("Premise"))
343
+
344
+
345
+ def assume(number: int, formula: Node) -> Line:
346
+ """Build an assumption line (rule ``"Assume"``) for the head of a subproof."""
347
+ return Line(number, formula, Justification("Assume"))
348
+
349
+
350
+ def line(number: int, formula: Node, rule: str, *cites, extra=()) -> Line:
351
+ """Build a derived line citing ``*cites`` (ints for lines, ``(s, e)`` for subproofs)."""
352
+ return Line(number, formula, Justification(rule, tuple(cites), tuple(extra)))
353
+
354
+
355
+ def flag(number: int, var: "Variable") -> Line:
356
+ """Build a boxed-variable head line (rule ``"Flag"``) for a ∀I subproof.
357
+
358
+ The line's formula is the eigenvariable itself; it is a scope marker, not a
359
+ logical hypothesis. Pair it with ``Subproof(assumption=flag(n, e), …, flag=e)``.
360
+ """
361
+ return Line(number, var, Justification("Flag"))
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # Indexing: flatten the proof, validate numbering, compute scope/visibility
366
+ # ---------------------------------------------------------------------------
367
+
368
+ _RESERVED_RULES = frozenset({"Premise", "Assume", "Flag"})
369
+
370
+
371
+ class _Rec:
372
+ """A flattened line record (mutable: ``pos`` is filled during the linear pass)."""
373
+
374
+ __slots__ = ("number", "formula", "just", "scope", "role", "pos")
375
+
376
+ def __init__(self, number, formula, just, scope, role):
377
+ self.number = number
378
+ self.formula = formula
379
+ self.just = just
380
+ self.scope = scope # tuple of enclosing subproof ids (outer→inner)
381
+ self.role = role # 'premise' | 'assume' | 'flag' | 'derived'
382
+ self.pos = -1 # linear position, set by _index
383
+
384
+
385
+ class _SubRec:
386
+ """A flattened subproof record used to resolve span citations and discharge."""
387
+
388
+ __slots__ = ("sid", "scope", "start", "end", "assumption", "flag",
389
+ "conclusion", "kind")
390
+
391
+ def __init__(self, sid, scope, start, end, assumption, flag, conclusion, kind):
392
+ self.sid = sid
393
+ self.scope = scope # container scope (NOT including this subproof)
394
+ self.start = start # first line number in the box
395
+ self.end = end # last line number in the box
396
+ self.assumption = assumption # assumed formula or None
397
+ self.flag = flag # eigenvariable Variable or None
398
+ self.conclusion = conclusion # last body line's formula or None
399
+ self.kind = kind
400
+
401
+
402
+ class _IndexError(Exception):
403
+ """Raised for a structurally malformed proof (bad numbering, role clash)."""
404
+
405
+ def __init__(self, number: Optional[int], message: str):
406
+ super().__init__(message)
407
+ self.number = number
408
+ self.message = message
409
+
410
+
411
+ def _canon_q(node: Node) -> Node:
412
+ """Canonicalise quantifier spellings ('forall'/'exists' → '∀'/'∃') throughout.
413
+
414
+ ``Quantifier`` is a frozen dataclass keyed on the raw ``type`` string, so
415
+ ``Quantifier('forall', …) != Quantifier('∀', …)``. Normalising every formula
416
+ entering the checker to the glyph spelling keeps the structural ``==`` the rules
417
+ rely on from falsely rejecting a valid proof that mixed the two spellings.
418
+ """
419
+ node = node.map_children(_canon_q)
420
+ if isinstance(node, Quantifier) and node.type in ("forall", "exists"):
421
+ return Quantifier("∀" if node.type == "forall" else "∃", node.variable, node.formula)
422
+ if isinstance(node, SortedQuantifier) and node.type in ("forall", "exists"):
423
+ return SortedQuantifier("∀" if node.type == "forall" else "∃",
424
+ node.variable, node.sort, node.formula)
425
+ return node
426
+
427
+
428
+ def _index(proof: "Proof"):
429
+ """Flatten ``proof`` into ordered line records + subproof records.
430
+
431
+ Validates that line numbers are exactly 1..N in linear order (premises first,
432
+ then each step in order, an assumption before its body). Returns
433
+ ``(order, lines_by_number, subs_by_span, sub_by_sid)``. Raises
434
+ :class:`_IndexError` on a structural defect.
435
+ """
436
+ order: List[_Rec] = []
437
+ lines: Dict[int, _Rec] = {}
438
+ subs_by_span: Dict[Tuple[int, int], _SubRec] = {}
439
+ sub_by_sid: Dict[int, _SubRec] = {}
440
+ sid_counter = [0]
441
+
442
+ def add(rec: _Rec):
443
+ if rec.number in lines:
444
+ raise _IndexError(rec.number, f"duplicate line number {rec.number}")
445
+ rec.pos = len(order)
446
+ order.append(rec)
447
+ lines[rec.number] = rec
448
+
449
+ for pl in proof.premises:
450
+ if not isinstance(pl, Line):
451
+ raise _IndexError(None, "proof.premises must contain Line objects")
452
+ add(_Rec(pl.number, _canon_q(pl.formula), pl.justification, (), "premise"))
453
+
454
+ def walk(steps, scope) -> List[int]:
455
+ nums: List[int] = []
456
+ for st in steps:
457
+ if isinstance(st, Line):
458
+ add(_Rec(st.number, _canon_q(st.formula), st.justification, scope, "derived"))
459
+ nums.append(st.number)
460
+ elif isinstance(st, Subproof):
461
+ sid = sid_counter[0]
462
+ sid_counter[0] += 1
463
+ inner = scope + (sid,)
464
+ sp_nums: List[int] = []
465
+ assum_formula = None
466
+ if st.assumption is not None:
467
+ a = st.assumption
468
+ if not isinstance(a, Line):
469
+ raise _IndexError(None,
470
+ "a subproof assumption must be a Line "
471
+ "('Assume' hypothesis or 'Flag' boxed-variable head)")
472
+ # A 'Flag' head (rule == "Flag") introduces only the boxed
473
+ # eigenvariable — its formula is NOT a logical hypothesis (∀I).
474
+ # An 'Assume' head IS a hypothesis even when a flag is also set
475
+ # (∃E assumes φ(e) and flags e).
476
+ is_flag = a.justification.rule == "Flag"
477
+ role = "flag" if is_flag else "assume"
478
+ add(_Rec(a.number, _canon_q(a.formula), a.justification, inner, role))
479
+ sp_nums.append(a.number)
480
+ if not is_flag:
481
+ assum_formula = _canon_q(a.formula)
482
+ else:
483
+ raise _IndexError(None,
484
+ "a subproof must carry an assumption Line (an 'Assume' "
485
+ "hypothesis, or a 'Flag' boxed-variable head for ∀I)")
486
+ body_nums = walk(st.body, inner)
487
+ sp_nums.extend(body_nums)
488
+ if not sp_nums:
489
+ raise _IndexError(None, "a subproof must contain at least one line")
490
+ start, end = min(sp_nums), max(sp_nums)
491
+ conclusion = (_canon_q(st.body[-1].formula)
492
+ if st.body and isinstance(st.body[-1], Line) else None)
493
+ rec = _SubRec(sid, scope, start, end, assum_formula, st.flag,
494
+ conclusion, st.kind)
495
+ subs_by_span[(start, end)] = rec
496
+ sub_by_sid[sid] = rec
497
+ nums.extend(sp_nums)
498
+ else:
499
+ raise _IndexError(None, f"unexpected step {type(st).__name__}")
500
+ return nums
501
+
502
+ walk(proof.steps, ())
503
+
504
+ # Numbering must be exactly 1..N in linear order.
505
+ for expected, rec in enumerate(order, start=1):
506
+ if rec.number != expected:
507
+ raise _IndexError(rec.number,
508
+ f"line numbers must be 1..N in order; expected {expected}, "
509
+ f"got {rec.number}")
510
+
511
+ return order, lines, subs_by_span, sub_by_sid
512
+
513
+
514
+ def _is_prefix(a: tuple, b: tuple) -> bool:
515
+ """Return True iff tuple ``a`` is a (not necessarily proper) prefix of ``b``."""
516
+ return len(a) <= len(b) and tuple(a) == tuple(b[:len(a)])
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Resolved citations
521
+ # ---------------------------------------------------------------------------
522
+
523
+ @dataclass
524
+ class _Ref:
525
+ """A resolved, accessibility-checked citation handed to a rule checker."""
526
+
527
+ kind: str # 'line' | 'subproof'
528
+ formula: Optional[Node] = None # line: the cited formula
529
+ assumption: Optional[Node] = None # subproof: assumed formula
530
+ conclusion: Optional[Node] = None # subproof: last body line formula
531
+ flag: Optional[Variable] = None # subproof: eigenvariable
532
+ sp_kind: Optional[str] = None # subproof: kind tag
533
+
534
+
535
+ def _resolve(tok, citing: _Rec, lines, subs_by_span):
536
+ """Resolve one citation token against the Fitch accessibility relation.
537
+
538
+ Returns ``(ref, None)`` on success or ``(None, reason)`` if the cited line /
539
+ subproof does not exist, lies inside an already-closed box, or is not above the
540
+ citing line.
541
+ """
542
+ if isinstance(tok, int):
543
+ rec = lines.get(tok)
544
+ if rec is None:
545
+ return None, f"cites line {tok}, which does not exist"
546
+ if not _is_prefix(rec.scope, citing.scope):
547
+ return None, (f"line {tok} is not in scope at line {citing.number} "
548
+ f"(it lies inside a closed subproof)")
549
+ if rec.pos >= citing.pos:
550
+ return None, f"line {tok} is not above line {citing.number}"
551
+ return _Ref("line", formula=rec.formula), None
552
+
553
+ if isinstance(tok, tuple) and len(tok) == 2:
554
+ span = (tok[0], tok[1])
555
+ sp = subs_by_span.get(span)
556
+ if sp is None:
557
+ return None, f"cites subproof {span[0]}–{span[1]}, which does not exist"
558
+ if not _is_prefix(sp.scope, citing.scope):
559
+ return None, (f"subproof {span[0]}–{span[1]} is not in scope at "
560
+ f"line {citing.number}")
561
+ end_rec = lines.get(sp.end)
562
+ if end_rec is None or end_rec.pos >= citing.pos:
563
+ return None, (f"subproof {span[0]}–{span[1]} is not closed above "
564
+ f"line {citing.number}")
565
+ return _Ref("subproof", assumption=sp.assumption, conclusion=sp.conclusion,
566
+ flag=sp.flag, sp_kind=sp.kind), None
567
+
568
+ return None, f"malformed citation {tok!r}"
569
+
570
+
571
+ # ---------------------------------------------------------------------------
572
+ # Classical rule table (propositional fragment)
573
+ # ---------------------------------------------------------------------------
574
+ #
575
+ # Each rule checker has signature (concl, refs, extra, open_assumptions) and
576
+ # returns None when the step is licensed, or an error string. All comparisons are
577
+ # structural == on frozen, hashable nodes. Dispatch is class-based (isinstance),
578
+ # never on the surface glyph.
579
+
580
+ RuleFn = Callable[[Node, List[_Ref], tuple, List[Node]], Optional[str]]
581
+
582
+
583
+ def _r_reit(concl, refs, extra, oa):
584
+ """Reiteration: copy an in-scope earlier line."""
585
+ if len(refs) != 1 or refs[0].kind != "line":
586
+ return "Reit cites exactly one line"
587
+ if refs[0].formula != concl:
588
+ return "Reit: the conclusion must equal the cited line"
589
+ return None
590
+
591
+
592
+ def _r_and_i(concl, refs, extra, oa):
593
+ """∧I: from φ and ψ infer φ ∧ ψ (conjuncts in cited order)."""
594
+ if not isinstance(concl, And):
595
+ return "∧I must conclude a conjunction"
596
+ if len(refs) != 2 or any(r.kind != "line" for r in refs):
597
+ return "∧I cites two lines"
598
+ if concl.left != refs[0].formula or concl.right != refs[1].formula:
599
+ return "∧I: the conjuncts must equal the two cited lines, in order"
600
+ return None
601
+
602
+
603
+ def _r_and_e(concl, refs, extra, oa):
604
+ """∧E: from φ ∧ ψ infer φ (or ψ)."""
605
+ if len(refs) != 1 or refs[0].kind != "line":
606
+ return "∧E cites one line"
607
+ src = refs[0].formula
608
+ if not isinstance(src, And):
609
+ return "∧E cites a conjunction"
610
+ if concl != src.left and concl != src.right:
611
+ return "∧E: the conclusion must be one of the conjuncts"
612
+ return None
613
+
614
+
615
+ def _r_or_i(concl, refs, extra, oa):
616
+ """∨I: from φ infer φ ∨ ψ (or ψ ∨ φ)."""
617
+ if not isinstance(concl, Or):
618
+ return "∨I must conclude a disjunction"
619
+ if len(refs) != 1 or refs[0].kind != "line":
620
+ return "∨I cites one line"
621
+ src = refs[0].formula
622
+ if concl.left != src and concl.right != src:
623
+ return "∨I: the cited line must be one of the disjuncts"
624
+ return None
625
+
626
+
627
+ def _r_or_e(concl, refs, extra, oa):
628
+ """∨E: from φ ∨ ψ and subproofs [φ ⊢ χ], [ψ ⊢ χ] infer χ."""
629
+ if len(refs) != 3:
630
+ return "∨E cites a disjunction line and two subproofs"
631
+ dis, s1, s2 = refs
632
+ if dis.kind != "line" or not isinstance(dis.formula, Or):
633
+ return "∨E: the first citation must be a disjunction line"
634
+ if s1.kind != "subproof" or s2.kind != "subproof":
635
+ return "∨E: the second and third citations must be subproofs"
636
+ left, right = dis.formula.left, dis.formula.right
637
+ if s1.assumption != left:
638
+ return "∨E: the first subproof must assume the left disjunct"
639
+ if s2.assumption != right:
640
+ return "∨E: the second subproof must assume the right disjunct"
641
+ if s1.conclusion != concl or s2.conclusion != concl:
642
+ return "∨E: both subproofs must derive the conclusion"
643
+ return None
644
+
645
+
646
+ def _r_imp_i(concl, refs, extra, oa):
647
+ """→I: from a subproof [φ ⊢ ψ] infer φ → ψ (discharge φ)."""
648
+ if not isinstance(concl, Implies):
649
+ return "→I must conclude an implication"
650
+ if len(refs) != 1 or refs[0].kind != "subproof":
651
+ return "→I cites one subproof"
652
+ s = refs[0]
653
+ if s.assumption != concl.left:
654
+ return "→I: the subproof's assumption must be the antecedent"
655
+ if s.conclusion != concl.right:
656
+ return "→I: the subproof's last line must be the consequent"
657
+ return None
658
+
659
+
660
+ def _r_imp_e(concl, refs, extra, oa):
661
+ """→E (modus ponens): from φ → ψ and φ infer ψ."""
662
+ if len(refs) != 2 or any(r.kind != "line" for r in refs):
663
+ return "→E cites two lines"
664
+ f1, f2 = refs[0].formula, refs[1].formula
665
+ for imp, ant in ((f1, f2), (f2, f1)):
666
+ if isinstance(imp, Implies) and imp.left == ant and imp.right == concl:
667
+ return None
668
+ return "→E: needs an implication φ→ψ and its antecedent φ, concluding ψ"
669
+
670
+
671
+ def _r_iff_i(concl, refs, extra, oa):
672
+ """↔I: from subproofs [φ ⊢ ψ] and [ψ ⊢ φ] infer φ ↔ ψ."""
673
+ if not isinstance(concl, Iff):
674
+ return "↔I must conclude a biconditional"
675
+ if len(refs) != 2 or any(r.kind != "subproof" for r in refs):
676
+ return "↔I cites two subproofs"
677
+ s1, s2 = refs
678
+ a, b = concl.left, concl.right
679
+ forward = s1.assumption == a and s1.conclusion == b
680
+ backward = s2.assumption == b and s2.conclusion == a
681
+ forward2 = s2.assumption == a and s2.conclusion == b
682
+ backward2 = s1.assumption == b and s1.conclusion == a
683
+ if (forward and backward) or (forward2 and backward2):
684
+ return None
685
+ return "↔I: the subproofs must derive φ⊢ψ and ψ⊢φ"
686
+
687
+
688
+ def _r_iff_e(concl, refs, extra, oa):
689
+ """↔E: from φ ↔ ψ and one side infer the other."""
690
+ if len(refs) != 2 or any(r.kind != "line" for r in refs):
691
+ return "↔E cites two lines"
692
+ f1, f2 = refs[0].formula, refs[1].formula
693
+ for bic, side in ((f1, f2), (f2, f1)):
694
+ if isinstance(bic, Iff):
695
+ if side == bic.left and concl == bic.right:
696
+ return None
697
+ if side == bic.right and concl == bic.left:
698
+ return None
699
+ return "↔E: needs a biconditional and one side, concluding the other"
700
+
701
+
702
+ def _r_bot_i(concl, refs, extra, oa):
703
+ """⊥I: from φ and ¬φ infer ⊥."""
704
+ if not is_falsum(concl):
705
+ return "⊥I must conclude ⊥"
706
+ if len(refs) != 2 or any(r.kind != "line" for r in refs):
707
+ return "⊥I cites two lines"
708
+ f1, f2 = refs[0].formula, refs[1].formula
709
+ if f1 == Not(f2) or f2 == Not(f1):
710
+ return None
711
+ return "⊥I: the cited lines must be φ and ¬φ"
712
+
713
+
714
+ def _r_bot_e(concl, refs, extra, oa):
715
+ """⊥E (ex falso quodlibet): from ⊥ infer anything."""
716
+ if len(refs) != 1 or refs[0].kind != "line":
717
+ return "⊥E cites one line"
718
+ if not is_falsum(refs[0].formula):
719
+ return "⊥E must cite a ⊥ line"
720
+ return None
721
+
722
+
723
+ def _r_top_i(concl, refs, extra, oa):
724
+ """⊤I: infer the truth constant ``$true`` (or ``⊤``) from nothing (no citations).
725
+
726
+ Only the truth constant itself: no other atom, and not ``$false``, is licensed by
727
+ this rule.
728
+ """
729
+ if not (isinstance(concl, Atom) and not concl.args
730
+ and concl.predicate in (_TRUE_CONSTANT, _TRUE_GLYPH)):
731
+ return "⊤I must conclude $true"
732
+ if refs:
733
+ return "⊤I cites nothing"
734
+ return None
735
+
736
+
737
+ def _r_not_i(concl, refs, extra, oa):
738
+ """¬I: from a subproof [φ ⊢ ⊥] infer ¬φ (discharge φ)."""
739
+ if not isinstance(concl, Not):
740
+ return "¬I must conclude a negation"
741
+ if len(refs) != 1 or refs[0].kind != "subproof":
742
+ return "¬I cites one subproof"
743
+ s = refs[0]
744
+ if s.assumption != concl.formula:
745
+ return "¬I: the subproof must assume φ where the conclusion is ¬φ"
746
+ if not is_falsum(s.conclusion):
747
+ return "¬I: the subproof must derive ⊥"
748
+ return None
749
+
750
+
751
+ def _r_raa(concl, refs, extra, oa):
752
+ """RAA / proof by contradiction (classical): from [¬φ ⊢ ⊥] infer φ."""
753
+ if len(refs) != 1 or refs[0].kind != "subproof":
754
+ return "RAA cites one subproof"
755
+ s = refs[0]
756
+ if s.assumption != Not(concl):
757
+ return "RAA: the subproof must assume ¬(conclusion)"
758
+ if not is_falsum(s.conclusion):
759
+ return "RAA: the subproof must derive ⊥"
760
+ return None
761
+
762
+
763
+ def _r_dne(concl, refs, extra, oa):
764
+ """¬E / double-negation elimination (classical): from ¬¬φ infer φ."""
765
+ if len(refs) != 1 or refs[0].kind != "line":
766
+ return "¬E cites one line"
767
+ if refs[0].formula != Not(Not(concl)):
768
+ return "¬E: the cited line must be ¬¬(conclusion)"
769
+ return None
770
+
771
+
772
+ _PROP_RULES: Dict[str, RuleFn] = {
773
+ "Reit": _r_reit,
774
+ "∧I": _r_and_i, "∧E": _r_and_e,
775
+ "∨I": _r_or_i, "∨E": _r_or_e,
776
+ "→I": _r_imp_i, "→E": _r_imp_e,
777
+ "↔I": _r_iff_i, "↔E": _r_iff_e,
778
+ "⊥I": _r_bot_i, "⊥E": _r_bot_e, "⊤I": _r_top_i,
779
+ "¬I": _r_not_i, "RAA": _r_raa, "¬E": _r_dne,
780
+ }
781
+
782
+ # ---------------------------------------------------------------------------
783
+ # Quantifier machinery (capture-avoiding substitution + eigenvariable support)
784
+ # ---------------------------------------------------------------------------
785
+
786
+ def _q_kind(node: Node) -> Optional[str]:
787
+ """Normalise an *unsorted* quantifier to '∀' / '∃', or None.
788
+
789
+ Returns None for SortedQuantifier (the sorted-quantifier rules are not offered
790
+ — sound sort-checking of instantiation terms needs a signature the kit does not
791
+ carry) and for non-quantifier nodes, so sorted/structural nodes never match an
792
+ unsorted FOL rule.
793
+ """
794
+ if isinstance(node, Quantifier):
795
+ if node.type in ("forall", "∀"):
796
+ return "∀"
797
+ if node.type in ("exists", "∃"):
798
+ return "∃"
799
+ return None
800
+
801
+
802
+ def _is_term(node: Node) -> bool:
803
+ """Return True iff ``node`` is a first-order term (a legal instantiation)."""
804
+ return isinstance(node, (Variable, Constant, Number, Function, SortedConstant))
805
+
806
+
807
+ def _free_vars(node: Node) -> set:
808
+ """Return the free logical Variable occurrences of ``node`` (LambdaVars dropped)."""
809
+ return {v for v in free_variables(node) if isinstance(v, Variable)}
810
+
811
+
812
+ #: Node types that bind a logical ``Variable`` over a ``formula`` scope. Besides the
813
+ #: quantifiers this covers the counting quantifiers, the cardinality terms (and their
814
+ #: sorted variants) and the IF-logic slashed existential — they bind their variable
815
+ #: exactly as ∀/∃ do, so the shadowing and capture-avoidance rules below apply to them
816
+ #: unchanged. ``SlashedExists`` additionally carries a slash set that is NOT shadowed
817
+ #: by its own binder; :func:`subst_slash_set` rewrites it first. Kept in step with the
818
+ #: binder tuple in :func:`unicode_logic_kit.fol._msfl_nodes.free_variables`.
819
+ _VAR_BINDERS = (Quantifier, SortedQuantifier, Count, Cardinality,
820
+ SortedCount, SortedCardinality, SlashedExists)
821
+
822
+
823
+ def _subst_var(formula: Node, var: Variable, replacement: Node) -> Node:
824
+ """Capture-avoiding substitution of a *logical Variable* by a term.
825
+
826
+ The same specification as the kit's :func:`substitute`, restricted to a logical
827
+ ``Variable`` target: occurrences of ``var`` bound by an inner quantifier are left
828
+ untouched (shadowing), and a bound variable that would capture a free variable
829
+ of ``replacement`` is α-renamed first. The new name is minted by the rule the
830
+ generic substitution uses (``_fresh_binder_name``), so both give the same formula
831
+ spelled the same way, and a checker that recomputes an instance with
832
+ :func:`substitute` finds the one a search recorded with this function.
833
+ The input is not mutated.
834
+ """
835
+ return _subst_var_inner(formula, var, replacement, _free_vars(replacement))
836
+
837
+
838
+ def _subst_var_inner(t: Node, var: Variable, repl: Node, fv: set) -> Node:
839
+ """Recursive worker for :func:`_subst_var` (``fv`` = free vars of ``repl``)."""
840
+ if t == var:
841
+ return repl
842
+ if isinstance(t, (Variable, Constant, Number, LambdaVar, SortedConstant)):
843
+ return t
844
+ if isinstance(t, _VAR_BINDERS):
845
+ if isinstance(t, SlashedExists):
846
+ # The slash set names ENCLOSING binders, so it is rewritten BEFORE the
847
+ # shadowing check — it is not shadowed by this binder's own variable.
848
+ # An emptied slash set degrades to a plain ∃, which re-enters below.
849
+ rewritten = subst_slash_set(t, var, repl)
850
+ if not isinstance(rewritten, SlashedExists):
851
+ return _subst_var_inner(rewritten, var, repl, fv)
852
+ t = rewritten
853
+ if t.variable == var:
854
+ return t # var is re-bound here: occurrences below are shadowed
855
+ bound = t.variable
856
+ body = t.formula
857
+ if bound in fv:
858
+ # The binder would capture a free variable of the replacement; rename it.
859
+ # The fresh name must differ from EVERY name inside the scope, bound ones
860
+ # included: _rename moves the old binder's occurrences onto it, and an inner
861
+ # binder that already uses the name (``∃y0`` when the fresh name is ``y0``)
862
+ # would capture them. It must also differ from the substituted variable (the
863
+ # renamed scope is substituted next) and from the names of the replacement;
864
+ # slash names are plain strings, so they are passed explicitly — a fresh
865
+ # name colliding with one would silently rewire the independence set.
866
+ slash = t.slashed if isinstance(t, SlashedExists) else ()
867
+ fresh = Variable(_fresh_binder_name(bound, body, var, repl, fv, slashed=slash))
868
+ body = _rename(body, bound, fresh)
869
+ bound = fresh
870
+ inner = _subst_var_inner(body, var, repl, fv)
871
+ # Every binder in the family carries its binder in ``variable`` and its scope
872
+ # in ``formula``; the remaining fields (∀/∃ type, sort, count op and n) are
873
+ # untouched, so a field-wise replace rebuilds each shape uniformly.
874
+ return replace(t, variable=bound, formula=inner)
875
+ return t.map_children(lambda c: _subst_var_inner(c, var, repl, fv))
876
+
877
+
878
+ def _not_free_in_assumptions(e: Variable, oa: List[Node]) -> Optional[str]:
879
+ """Return an error string if eigenvariable ``e`` is free in any open assumption."""
880
+ for g in oa:
881
+ if e in _free_vars(g):
882
+ return (f"eigenvariable {e.name} occurs free in an undischarged "
883
+ f"assumption ({g.to_unicode_str()})")
884
+ return None
885
+
886
+
887
+ # ---------------------------------------------------------------------------
888
+ # Quantifier rules (classical FOL)
889
+ # ---------------------------------------------------------------------------
890
+
891
+ def _r_forall_e(concl, refs, extra, oa):
892
+ """∀E (universal instantiation): from ∀x φ infer φ[x:=t]."""
893
+ if len(refs) != 1 or refs[0].kind != "line":
894
+ return "∀E cites one line"
895
+ src = refs[0].formula
896
+ if _q_kind(src) != "∀":
897
+ return "∀E must cite a universal ∀x φ"
898
+ if len(extra) != 1 or not _is_term(extra[0]):
899
+ return "∀E needs the instantiation term in justification extra"
900
+ t = extra[0]
901
+ if concl != _subst_var(src.formula, src.variable, t):
902
+ return "∀E: the conclusion must be φ[x:=t] for the cited ∀x φ and term t"
903
+ return None
904
+
905
+
906
+ def _r_forall_i(concl, refs, extra, oa):
907
+ """∀I (universal generalization): from a box flagging e that derives φ(e) infer ∀x φ.
908
+
909
+ Side condition: the eigenvariable e must not occur free in any undischarged
910
+ assumption (nor, consequently, in the conclusion).
911
+ """
912
+ if _q_kind(concl) != "∀":
913
+ return "∀I must conclude a universal ∀x φ"
914
+ if len(refs) != 1 or refs[0].kind != "subproof":
915
+ return "∀I cites one subproof"
916
+ s = refs[0]
917
+ e = s.flag
918
+ if not isinstance(e, Variable):
919
+ return "∀I: the subproof must flag an eigenvariable (a Flag head with flag=)"
920
+ if s.assumption is not None:
921
+ # The box must be a PURE eigenvariable box (a 'Flag' head), not one that
922
+ # also discharged a hypothesis. Otherwise a hypothesis φ(e) mentioning the
923
+ # eigenvariable would be invisible to the freshness check below (which runs
924
+ # against the open assumptions OUTSIDE the box, where φ(e) is discharged),
925
+ # unsoundly licensing e.g. ⊢ ∀x P(x) from a box assuming P(e).
926
+ return ("∀I: the subproof must be a pure eigenvariable box (a Flag head), "
927
+ "not one that discharges a hypothesis")
928
+ if s.conclusion != _subst_var(concl.formula, concl.variable, e):
929
+ return "∀I: the subproof must derive the body φ with the eigenvariable, φ(e)"
930
+ err = _not_free_in_assumptions(e, oa)
931
+ if err:
932
+ return "∀I: " + err
933
+ if e in _free_vars(concl):
934
+ return f"∀I: eigenvariable {e.name} still occurs free in the conclusion"
935
+ return None
936
+
937
+
938
+ def _r_exists_i(concl, refs, extra, oa):
939
+ """∃I (existential generalization): from φ[x:=t] infer ∃x φ."""
940
+ if _q_kind(concl) != "∃":
941
+ return "∃I must conclude an existential ∃x φ"
942
+ if len(refs) != 1 or refs[0].kind != "line":
943
+ return "∃I cites one line"
944
+ if len(extra) != 1 or not _is_term(extra[0]):
945
+ return "∃I needs the witness term in justification extra"
946
+ t = extra[0]
947
+ if refs[0].formula != _subst_var(concl.formula, concl.variable, t):
948
+ return "∃I: the cited line must be φ[x:=t] for the concluded ∃x φ and term t"
949
+ return None
950
+
951
+
952
+ def _r_exists_e(concl, refs, extra, oa):
953
+ """∃E (existential instantiation): from ∃x φ and a box [φ(e) ⊢ χ] infer χ.
954
+
955
+ Side conditions: the eigenvariable e must not occur free in the conclusion χ,
956
+ in the major premise ∃x φ, or in any undischarged assumption.
957
+ """
958
+ if len(refs) != 2:
959
+ return "∃E cites an existential line and one subproof"
960
+ ex, s = refs
961
+ if ex.kind != "line" or _q_kind(ex.formula) != "∃":
962
+ return "∃E: the first citation must be an existential ∃x φ"
963
+ if s.kind != "subproof":
964
+ return "∃E: the second citation must be a subproof"
965
+ e = s.flag
966
+ if not isinstance(e, Variable):
967
+ return "∃E: the subproof must flag an eigenvariable"
968
+ expected_assumption = _subst_var(ex.formula.formula, ex.formula.variable, e)
969
+ if s.assumption != expected_assumption:
970
+ return "∃E: the subproof must assume φ[x:=e] (the body with the eigenvariable)"
971
+ if s.conclusion != concl:
972
+ return "∃E: the subproof's last line must be the rule's conclusion"
973
+ if e in _free_vars(concl):
974
+ return f"∃E: eigenvariable {e.name} occurs free in the conclusion"
975
+ if e in _free_vars(ex.formula):
976
+ return f"∃E: eigenvariable {e.name} occurs free in the major premise ∃x φ"
977
+ err = _not_free_in_assumptions(e, oa)
978
+ if err:
979
+ return "∃E: " + err
980
+ return None
981
+
982
+
983
+ _QUANTIFIER_RULES: Dict[str, RuleFn] = {
984
+ "∀E": _r_forall_e, "∀I": _r_forall_i,
985
+ "∃I": _r_exists_i, "∃E": _r_exists_e,
986
+ }
987
+
988
+
989
+ # ---------------------------------------------------------------------------
990
+ # Equality rules (=I reflexivity, =E Leibniz substitution)
991
+ # ---------------------------------------------------------------------------
992
+ #
993
+ # The kit treats '=' as an ordinary uninterpreted Atom predicate everywhere else
994
+ # (resolution has no congruence), so a Fitch system with equality must add these
995
+ # rules explicitly. They are certified against Z3 (which interprets '=' natively),
996
+ # NOT resolution — exactly the divergence the design flags.
997
+
998
+ def _is_equality(node: Node) -> bool:
999
+ """Return True iff ``node`` is an equality atom ``s = t``."""
1000
+ return isinstance(node, Atom) and node.predicate == "=" and len(node.args) == 2
1001
+
1002
+
1003
+ def _r_eq_i(concl, refs, extra, oa):
1004
+ """=I (reflexivity): infer t = t from nothing."""
1005
+ if not _is_equality(concl):
1006
+ return "=I must conclude an equality t = t"
1007
+ if concl.args[0] != concl.args[1]:
1008
+ return "=I (reflexivity): the two sides must be the same term"
1009
+ return None
1010
+
1011
+
1012
+ def _r_eq_e(concl, refs, extra, oa):
1013
+ """=E (Leibniz): from s = t and φ infer the result of replacing s by t in φ.
1014
+
1015
+ Certified semantically against Z3 (cited equality and formula ⊨ conclusion);
1016
+ since '=' is uninterpreted to resolution, Z3 is the only sound oracle here.
1017
+ """
1018
+ if len(refs) != 2 or any(r.kind != "line" for r in refs):
1019
+ return "=E cites two lines (an equality and a formula)"
1020
+ eq = None
1021
+ phi = None
1022
+ for r in refs:
1023
+ if eq is None and _is_equality(r.formula):
1024
+ eq = r.formula
1025
+ else:
1026
+ phi = r.formula
1027
+ if eq is None or phi is None:
1028
+ return "=E: exactly one cited line must be an equality s = t"
1029
+ if not _classical_valid([eq, phi], concl):
1030
+ return "=E: the conclusion does not follow from the equality and the formula"
1031
+ return None
1032
+
1033
+
1034
+ _EQUALITY_RULES: Dict[str, RuleFn] = {"=I": _r_eq_i, "=E": _r_eq_e}
1035
+
1036
+
1037
+ # The classical rule table: propositional + quantifier + equality rules.
1038
+ _CLASSICAL_RULES: Dict[str, RuleFn] = {
1039
+ **_PROP_RULES, **_QUANTIFIER_RULES, **_EQUALITY_RULES,
1040
+ }
1041
+
1042
+
1043
+ # ---------------------------------------------------------------------------
1044
+ # The checker
1045
+ # ---------------------------------------------------------------------------
1046
+
1047
+ _CLASSICAL_LOGICS = frozenset({"fol", "msfol", "classical", "prop"})
1048
+
1049
+
1050
+ def _open_assumptions(rec: _Rec, sub_by_sid, premise_formulas) -> List[Node]:
1051
+ """Return the undischarged hypotheses in scope at ``rec`` (premises + enclosing)."""
1052
+ out = list(premise_formulas)
1053
+ for sid in rec.scope:
1054
+ sp = sub_by_sid.get(sid)
1055
+ if sp is not None and sp.assumption is not None:
1056
+ out.append(sp.assumption)
1057
+ return out
1058
+
1059
+
1060
+ def _conclusion_of(proof: "Proof") -> Optional[Node]:
1061
+ """Return the formula of the last top-level line, or None if there is none."""
1062
+ if proof.steps and isinstance(proof.steps[-1], Line):
1063
+ return proof.steps[-1].formula
1064
+ return None
1065
+
1066
+
1067
+ def verify_proof(proof: "Proof", logic: Optional[str] = None) -> ProofResult:
1068
+ """Check ``proof`` and return a :class:`ProofResult` (sequent + first error).
1069
+
1070
+ ``logic`` overrides ``proof.logic`` if given. Classical logics (``"fol"`` /
1071
+ ``"msfol"``) are checked by the syntactic rule table; ``"K3"`` / ``"LP"`` are
1072
+ certified semantically against
1073
+ :func:`unicode_logic_kit.semantics.manyvalued.entails`; and the modal family
1074
+ (``"K"`` / ``"T"`` / ``"S4"`` / ``"S5"``) is certified against the standard
1075
+ translation to FOL plus the frame axioms, decided by Z3. The returned result
1076
+ names the certified sequent (premises ⊢ conclusion) and, on failure, the first
1077
+ offending line and the reason.
1078
+ """
1079
+ logic = (logic or proof.logic or "fol").strip()
1080
+ # Filter to Line premises so a malformed premise is reported by _index (below)
1081
+ # rather than crashing here on a missing .formula.
1082
+ premise_formulas = tuple(_canon_q(p.formula) for p in proof.premises if isinstance(p, Line))
1083
+
1084
+ try:
1085
+ order, lines, subs_by_span, sub_by_sid = _index(proof)
1086
+ except _IndexError as e:
1087
+ return ProofResult(False, premise_formulas, _conclusion_of(proof),
1088
+ e.number, e.message, logic)
1089
+
1090
+ conclusion = _conclusion_of(proof)
1091
+ if conclusion is None:
1092
+ return ProofResult(False, premise_formulas, None, None,
1093
+ "the proof has no top-level concluding line", logic)
1094
+
1095
+ key = logic.lower()
1096
+ classical = key in _CLASSICAL_LOGICS
1097
+ semantic_checker = None
1098
+ if not classical:
1099
+ semantic_checker = _make_semantic_checker(logic)
1100
+ if semantic_checker is None:
1101
+ return ProofResult(False, premise_formulas, conclusion, None,
1102
+ f"unknown logic {logic!r}", logic)
1103
+
1104
+ for rec in order:
1105
+ # Premises / assumptions / flags are free; only sanity-check their tags.
1106
+ if rec.role in ("premise", "assume", "flag"):
1107
+ expected = {"premise": "Premise", "assume": "Assume", "flag": "Assume"}[rec.role]
1108
+ if rec.just.rule not in (expected, "Flag"):
1109
+ return ProofResult(False, premise_formulas, conclusion, rec.number,
1110
+ f"line {rec.number} should be justified "
1111
+ f"'{expected}', not {rec.just.rule!r}", logic)
1112
+ continue
1113
+
1114
+ if rec.just.rule in _RESERVED_RULES:
1115
+ return ProofResult(False, premise_formulas, conclusion, rec.number,
1116
+ f"line {rec.number} is a derivation step but uses "
1117
+ f"the reserved justification {rec.just.rule!r}", logic)
1118
+
1119
+ # Resolve and accessibility-check every citation. This runs for ALL
1120
+ # regimes — even the semantic (K3/LP) path rejects a citation that does not
1121
+ # exist or reaches into a closed sibling box; only the final *verdict* on a
1122
+ # line differs (the syntactic rule table vs. the open-assumption oracle).
1123
+ refs: List[_Ref] = []
1124
+ for tok in rec.just.cites:
1125
+ ref, err = _resolve(tok, rec, lines, subs_by_span)
1126
+ if err is not None:
1127
+ return ProofResult(False, premise_formulas, conclusion, rec.number,
1128
+ f"line {rec.number}: {err}", logic)
1129
+ refs.append(ref)
1130
+
1131
+ oa = _open_assumptions(rec, sub_by_sid, premise_formulas)
1132
+
1133
+ if classical:
1134
+ fn = _CLASSICAL_RULES.get(rec.just.rule)
1135
+ if fn is None:
1136
+ return ProofResult(False, premise_formulas, conclusion, rec.number,
1137
+ f"line {rec.number}: unknown rule "
1138
+ f"{rec.just.rule!r} for logic {logic!r}", logic)
1139
+ err = fn(rec.formula, refs, rec.just.extra, oa)
1140
+ if err is not None:
1141
+ return ProofResult(False, premise_formulas, conclusion, rec.number,
1142
+ f"line {rec.number}: {err}", logic)
1143
+ else:
1144
+ ok, reason = semantic_checker(oa, rec.formula)
1145
+ if not ok:
1146
+ return ProofResult(False, premise_formulas, conclusion, rec.number,
1147
+ f"line {rec.number}: {reason}", logic)
1148
+
1149
+ return ProofResult(True, premise_formulas, conclusion, None, None, logic)
1150
+
1151
+
1152
+ def check_proof(proof: "Proof", logic: Optional[str] = None) -> bool:
1153
+ """Return True iff ``proof`` is a valid Fitch derivation (sound).
1154
+
1155
+ A thin bool wrapper over :func:`verify_proof`; use that for the failing line
1156
+ and reason.
1157
+ """
1158
+ return verify_proof(proof, logic).ok
1159
+
1160
+
1161
+ # ---------------------------------------------------------------------------
1162
+ # Semantic checkers for the non-classical logics (filled by later phases)
1163
+ # ---------------------------------------------------------------------------
1164
+
1165
+ def _has_quantifier(node: Node) -> bool:
1166
+ """Return True iff a (first- or second-order) quantifier occurs anywhere in ``node``."""
1167
+ return any(isinstance(n, (Quantifier, SortedQuantifier)) for n in node.walk())
1168
+
1169
+
1170
+ _MANYVALUED_LOGICS = {"k3": "K3", "lp": "LP"}
1171
+
1172
+
1173
+ def _make_manyvalued_checker(canonical: str):
1174
+ """Return a K3/LP step certifier using ``manyvalued.entails`` as the oracle.
1175
+
1176
+ Each derived line must be a ``canonical``-consequence of its open assumptions.
1177
+ ``entails`` enumerates all 3ⁿ assignments — complete and sound for the
1178
+ *propositional* fragment — so quantified input is rejected (a single
1179
+ finite-domain enumeration would not soundly certify a first-order step).
1180
+ """
1181
+ from ..semantics.manyvalued import entails
1182
+
1183
+ def check(oa: List[Node], formula: Node):
1184
+ for node in (formula, *oa):
1185
+ if _has_quantifier(node):
1186
+ return (False,
1187
+ f"{canonical} checking supports only the propositional "
1188
+ f"fragment, but a quantifier is present")
1189
+ try:
1190
+ ok = entails(list(oa), formula, logic=canonical)
1191
+ except NotImplementedError as e:
1192
+ return False, f"{canonical}: {e}"
1193
+ except ValueError as e:
1194
+ return False, f"{canonical}: {e}"
1195
+ if not ok:
1196
+ return (False, f"the line is not a {canonical} consequence of its "
1197
+ f"open assumptions")
1198
+ return True, None
1199
+
1200
+ return check
1201
+
1202
+
1203
+ # ---------------------------------------------------------------------------
1204
+ # Modal family (alethic K/T/S4/S5, epistemic S5, doxastic KD45, deontic KD)
1205
+ # ---------------------------------------------------------------------------
1206
+ #
1207
+ # Modal proofs are certified by *local consequence*: a derived line φ is licensed
1208
+ # iff its open assumptions entail it at the current world in every model of the
1209
+ # frame class. Brute Kripke-frame enumeration is neither practical (the frame
1210
+ # space explodes) nor soundly boundable, so instead each obligation is reduced to
1211
+ # classical FOL via the standard translation ST and the frame conditions are added
1212
+ # as first-order axioms; Z3 then decides it. ``is_valid`` only ever returns True on
1213
+ # a genuine ``unsat`` of the negation (a Z3 ``unknown`` ⇒ False), so this is
1214
+ # sound: an accepted step is genuinely a local modal consequence.
1215
+ #
1216
+ # Local consequence validates the classical propositional rules, the deduction
1217
+ # theorem (→I/¬I), every modal theorem of the system (the K/T/4/5/D axioms come out
1218
+ # as zero-premise local validities, as does the necessitation of any theorem,
1219
+ # e.g. ⊢ □(p∨¬p)), and the factivity discipline (Knows is reflexive ⇒ factive;
1220
+ # Believes/Obligatory are non-reflexive ⇒ NOT factive). It deliberately does NOT
1221
+ # license necessitation from a *contingent* assumption (φ ⊬ □φ) — there is no sound
1222
+ # □I-by-strict-subproof over local consequence, and such a step is correctly
1223
+ # rejected. Temporal operators (Always/Eventually/Next/Until) and quantified modal
1224
+ # input are out of scope (no sound first-order rendering) and are rejected.
1225
+
1226
+ _MODAL_SYSTEMS = {"k": "K", "t": "T", "s4": "S4", "s5": "S5", "modal": "K"}
1227
+
1228
+ # Per frame class, the relational axioms its accessibility relation must
1229
+ # satisfy — the shared registry (unicode_logic_kit.fol.frames), so this route
1230
+ # understands exactly the systems every other route does. The axioms come
1231
+ # from that module's one first-order emitter as well: natural deduction over
1232
+ # the standard translation needs no sort guard, which is the same shape the
1233
+ # hybrid route uses.
1234
+ _FRAME_AXIOMS = _SHARED_FRAMES
1235
+
1236
+
1237
+ def _frame_axiom(kind: str, relation: str):
1238
+ """One frame axiom over ``relation``, refusing by name what has no
1239
+ first-order form (Löb, McKinsey, Grz — the GL / S4.1 / Grz systems);
1240
+ those need the higher-order routes, and silently dropping them would
1241
+ search for a proof in a weaker logic than the caller named."""
1242
+ return unguarded_frame_axiom(kind, relation, prefix="w")
1243
+
1244
+
1245
+ def _collect_modal_frames(nodes, alethic_system):
1246
+ """Return ``({relation_name: frame_class}, unsupported_reason_or_None)``.
1247
+
1248
+ Each modality contributes its accessibility relation and the frame class it is
1249
+ interpreted over: Box/Diamond use the alethic relation with the chosen system;
1250
+ Knows is S5 (factive knowledge); Believes is KD45 (consistent, introspective,
1251
+ non-factive belief); Obligatory/Permitted are KD (serial, non-factive). The
1252
+ relation-predicate names match the standard translation's fixed scheme.
1253
+ """
1254
+ frames: Dict[str, str] = {}
1255
+ unsupported = None
1256
+ for node in nodes:
1257
+ for n in node.walk():
1258
+ if isinstance(n, (Box, Diamond)):
1259
+ frames["R"] = alethic_system
1260
+ elif isinstance(n, Knows):
1261
+ frames["Rk_" + (getattr(n.agent, "name", None) or key_text(n.agent))] = "S5"
1262
+ elif isinstance(n, (EverybodyKnows, DistributedKnowledge, CommonKnowledge)):
1263
+ # The group operators quantify over the members' own Rk_ relations.
1264
+ for member in n.group:
1265
+ frames["Rk_" + (getattr(member, "name", None) or key_text(member))] = "S5"
1266
+ elif isinstance(n, Believes):
1267
+ frames["Rb_" + (getattr(n.agent, "name", None) or key_text(n.agent))] = "KD45"
1268
+ elif isinstance(n, (Obligatory, Permitted)):
1269
+ frames["D"] = "KD"
1270
+ elif isinstance(n, (Always, Eventually, Next, Until,
1271
+ Historically, Once, Previous, Since)):
1272
+ unsupported = "temporal operators (Always/Eventually/Next/Until and past-tense H/P/Y/Since)"
1273
+ elif isinstance(n, (Quantifier, SortedQuantifier)):
1274
+ unsupported = "quantifiers (first-order modal logic)"
1275
+ return frames, unsupported
1276
+
1277
+
1278
+ def _make_modal_checker(alethic_system: str):
1279
+ """Return a modal step certifier for the given alethic frame class.
1280
+
1281
+ Each step's obligation — frame axioms ∧ ST(open assumptions) → ST(line) — is
1282
+ decided by Z3. Knows/Believes/Obligatory carry their standard frame classes
1283
+ regardless of ``alethic_system``.
1284
+ """
1285
+ from .z3_models import is_valid
1286
+
1287
+ def check(oa: List[Node], formula: Node):
1288
+ nodes = [formula, *oa]
1289
+ frames, unsupported = _collect_modal_frames(nodes, alethic_system)
1290
+ if unsupported:
1291
+ return False, f"modal checking does not support {unsupported}"
1292
+ # A user predicate sharing a name with an accessibility relation introduced
1293
+ # by the standard translation would conflate the two symbols in Z3 (and, at
1294
+ # a different arity, raise inside to_z3). Reject it with a clear message
1295
+ # rather than crash or risk a conflated verdict.
1296
+ for node in nodes:
1297
+ for n in node.walk():
1298
+ if isinstance(n, Atom) and n.predicate in frames:
1299
+ return (False, f"predicate name {n.predicate!r} collides with a "
1300
+ f"modal accessibility relation in the standard "
1301
+ f"translation; rename it")
1302
+ axioms: List[Node] = []
1303
+ for rel, fclass in frames.items():
1304
+ try:
1305
+ conds = resolve_frame(fclass)
1306
+ except ValueError as exc:
1307
+ return (False, f"modal: {exc}")
1308
+ for kind in conds:
1309
+ try:
1310
+ axioms.append(_frame_axiom(kind, rel))
1311
+ except UnsupportedFrameCondition as exc:
1312
+ return (False, f"modal: {exc}")
1313
+ try:
1314
+ from ..fol._identifiers import symbol_names
1315
+ from ..fol.modal_translation import _check_nominal_collision, standard_translation
1316
+ # The line and its open assumptions are translated one by one but form ONE
1317
+ # obligation, so each translation avoids the names of all of them: a variable
1318
+ # the user spelled like the world variable is renamed to the same name in every
1319
+ # formula, and never to a name another of the formulas uses for something else.
1320
+ line, assumptions = _desugar_falsum(formula), [_desugar_falsum(g) for g in oa]
1321
+ # The same holds for the world constants of the nominals (``nom_a``): the
1322
+ # translation refuses a user symbol spelled like one, but looks at one formula at
1323
+ # a time, and an assumption that names the nominal ``a`` next to a line that
1324
+ # holds a user constant ``nom_a`` would pass both looks and then be read by Z3 as
1325
+ # one symbol. So the guard runs once over everything the obligation holds.
1326
+ try:
1327
+ _check_nominal_collision(reduce(And, [line, *assumptions]))
1328
+ except ValueError as e:
1329
+ return False, f"modal: {e}"
1330
+ names = symbol_names(line, *assumptions)
1331
+ st_concl = standard_translation(line, world="w", avoid=names)
1332
+ st_oa = [standard_translation(g, world="w", avoid=names) for g in assumptions]
1333
+ except NotImplementedError as e:
1334
+ return False, f"modal: {e}"
1335
+ antecedents = axioms + st_oa
1336
+ obligation = (Implies(reduce(And, antecedents), st_concl)
1337
+ if antecedents else st_concl)
1338
+ try:
1339
+ valid = is_valid(obligation)
1340
+ except Exception as e: # noqa: BLE001 — never crash on a Z3 backend error
1341
+ return False, f"modal certification could not be decided ({e})"
1342
+ if valid:
1343
+ return True, None
1344
+ return (False, f"the line is not a local {alethic_system}-modal consequence "
1345
+ f"of its open assumptions")
1346
+
1347
+ return check
1348
+
1349
+
1350
+ def _make_semantic_checker(logic: str):
1351
+ """Return a ``(open_assumptions, formula) -> (ok, reason)`` certifier, or None.
1352
+
1353
+ ``"K3"``/``"LP"`` are certified against the three-valued decision procedure
1354
+ :func:`unicode_logic_kit.semantics.manyvalued.entails`. ``"K"``/``"T"``/``"S4"``/
1355
+ ``"S5"`` (and ``"modal"``) certify the modal family by standard translation to
1356
+ FOL plus frame axioms, decided by Z3. Returns None for an unrecognised logic.
1357
+ """
1358
+ key = logic.lower()
1359
+ if key in _MANYVALUED_LOGICS:
1360
+ return _make_manyvalued_checker(_MANYVALUED_LOGICS[key])
1361
+ if key in _MODAL_SYSTEMS:
1362
+ return _make_modal_checker(_MODAL_SYSTEMS[key])
1363
+ return None
1364
+
1365
+
1366
+ # ---------------------------------------------------------------------------
1367
+ # Rendering: Fitch notation (Unicode/ASCII) and LaTeX
1368
+ # ---------------------------------------------------------------------------
1369
+
1370
+ def _fmt_cite(tok) -> str:
1371
+ """Format one citation token: a line number, or a ``start–end`` subproof span."""
1372
+ if isinstance(tok, tuple) and len(tok) == 2:
1373
+ return f"{tok[0]}–{tok[1]}"
1374
+ return str(tok)
1375
+
1376
+
1377
+ def _just_text(just: "Justification") -> str:
1378
+ """Render a justification as ``rule cites [terms]`` (e.g. ``∀E 1 [socrates]``)."""
1379
+ parts = [just.rule]
1380
+ if just.cites:
1381
+ parts.append(", ".join(_fmt_cite(c) for c in just.cites))
1382
+ text = " ".join(parts)
1383
+ if just.extra:
1384
+ terms = ", ".join(e.to_unicode_str() if isinstance(e, Node) else str(e)
1385
+ for e in just.extra)
1386
+ text += f" [{terms}]"
1387
+ return text
1388
+
1389
+
1390
+ def _visual_rows(proof: "Proof") -> List[dict]:
1391
+ """Flatten the proof into ordered render rows (line rows and Fitch-bar rows).
1392
+
1393
+ A ``line`` row carries its number, nesting depth, formula, and justification
1394
+ text; a ``bar`` row carries the depth at which the horizontal Fitch rule sits
1395
+ (under the premises, and under each subproof's assumption).
1396
+ """
1397
+ rows: List[dict] = []
1398
+ for pl in proof.premises:
1399
+ rows.append({"kind": "line", "number": pl.number, "depth": 0,
1400
+ "formula": pl.formula, "just": _just_text(pl.justification)})
1401
+ if proof.premises and proof.steps:
1402
+ rows.append({"kind": "bar", "depth": 0})
1403
+
1404
+ def walk(steps, depth):
1405
+ for st in steps:
1406
+ if isinstance(st, Line):
1407
+ rows.append({"kind": "line", "number": st.number, "depth": depth,
1408
+ "formula": st.formula,
1409
+ "just": _just_text(st.justification)})
1410
+ elif isinstance(st, Subproof) and st.assumption is not None:
1411
+ a = st.assumption
1412
+ rows.append({"kind": "line", "number": a.number, "depth": depth + 1,
1413
+ "formula": a.formula,
1414
+ "just": _just_text(a.justification)})
1415
+ rows.append({"kind": "bar", "depth": depth + 1})
1416
+ walk(st.body, depth + 1)
1417
+
1418
+ walk(proof.steps, 0)
1419
+ return rows
1420
+
1421
+
1422
+ def render_fitch(proof: "Proof", ascii: bool = False) -> str:
1423
+ """Render ``proof`` in classic Fitch notation as a multi-line string.
1424
+
1425
+ A line-number gutter, one vertical scope bar per open subproof, a horizontal
1426
+ rule under the premises and under each assumption, and a right-hand
1427
+ justification column (``rule cites``). Pass ``ascii=True`` for an ASCII-only
1428
+ rendering (``|``/``+``/``-`` instead of ``│``/``├``/``─``).
1429
+ """
1430
+ vbar = "|" if ascii else "│"
1431
+ corner = "+" if ascii else "├"
1432
+ hbar = "-" if ascii else "─"
1433
+
1434
+ rows = _visual_rows(proof)
1435
+ width = 1
1436
+ for r in rows:
1437
+ if r["kind"] == "line":
1438
+ r["ftext"] = r["formula"].to_unicode_str()
1439
+ width = max(width, len(str(r["number"])))
1440
+
1441
+ left_of: Dict[int, str] = {}
1442
+ content_w = 0
1443
+ for i, r in enumerate(rows):
1444
+ if r["kind"] == "line":
1445
+ level = r["depth"] + 1
1446
+ left = f"{str(r['number']).rjust(width)} {(vbar + ' ') * level}"
1447
+ left_of[i] = left
1448
+ content_w = max(content_w, len(left) + len(r["ftext"]))
1449
+
1450
+ out: List[str] = []
1451
+ for i, r in enumerate(rows):
1452
+ if r["kind"] == "line":
1453
+ body = left_of[i] + r["ftext"]
1454
+ if r["just"]:
1455
+ body += " " * (content_w - len(body) + 3) + r["just"]
1456
+ out.append(body.rstrip())
1457
+ else:
1458
+ level = r["depth"] + 1
1459
+ prefix = " " * (width + 1) + (vbar + " ") * (level - 1)
1460
+ out.append(prefix + corner + hbar * 6)
1461
+ return "\n".join(out)
1462
+
1463
+
1464
+ _RULE_GLYPH_LATEX = {
1465
+ "∧": r"\land", "∨": r"\lor", "→": r"\rightarrow",
1466
+ "↔": r"\leftrightarrow", "¬": r"\lnot", "⊕": r"\oplus",
1467
+ "∀": r"\forall", "∃": r"\exists", "⊥": r"\bot",
1468
+ "–": "--",
1469
+ }
1470
+
1471
+
1472
+ def _latex_just(text: str) -> str:
1473
+ """Render a justification string for LaTeX, mapping operator glyphs to macros."""
1474
+ for glyph, macro in _RULE_GLYPH_LATEX.items():
1475
+ text = text.replace(glyph, f"${macro}$")
1476
+ return r"\text{" + text + "}"
1477
+
1478
+
1479
+ def render_latex_fitch(proof: "Proof") -> str:
1480
+ """Render ``proof`` as a LaTeX ``array`` Fitch derivation (no external package).
1481
+
1482
+ The number column is separated from the body by the main Fitch bar (the array
1483
+ ``|`` rule); each nested subproof adds a ``\\mid`` to its lines, and a
1484
+ ``\\cline{2-2}`` draws the horizontal rule under the premises and each
1485
+ assumption. Compiles with a unicode-aware engine (xelatex/lualatex) for the
1486
+ rendered formulas. The output is a stable, exact string (not re-parsed).
1487
+ """
1488
+ rows = _visual_rows(proof)
1489
+ out = [r"\[", r"\begin{array}{r|l@{\qquad}l}"]
1490
+ for r in rows:
1491
+ if r["kind"] == "line":
1492
+ bars = r"\mid " * r["depth"]
1493
+ out.append(f"{r['number']} & {bars}{r['formula'].to_latex()} & "
1494
+ f"{_latex_just(r['just'])} \\\\")
1495
+ else:
1496
+ out.append(r"\cline{2-2}")
1497
+ out.append(r"\end{array}")
1498
+ out.append(r"\]")
1499
+ return "\n".join(out)
1500
+
1501
+
1502
+ # ---------------------------------------------------------------------------
1503
+ # Rendering: self-contained HTML page (mirrors CCGDerivation.to_html's idiom)
1504
+ # ---------------------------------------------------------------------------
1505
+
1506
+ _FITCH_CSS = """
1507
+ .scroll{overflow-x:auto;padding:22px 8px}
1508
+ .fitch{width:max-content;min-width:100%;padding:0 22px;
1509
+ font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
1510
+ font-size:14px;line-height:1.75}
1511
+ .ln{display:flex;align-items:stretch}
1512
+ .no{flex:none;width:2.6em;text-align:right;padding-right:.6em;color:var(--muted)}
1513
+ .bars{flex:none;display:flex}
1514
+ .bx{flex:none;width:14px;border-left:1.3px solid var(--bar)}
1515
+ .f{flex:none;padding:0 1em 0 .5em;white-space:pre;color:var(--ink)}
1516
+ .j{flex:none;color:var(--muted);white-space:pre;padding-left:1.4em}
1517
+ .bar .rule{flex:1 1 auto;align-self:center;border-top:1.3px solid var(--bar);margin-right:1.6em}
1518
+ """
1519
+
1520
+
1521
+ def _html_fitch(proof: "Proof") -> str:
1522
+ """Render ``proof``'s body markup: one flex row per :func:`_visual_rows` row.
1523
+
1524
+ A ``line`` row is a number gutter, ``depth + 1`` nested ``.bx`` cells (each a
1525
+ ``border-left`` — the vertical Fitch scope bars, exactly as many as
1526
+ :func:`render_fitch` prints ``│`` characters for that line), the formula, and
1527
+ the right-hand justification. A ``bar`` row draws ``depth`` plain ``.bx``
1528
+ cells followed by a horizontal ``.rule`` filling the rest of the row — the
1529
+ markup equivalent of ``render_fitch``'s ``├──────``.
1530
+ """
1531
+ parts: List[str] = []
1532
+ for r in _visual_rows(proof):
1533
+ if r["kind"] == "line":
1534
+ bars = "".join('<div class="bx"></div>' for _ in range(r["depth"] + 1))
1535
+ just = ('<div class="j">%s</div>' % esc_html(r["just"])) if r["just"] else ""
1536
+ parts.append(
1537
+ '<div class="ln"><div class="no">%s</div><div class="bars">%s</div>'
1538
+ '<div class="f">%s</div>%s</div>'
1539
+ % (r["number"], bars, esc_html(r["formula"].to_unicode_str()), just)
1540
+ )
1541
+ else:
1542
+ bars = "".join('<div class="bx"></div>' for _ in range(r["depth"]))
1543
+ parts.append(
1544
+ '<div class="ln bar"><div class="no"></div><div class="bars">%s</div>'
1545
+ '<div class="rule"></div></div>' % bars
1546
+ )
1547
+ return '<div class="scroll"><div class="fitch">%s</div></div>' % "".join(parts)