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.
- unicode_logic_kit/__init__.py +385 -0
- unicode_logic_kit/__main__.py +520 -0
- unicode_logic_kit/_deadline.py +219 -0
- unicode_logic_kit/ace/__init__.py +126 -0
- unicode_logic_kit/ace/_align.py +135 -0
- unicode_logic_kit/ace/chem_lexicon.py +128 -0
- unicode_logic_kit/ace/drs_reader.py +570 -0
- unicode_logic_kit/ace/mapping.py +666 -0
- unicode_logic_kit/ace/reverse_modal.py +138 -0
- unicode_logic_kit/ace/runner.py +551 -0
- unicode_logic_kit/ace/translate.py +452 -0
- unicode_logic_kit/ace/verbalize.py +1070 -0
- unicode_logic_kit/api.py +1284 -0
- unicode_logic_kit/atp/__init__.py +177 -0
- unicode_logic_kit/atp/_ascii_names.py +113 -0
- unicode_logic_kit/atp/_html.py +72 -0
- unicode_logic_kit/atp/_substructural_input.py +228 -0
- unicode_logic_kit/atp/_tff_problem.py +715 -0
- unicode_logic_kit/atp/_tptp_problem.py +1111 -0
- unicode_logic_kit/atp/_writer_support.py +289 -0
- unicode_logic_kit/atp/clingo_backend.py +1180 -0
- unicode_logic_kit/atp/cvc5_backend.py +1385 -0
- unicode_logic_kit/atp/eprover_backend.py +732 -0
- unicode_logic_kit/atp/finite_domain.py +1055 -0
- unicode_logic_kit/atp/fitch.py +1547 -0
- unicode_logic_kit/atp/fitch_search.py +551 -0
- unicode_logic_kit/atp/hets_backend.py +339 -0
- unicode_logic_kit/atp/hybrid_down.py +120 -0
- unicode_logic_kit/atp/incremental.py +250 -0
- unicode_logic_kit/atp/kripke_enum.py +741 -0
- unicode_logic_kit/atp/lambek.py +436 -0
- unicode_logic_kit/atp/leo3_backend.py +332 -0
- unicode_logic_kit/atp/linear.py +738 -0
- unicode_logic_kit/atp/lj.py +705 -0
- unicode_logic_kit/atp/logic_backends.py +566 -0
- unicode_logic_kit/atp/ltl_tableau.py +1084 -0
- unicode_logic_kit/atp/minizinc_backend.py +1402 -0
- unicode_logic_kit/atp/modal_tableau.py +1382 -0
- unicode_logic_kit/atp/nanocop_backend.py +410 -0
- unicode_logic_kit/atp/portfolio.py +489 -0
- unicode_logic_kit/atp/protocol.py +1803 -0
- unicode_logic_kit/atp/prover9_entailment.py +1153 -0
- unicode_logic_kit/atp/resolution.py +1376 -0
- unicode_logic_kit/atp/resolution_check.py +1114 -0
- unicode_logic_kit/atp/sequent.py +1050 -0
- unicode_logic_kit/atp/tableau.py +921 -0
- unicode_logic_kit/atp/tableau_check.py +543 -0
- unicode_logic_kit/atp/tptp_ncl.py +811 -0
- unicode_logic_kit/atp/tptp_tff.py +1546 -0
- unicode_logic_kit/atp/tstp.py +1333 -0
- unicode_logic_kit/atp/tstp_check.py +1096 -0
- unicode_logic_kit/atp/twee_backend.py +236 -0
- unicode_logic_kit/atp/twee_check.py +711 -0
- unicode_logic_kit/atp/twee_entailment.py +953 -0
- unicode_logic_kit/atp/vampire_entailment.py +540 -0
- unicode_logic_kit/atp/z3_arith.py +470 -0
- unicode_logic_kit/atp/z3_equivalence.py +36 -0
- unicode_logic_kit/atp/z3_fuzzy.py +362 -0
- unicode_logic_kit/atp/z3_input.py +500 -0
- unicode_logic_kit/atp/z3_models.py +208 -0
- unicode_logic_kit/chem/__init__.py +88 -0
- unicode_logic_kit/chem/_naming.py +284 -0
- unicode_logic_kit/chem/cache.py +185 -0
- unicode_logic_kit/chem/interop.py +244 -0
- unicode_logic_kit/chem/mol.py +525 -0
- unicode_logic_kit/chem/signature.py +112 -0
- unicode_logic_kit/comorphism.py +497 -0
- unicode_logic_kit/dl/__init__.py +384 -0
- unicode_logic_kit/dl/classification.py +227 -0
- unicode_logic_kit/dl/concepts.py +632 -0
- unicode_logic_kit/dl/datatypes.py +818 -0
- unicode_logic_kit/dl/owl_functional.py +2433 -0
- unicode_logic_kit/dl/owl_manchester.py +1637 -0
- unicode_logic_kit/dl/owl_reasoner.py +790 -0
- unicode_logic_kit/dl/parser.py +391 -0
- unicode_logic_kit/dl/tableau.py +4048 -0
- unicode_logic_kit/dl/translate.py +2704 -0
- unicode_logic_kit/drt/__init__.py +94 -0
- unicode_logic_kit/drt/export.py +179 -0
- unicode_logic_kit/drt/nodes.py +506 -0
- unicode_logic_kit/drt/parser.py +965 -0
- unicode_logic_kit/drt/resolve.py +195 -0
- unicode_logic_kit/drt/reverse.py +175 -0
- unicode_logic_kit/eval/__init__.py +106 -0
- unicode_logic_kit/eval/batch.py +382 -0
- unicode_logic_kit/eval/canonical.py +663 -0
- unicode_logic_kit/eval/chem_batch.py +606 -0
- unicode_logic_kit/eval/converses.py +200 -0
- unicode_logic_kit/eval/datasets/__init__.py +136 -0
- unicode_logic_kit/eval/datasets/_base.py +263 -0
- unicode_logic_kit/eval/datasets/_proofwriter_proof.py +422 -0
- unicode_logic_kit/eval/datasets/c3po.py +678 -0
- unicode_logic_kit/eval/datasets/folio.py +158 -0
- unicode_logic_kit/eval/datasets/fracas.py +418 -0
- unicode_logic_kit/eval/datasets/groves.py +191 -0
- unicode_logic_kit/eval/datasets/logicbench.py +467 -0
- unicode_logic_kit/eval/datasets/logicnli.py +303 -0
- unicode_logic_kit/eval/datasets/malls.py +133 -0
- unicode_logic_kit/eval/datasets/pfolio.py +594 -0
- unicode_logic_kit/eval/datasets/pmb.py +242 -0
- unicode_logic_kit/eval/datasets/prontoqa.py +611 -0
- unicode_logic_kit/eval/datasets/proofwriter.py +1431 -0
- unicode_logic_kit/eval/datasets/proverqa.py +674 -0
- unicode_logic_kit/eval/datasets/willow.py +478 -0
- unicode_logic_kit/eval/equivalence.py +466 -0
- unicode_logic_kit/eval/exercise_gen.py +533 -0
- unicode_logic_kit/eval/explain.py +791 -0
- unicode_logic_kit/eval/generality.py +750 -0
- unicode_logic_kit/eval/metric_hf.py +458 -0
- unicode_logic_kit/eval/predicate_match.py +343 -0
- unicode_logic_kit/eval/theory_check.py +1170 -0
- unicode_logic_kit/eval/validate.py +306 -0
- unicode_logic_kit/fol/__init__.py +177 -0
- unicode_logic_kit/fol/_atom_keys.py +510 -0
- unicode_logic_kit/fol/_fol_nodes.py +3586 -0
- unicode_logic_kit/fol/_free_parameters.py +105 -0
- unicode_logic_kit/fol/_ho_nodes.py +448 -0
- unicode_logic_kit/fol/_hybrid_nodes.py +308 -0
- unicode_logic_kit/fol/_identifiers.py +1091 -0
- unicode_logic_kit/fol/_lambek_nodes.py +112 -0
- unicode_logic_kit/fol/_linear_nodes.py +352 -0
- unicode_logic_kit/fol/_modal_nodes.py +1467 -0
- unicode_logic_kit/fol/_msfl_nodes.py +2196 -0
- unicode_logic_kit/fol/_numeral_symbols.py +231 -0
- unicode_logic_kit/fol/_so_nodes.py +200 -0
- unicode_logic_kit/fol/_symbol_names.py +81 -0
- unicode_logic_kit/fol/_team_nodes.py +181 -0
- unicode_logic_kit/fol/_tptp_symbols.py +551 -0
- unicode_logic_kit/fol/_truth_constants.py +117 -0
- unicode_logic_kit/fol/casl_export.py +1135 -0
- unicode_logic_kit/fol/casl_import.py +929 -0
- unicode_logic_kit/fol/derivation.py +367 -0
- unicode_logic_kit/fol/dialect_detect.py +70 -0
- unicode_logic_kit/fol/dialect_repair.py +537 -0
- unicode_logic_kit/fol/frames.py +637 -0
- unicode_logic_kit/fol/grammars/terminals.lark +31 -0
- unicode_logic_kit/fol/lambda_tools.py +297 -0
- unicode_logic_kit/fol/latex_input.py +429 -0
- unicode_logic_kit/fol/modal_translation.py +944 -0
- unicode_logic_kit/fol/msflparser.py +1033 -0
- unicode_logic_kit/fol/naming.py +422 -0
- unicode_logic_kit/fol/nodes.py +241 -0
- unicode_logic_kit/fol/normalforms.py +492 -0
- unicode_logic_kit/fol/pal.py +287 -0
- unicode_logic_kit/fol/prolog_export.py +566 -0
- unicode_logic_kit/fol/prolog_input.py +505 -0
- unicode_logic_kit/fol/prover9_input.py +1325 -0
- unicode_logic_kit/fol/qml.py +1760 -0
- unicode_logic_kit/fol/qmltp_input.py +525 -0
- unicode_logic_kit/fol/sanitize.py +221 -0
- unicode_logic_kit/fol/serialize.py +79 -0
- unicode_logic_kit/fol/signature.py +1290 -0
- unicode_logic_kit/fol/simplify_check.py +544 -0
- unicode_logic_kit/fol/spans.py +594 -0
- unicode_logic_kit/fol/tptp_input.py +1503 -0
- unicode_logic_kit/fol/tptp_repair.py +941 -0
- unicode_logic_kit/fol/unification.py +157 -0
- unicode_logic_kit/fol/verbalize.py +263 -0
- unicode_logic_kit/hets/__init__.py +163 -0
- unicode_logic_kit/hets/bridge.py +142 -0
- unicode_logic_kit/hets/client.py +748 -0
- unicode_logic_kit/hets/docker.py +420 -0
- unicode_logic_kit/hets/dol.py +712 -0
- unicode_logic_kit/hets/haskell_json.py +355 -0
- unicode_logic_kit/hets/owl_backend.py +794 -0
- unicode_logic_kit/hets/owl_cli.py +598 -0
- unicode_logic_kit/hets/symbols.py +512 -0
- unicode_logic_kit/hol/__init__.py +140 -0
- unicode_logic_kit/hol/_ho_common.py +323 -0
- unicode_logic_kit/hol/_isabelle_binders.py +125 -0
- unicode_logic_kit/hol/classical.py +812 -0
- unicode_logic_kit/hol/deepshallow/__init__.py +45 -0
- unicode_logic_kit/hol/deepshallow/_common.py +177 -0
- unicode_logic_kit/hol/deepshallow/conditional.py +225 -0
- unicode_logic_kit/hol/deepshallow/intuitionistic.py +181 -0
- unicode_logic_kit/hol/deepshallow/modal.py +217 -0
- unicode_logic_kit/hol/deepshallow/qml.py +406 -0
- unicode_logic_kit/hol/deepshallow/relevant.py +206 -0
- unicode_logic_kit/hol/free.py +753 -0
- unicode_logic_kit/hol/goedel.py +336 -0
- unicode_logic_kit/hol/ho_modal.py +1743 -0
- unicode_logic_kit/hol/intuitionistic.py +403 -0
- unicode_logic_kit/hol/isabelle_conditional.py +593 -0
- unicode_logic_kit/hol/isabelle_modal.py +1908 -0
- unicode_logic_kit/hol/isabelle_relevant.py +412 -0
- unicode_logic_kit/hol/isabelle_runner.py +1147 -0
- unicode_logic_kit/hol/isabelle_substructural.py +884 -0
- unicode_logic_kit/hol/lean.py +1018 -0
- unicode_logic_kit/hol/manyvalued.py +921 -0
- unicode_logic_kit/hol/secondorder.py +687 -0
- unicode_logic_kit/hol/thf_modal.py +941 -0
- unicode_logic_kit/hol/thirdorder.py +397 -0
- unicode_logic_kit/ilp/__init__.py +89 -0
- unicode_logic_kit/ilp/readback.py +389 -0
- unicode_logic_kit/ilp/separation.py +153 -0
- unicode_logic_kit/ilp/task.py +730 -0
- unicode_logic_kit/logic.py +163 -0
- unicode_logic_kit/mcp/__init__.py +28 -0
- unicode_logic_kit/mcp/__main__.py +5 -0
- unicode_logic_kit/mcp/chem_tools.py +1031 -0
- unicode_logic_kit/mcp/server.py +2453 -0
- unicode_logic_kit/mcp/syntax_spec.py +681 -0
- unicode_logic_kit/prob/__init__.py +53 -0
- unicode_logic_kit/prob/_bdd.py +225 -0
- unicode_logic_kit/prob/_column_gen.py +668 -0
- unicode_logic_kit/prob/distribution.py +686 -0
- unicode_logic_kit/prob/nilsson.py +470 -0
- unicode_logic_kit/py.typed +0 -0
- unicode_logic_kit/semantics/__init__.py +137 -0
- unicode_logic_kit/semantics/_modal_reject.py +156 -0
- unicode_logic_kit/semantics/action_models.py +466 -0
- unicode_logic_kit/semantics/asp_models.py +1200 -0
- unicode_logic_kit/semantics/conditional.py +580 -0
- unicode_logic_kit/semantics/dynamic_epistemic.py +95 -0
- unicode_logic_kit/semantics/free_logic.py +913 -0
- unicode_logic_kit/semantics/fuzzy.py +384 -0
- unicode_logic_kit/semantics/fuzzy_kripke.py +442 -0
- unicode_logic_kit/semantics/intuitionistic.py +581 -0
- unicode_logic_kit/semantics/kripke.py +1139 -0
- unicode_logic_kit/semantics/manyvalued.py +580 -0
- unicode_logic_kit/semantics/matrix.py +342 -0
- unicode_logic_kit/semantics/model_eval.py +1135 -0
- unicode_logic_kit/semantics/modelfinder.py +1036 -0
- unicode_logic_kit/semantics/nonmonotonic.py +372 -0
- unicode_logic_kit/semantics/relevant.py +331 -0
- unicode_logic_kit/semantics/secondorder.py +657 -0
- unicode_logic_kit/semantics/structures.py +352 -0
- unicode_logic_kit/semantics/tarski.py +975 -0
- unicode_logic_kit/semantics/team.py +315 -0
- unicode_logic_kit/semantics/team_translation.py +416 -0
- unicode_logic_kit/semantics/thirdorder.py +358 -0
- unicode_logic_kit/semantics/tnorm.py +85 -0
- unicode_logic_kit/semantics/truthtable.py +201 -0
- unicode_logic_kit-0.31.0.dist-info/METADATA +333 -0
- unicode_logic_kit-0.31.0.dist-info/RECORD +237 -0
- unicode_logic_kit-0.31.0.dist-info/WHEEL +4 -0
- 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)
|