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,1382 @@
1
+ """Labelled analytic tableaux for the propositional modal family.
2
+
3
+ The classical :mod:`unicode_logic_kit.atp.tableau` engine has no rule for a modal
4
+ operator — a ``Box`` / ``Knows`` / ``Obligatory`` node makes it raise. This module
5
+ fills that gap with a **labelled** (world-prefixed) tableau: a branch is a set of
6
+ *labelled* formulas ``w: φ`` (worlds are integers) together with the accessibility
7
+ edges generated along the way. The propositional / connective rules act at a fixed
8
+ world; the modal rules move between worlds:
9
+
10
+ - ``w: □φ`` (a *box* over its relation) asserts ``v: φ`` at every successor ``v`` of
11
+ ``w`` — and is re-applied whenever a new successor appears;
12
+ - ``w: ◇φ`` (a *diamond*) creates a **fresh** successor ``v`` with ``v: φ``;
13
+ - a negated box becomes a diamond of the negation and vice versa (``¬□φ ≡ ◇¬φ``).
14
+
15
+ The box/diamond family handled here is exactly the one with a single accessibility
16
+ relation: alethic ``□``/``◇``, epistemic ``K_a``, doxastic ``B_a``, deontic
17
+ ``O``/``P``, and the one-step temporal ``X`` (``Next``). Two of the three
18
+ GROUP-epistemic operators join that family too: ``EverybodyKnows`` (E_G, alpha-
19
+ reduced into one ``Knows`` box per agent) and ``DistributedKnowledge`` (D_G, a box
20
+ over the intersection of the group's per-agent relations, POSITIVE occurrences
21
+ only — see the comment above ``_distributed_relname``). ``CommonKnowledge`` (C_G)
22
+ needs the reflexive-transitive closure of a union relation, the same
23
+ least/greatest-fixpoint machinery ``Always``/``Eventually``/``Until`` need, so it
24
+ is rejected the same way they are (below). The relation names match the
25
+ :class:`~unicode_logic_kit.semantics.kripke.KripkeModel` convention
26
+ (``"alethic"`` / ``"K:"+a`` / ``"B:"+a`` / ``"deontic"`` / ``"temporal"``), so an open
27
+ branch is read off directly as a Kripke counter-model. The temporal *closure*
28
+ operators ``Always`` (G), ``Eventually`` (F) and ``Until`` need least/greatest-fixpoint
29
+ (eventuality) machinery beyond a basic labelled tableau and are rejected with a pointer
30
+ to :func:`~unicode_logic_kit.semantics.kripke.satisfies_modal` /
31
+ :func:`~unicode_logic_kit.hol.isabelle_runner.isabelle_decide_modal`. Hybrid constructs
32
+ (``Nominal`` / ``At``) are likewise rejected — a nominal's name-exactly-one-world
33
+ constraint has no rule here; use ``hybrid_is_valid`` (the standard translation + Z3)
34
+ or evaluate in a ``KripkeModel`` with a ``nominals=`` assignment.
35
+
36
+ **Public announcement logic (PAL)** — the ``Announce``/``AnnounceDiamond`` nodes
37
+ (``[φ!]ψ`` / ``⟨φ!⟩ψ``, :mod:`unicode_logic_kit.fol._modal_nodes`) — is decided too,
38
+ via a PRE-PASS: every public entry point below runs its formula(s) through
39
+ :func:`unicode_logic_kit.fol.pal.reduce_announcements` first (see :func:`_run`),
40
+ which eliminates every announcement using the standard PAL reduction axioms
41
+ before this tableau ever sees the formula. So ``modal_decide``/``modal_prove``/
42
+ ``is_modal_valid``/``modal_countermodel`` all decide genuine PAL formulas — e.g.
43
+ the reduction axiom ``[φ!]K_aψ ↔ (φ → K_a[φ!]ψ)`` is K-valid, while the famous
44
+ NON-theorem ``[φ!]K_aψ → K_a[φ!]ψ`` is not (see ``tests/test_pal.py``) — with no
45
+ PAL-specific rule in this module at all.
46
+
47
+ **Sorted constants.** ``c:S`` denotes an element of ``S`` at every world (a constant is a
48
+ rigid designator; ``semantics.kripke``'s module docstring). The annotation does not make a
49
+ second symbol, so ``Mortal(c:S)`` and ``Mortal(c)`` are one letter, and the guard atom
50
+ ``S(c)`` is a letter that is true at EVERY world: a world holding ``¬S(c)`` closes its
51
+ branch, and a model read off an open branch makes ``S(c)`` true everywhere. Without that a
52
+ valid formula such as ``S(c:S)`` had an open branch whose model left ``c`` out of ``S``, and
53
+ :func:`modal_decide` called it invalid. A sorted QUANTIFIER stays an opaque literal.
54
+
55
+ **Frame conditions** are realised as structural rules over the edge set: reflexivity
56
+ adds ``w → w`` for every world, symmetry mirrors each edge, transitivity takes the
57
+ closure, the euclidean rule closes ``w→v, w→u ⊢ v→u``, and seriality manufactures a
58
+ successor for a world that has a box obligation and lacks one. The named systems are K, T,
59
+ D/KD, B/KB, K4, K45, S4, S5, KD45.
60
+
61
+ **The model is a model of the frame.** The branch the search leaves open is not yet a
62
+ structure of the frame class that was asked for: a world that has no box obligation needs no
63
+ successor for the formula, but a serial relation gives it one. The model that is read off
64
+ therefore lets every such dead end of a serial relation see itself. A self-loop at a world
65
+ without a successor adds no obligation (the world has no box of that relation to satisfy) and
66
+ keeps a transitive, symmetric or euclidean relation closed. A relation the formula reads
67
+ but the branch never used (its operator sits in a disjunct the branch does not take) has no
68
+ edge on the branch, and a relation the model does not list is the empty relation, which is
69
+ neither reflexive nor serial: it is given the loops its system asks for as well, so every
70
+ relation the formula reads is a relation of the frame class. A relation the formula does not
71
+ read is not made up: nothing the formula says could tell the difference. The loops can change
72
+ the value of one construct: a distributed-knowledge box ``D_G`` reads the intersection of
73
+ several relations, and a world that is a dead end in each of them then gains an edge in the
74
+ intersection. A model is therefore handed out only when it falsifies the formula
75
+ (:func:`satisfies_modal`, which evaluates the relations as they are and does not know the
76
+ frame) AND every relation of it, and every relation the formula reads, satisfies the frame
77
+ conditions of its system; if either check fails, no model is handed out and the answer is
78
+ ``"unknown"``. The first check is made at each open branch already, so a branch whose model
79
+ does not falsify the formula (it holds a construct this tableau has no rule for, such as a
80
+ negated ``D_G``) does not end the search while another branch is left.
81
+
82
+ **Equality is NOT interpreted here.** ``a = b`` / ``a ≠ b`` need a semantics of TERMS —
83
+ what ``a`` and ``b`` denote, so that the atom can be decided as identity of those
84
+ denotations — and this tableau has none: an atom is a propositional letter, a branch
85
+ closes on a syntactic complement, and an open branch is read off as a valuation of
86
+ rendered atom keys. Run on ``a = a`` it would leave the branch for ``¬(a = a)`` open
87
+ (``is_modal_valid`` False, ``modal_decide`` "unknown") although identity is reflexive,
88
+ and on ``a = b → □(a = b)`` it would build a counter-model in which identity varies
89
+ from world to world — both against :func:`unicode_logic_kit.fol.qml.qml_is_valid`, where
90
+ ``=`` is RIGID identity over the object domain. Reading identity as an uninterpreted
91
+ relation is an approximation this kit refuses, so every public entry point raises
92
+ ``NotImplementedError`` naming the atom (the shared
93
+ :func:`~unicode_logic_kit.semantics._modal_reject.reject_equality`, the same refusal
94
+ :func:`~unicode_logic_kit.semantics.kripke.satisfies_modal` gives). The refusal is a
95
+ whole-formula scan at entry (:func:`_run`), BEFORE any search, and never left to the
96
+ search reaching an ``Atom``: a branch that closes on an unrelated contradiction, a
97
+ vacuous ``□`` at a dead end, or a tautologous disjunct would
98
+ otherwise return a verdict that never looked at the atom. Decide identity with
99
+ ``fol.qml.qml_is_valid`` or another first-order route.
100
+
101
+ **Cross-family bridges are NOT supported here.** A frame condition relating two
102
+ DIFFERENT relations — ``rb ⊆ rk`` (``K_a φ → B_a φ``), ``rb ⊆ rs``
103
+ (``Say_a φ → B_a φ``), ``∀w ∃v. d w v ∧ r w v`` (``Oφ → ◇φ``) — would need edge
104
+ rules that copy or jointly witness across relations, and this tableau has none:
105
+ every structural rule above acts within a single relation. Rather than accept a
106
+ ``bridges=`` request and quietly decide a *different* (weaker) logic, every public
107
+ entry point takes ``bridges=None`` and **raises** ``NotImplementedError`` naming the
108
+ routes that do implement the option — ``fol.qml.qml_is_valid`` /
109
+ ``fol.qml.qml_axioms`` and ``hol.isabelle_modal.to_isabelle_modal`` /
110
+ ``hol.thf_modal.to_thf_modal_full``. Without the guard a caller that threaded
111
+ ``bridges=`` through the toolkit would get ``invalid`` here and ``valid`` there for
112
+ the same formula, which is precisely the cross-route disagreement the option was
113
+ introduced to remove.
114
+
115
+ **Soundness vs. completeness.** Every rule preserves satisfiability over its frame
116
+ class, so a *closed* tableau is a real proof — ``is_modal_valid`` only returns ``True``
117
+ when the tableau closes. Termination on the transitive logics relies on subset
118
+ *blocking*, and the whole search is bounded (``max_worlds`` / ``max_steps`` and an optional
119
+ wall-clock ``timeout`` in milliseconds, which every public entry point takes and the search
120
+ checks at every step; a bound hit is ``"unknown"``, never an exception); to keep
121
+ the *invalid* verdict trustworthy regardless of any blocking/bound effect, an open
122
+ branch's model is **verified** with :func:`satisfies_modal` before it is reported, and
123
+ a model that fails to falsify the formula downgrades the answer to ``"unknown"`` rather
124
+ than risk a wrong ``"invalid"``. The result is the same valid / invalid / unknown
125
+ contract as the local-Isabelle runner, but in-process and install-free.
126
+
127
+ Public API: :func:`modal_tableau_closed`, :func:`is_modal_valid`, :func:`modal_prove`,
128
+ :func:`modal_decide`, :func:`modal_countermodel`.
129
+ """
130
+
131
+ import time
132
+ from typing import List, Optional, Tuple
133
+
134
+ from .._deadline import DeadlineReached
135
+ from ..fol.nodes import (
136
+ Node, Atom, Not, And, Or, Xor, Implies, Iff, Contrast,
137
+ Box, Diamond, Knows, Believes, Says, Wants, Obligatory, Permitted,
138
+ EverybodyKnows, DistributedKnowledge, CommonKnowledge,
139
+ Next, Always, Eventually, Until,
140
+ Historically, Once, Previous, Since,
141
+ Nominal, At, Would, Might,
142
+ Quantifier, SortedQuantifier, Count, SortedCount,
143
+ Cardinality, SortedCardinality, SecondOrderQuantifier,
144
+ )
145
+ from ..fol._modal_nodes import Announce, AnnounceDiamond
146
+ # Down (the ↓ binder, N1) is not yet re-exported through fol.nodes / fol's
147
+ # public __init__ / the top-level unicode_logic_kit package (that three-file
148
+ # edit is outside this change's file ownership — see the change's own
149
+ # report); imported directly from its defining module in the meantime, the
150
+ # same class object either import path would give.
151
+ from ..fol._hybrid_nodes import Down
152
+ from ..fol.pal import reduce_announcements
153
+ from ..semantics.kripke import KripkeModel, satisfies_modal
154
+ from ..semantics._modal_reject import reject_equality_in
155
+ from ..fol.frames import (
156
+ FRAME_CONDITIONS, FRAMES as _SHARED_FRAMES,
157
+ UnsupportedFrameCondition, resolve_frame, require_supported,
158
+ )
159
+ from ..fol._atom_keys import refuse_alike_agents
160
+ from ..fol._msfl_nodes import key_text
161
+ from ..fol._truth_constants import is_true_constant, is_truth_constant
162
+ from .fitch import is_falsum
163
+ from .lj import _forget_constant_sorts
164
+
165
+
166
+ # Relation names — the contract with semantics.kripke.KripkeModel.
167
+ _ALETHIC = "alethic"
168
+ _DEONTIC = "deontic"
169
+ _TEMPORAL = "temporal"
170
+ _KNOWS = "K:"
171
+ _BELIEVES = "B:"
172
+ _SAYS = "Say:"
173
+ _WANTS = "Want:"
174
+
175
+ #: The named modal systems, shared with every other route
176
+ #: (:mod:`unicode_logic_kit.fol.frames`). This tableau implements the rules for
177
+ #: five of the conditions in that registry — reflexivity, transitivity,
178
+ #: symmetry, seriality, euclideanness — and REFUSES the rest by name
179
+ #: (:data:`_TABLEAU_CONDITIONS` / :func:`_check_frame`): a labelled tableau
180
+ #: for density or partial functionality needs rules this module does not
181
+ #: have, and quietly dropping the condition would answer about a larger
182
+ #: frame class than the caller asked for.
183
+ _FRAMES = _SHARED_FRAMES
184
+
185
+ #: The frame conditions this tableau has sound rules for.
186
+ _TABLEAU_CONDITIONS = frozenset({"refl", "trans", "sym", "serial", "eucl"})
187
+
188
+ # Operators needing least/greatest-fixpoint (eventuality) or converse-relation
189
+ # machinery beyond this labelled tableau — routed to satisfies_modal / Isabelle.
190
+ _TEMPORAL_CLOSURE = (Always, Eventually, Until, Historically, Once, Previous, Since)
191
+
192
+
193
+ def _agent_key(agent: Node) -> str:
194
+ """Relation-key suffix for an epistemic/doxastic agent term (its name)."""
195
+ return getattr(agent, "name", None) or key_text(agent)
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Distributed knowledge (D_G): a box over the INTERSECTION of the group's
200
+ # per-agent "K:"+agent relations.
201
+ # ---------------------------------------------------------------------------
202
+ #
203
+ # Every OTHER box/diamond rule here acts on ONE named relation (`_apply_boxes`
204
+ # just pushes a box's body to `b.rels[relname]`'s successors; `_frame_close`
205
+ # closes ONE relation's edges under its own frame conditions). Distributed
206
+ # knowledge needs a box over several relations' INTERSECTION at once, which
207
+ # is not a relation _frame_close (or anything else here) already builds. The
208
+ # fix is additive rather than new machinery in the search loop itself: a D_G
209
+ # box is filed under a SYNTHETIC relation name that encodes its own
210
+ # constituent "K:"+agent names (`_distributed_relname`), and `_close_distributed`
211
+ # — run to fixpoint alongside `_frame_close`/`_apply_boxes` in `_solve` — keeps
212
+ # that synthetic relation's edge set equal to the intersection of its
213
+ # constituents' CURRENT edges every round (constituents only ever GROW during
214
+ # search — frame closure, or a nested diamond inside one of them — so this
215
+ # recompute is monotonic and terminates for exactly the reason `_frame_close`'s
216
+ # own transitive closure does). satisfies_modal itself never reads a "D∩:…"
217
+ # key — semantics.action_models.distributed_knowledge_holds recomputes the
218
+ # SAME intersection directly from "K:"+agent — so the synthetic name is purely
219
+ # an internal tableau bookkeeping device; `_build_model` happening to carry it
220
+ # along into the returned KripkeModel is harmless (an extra relation entry
221
+ # nothing downstream queries).
222
+ #
223
+ # A constituent "K:"+agent relation must also be FRAME-CLOSED under the
224
+ # caller's requested epistemic system (S5 reflexivity, etc.) even when the
225
+ # agent is mentioned nowhere else in the formula — otherwise D_G's box would
226
+ # quantify over an intersection of relations the search never applied the
227
+ # requested frame conditions to, which is unsound the moment any of those
228
+ # conditions matters (e.g. S5 factivity: D_a P → P needs "K:a" reflexive).
229
+ # `_frame_close` only ever looks at `_relnames(b)` (`b.rels`'s keys plus box
230
+ # relation names), so `_close_distributed` REGISTERS each constituent in
231
+ # `b.rels` (even with no edges yet) the first time it sees a synthetic D∩ key
232
+ # — this alone makes the constituent "live" for the next `_frame_close` pass,
233
+ # which is why the registration also reports `changed`: the `_solve` fixpoint
234
+ # loop (`_frame_close` / `_close_distributed` / `_apply_boxes`) must run one
235
+ # more round for that newly-live relation to actually pick up its reflexive/
236
+ # transitive/etc. edges before the intersection is (re)computed from them.
237
+ #
238
+ # Only the POSITIVE (box) occurrence of D_G is given a rule: the negated form
239
+ # ¬D_G φ (a diamond over the intersection) would need a fresh witness world
240
+ # added to EVERY constituent relation at once — the generic diamond rule in
241
+ # `_solve` adds an edge to exactly ONE named relation, and adding it only to
242
+ # the synthetic key would just be erased by the next `_close_distributed`
243
+ # pass (it is not yet in every constituent). Rather than special-case the
244
+ # generic diamond-witness step for one synthetic relation family, ¬D_G is left
245
+ # `"unsupported"` (inert, sound, see `_expand_simple`) — this still decides
246
+ # every validity of the shape `D_G φ → …` (the useful direction: D_G occurs
247
+ # POSITIVELY once its negation-as-premise is pushed through), which is what
248
+ # the box rule below is for.
249
+ _DISTRIBUTED_PREFIX = "D∩:"
250
+ _DISTRIBUTED_SEP = "|"
251
+
252
+
253
+ def _distributed_relname(agent_keys) -> str:
254
+ """Synthetic relation name for a D_G box: encodes its own "K:"+agent
255
+ constituents (sorted, so the same group always yields the same key)."""
256
+ names = sorted(_KNOWS + a for a in agent_keys)
257
+ return _DISTRIBUTED_PREFIX + _DISTRIBUTED_SEP.join(names)
258
+
259
+
260
+ def _distributed_constituents(relname: str):
261
+ """Return the constituent relation names a synthetic D∩ key encodes, or
262
+ None if ``relname`` is not one (an ordinary relation name never starts
263
+ with "D∩:", since that prefix is not producible by any agent name — agent
264
+ names lex as VARIABLE/NAME, which cannot contain "∩")."""
265
+ if not relname.startswith(_DISTRIBUTED_PREFIX):
266
+ return None
267
+ return relname[len(_DISTRIBUTED_PREFIX):].split(_DISTRIBUTED_SEP)
268
+
269
+
270
+ def _close_distributed(b: "_Branch") -> bool:
271
+ """Recompute every synthetic D∩ relation as the intersection of its
272
+ constituents' CURRENT edges; return True iff any edge set changed.
273
+
274
+ Also REGISTERS every constituent "K:"+agent relation in ``b.rels`` (with
275
+ no edges, if it has none yet) the first time it is seen — this makes it
276
+ 'live' for `_frame_close` (see the module comment above), which is what
277
+ lets an agent whose only mention is inside this D_G box still receive the
278
+ caller's requested epistemic frame conditions (S5 reflexivity and so on).
279
+ Registering a previously-absent constituent counts as a change so the
280
+ `_solve` fixpoint loop runs `_frame_close` again before the intersection
281
+ below is treated as final.
282
+ """
283
+ changed = False
284
+ for relname in list(_relnames(b)):
285
+ constituents = _distributed_constituents(relname)
286
+ if constituents is None:
287
+ continue
288
+ for name in constituents:
289
+ if name not in b.rels:
290
+ b.rels[name] = set()
291
+ changed = True
292
+ inter = None
293
+ for name in constituents:
294
+ edges = b.rels.get(name, set())
295
+ inter = set(edges) if inter is None else (inter & edges)
296
+ inter = inter if inter is not None else set()
297
+ if b.rels.get(relname) != inter:
298
+ b.rels[relname] = inter
299
+ changed = True
300
+ return changed
301
+
302
+
303
+ def _neg(f: Node) -> Node:
304
+ """Return the complementary formula of ``f`` (``¬φ`` ↔ ``φ``)."""
305
+ return f.formula if isinstance(f, Not) else Not(f)
306
+
307
+
308
+ def has_modal(node: Node) -> bool:
309
+ """True iff ``node`` contains any modal/temporal/epistemic/deontic/hybrid
310
+ operator — or a counterfactual — or a public-announcement operator.
311
+
312
+ Hybrid constructs (Nominal / At), the Lewis counterfactuals (Would / Might),
313
+ and the PAL announcement operators (Announce / AnnounceDiamond) count as
314
+ modal so the classical tableau routes them here, where each gets its clean,
315
+ specific rejection (or, for Announce/AnnounceDiamond, its pal.reduce_announcements
316
+ pre-pass — see :func:`_run`) instead of a generic no-rule error.
317
+ """
318
+ modal = (Box, Diamond, Knows, Believes, Says, Wants, Obligatory, Permitted,
319
+ EverybodyKnows, DistributedKnowledge, CommonKnowledge,
320
+ Next, Always, Eventually, Until,
321
+ Historically, Once, Previous, Since,
322
+ Nominal, At, Down, Would, Might, Announce, AnnounceDiamond)
323
+ return any(isinstance(n, modal) for n in node.walk())
324
+
325
+
326
+ def _contains_hybrid(node: Node) -> bool:
327
+ """True iff ``node`` contains a hybrid construct (a Nominal or an At)."""
328
+ return any(isinstance(n, (Nominal, At)) for n in node.walk())
329
+
330
+
331
+ def _contains_down(node: Node) -> bool:
332
+ """True iff ``node`` contains the ↓ binder (N1).
333
+
334
+ ``Down.variable`` is itself a :class:`Nominal` (see fol._hybrid_nodes'
335
+ module docstring), which means ``_contains_hybrid`` above already
336
+ detects every ``Down``-containing formula with ZERO extra code (the
337
+ generic ``Node._child_nodes``/``walk`` traversal is field-VALUE-typed,
338
+ not field-NAME-typed, so it walks straight into ``Down.variable`` too).
339
+ This function exists ONLY so ``_run`` can raise a ↓-SPECIFIC,
340
+ undecidability-naming message ahead of that generic one — Down must
341
+ never fall through to a generic branch (or even a correct-but-vaguer
342
+ one) unnoticed.
343
+ """
344
+ return any(isinstance(n, Down) for n in node.walk())
345
+
346
+
347
+ def _contains_counterfactual(node: Node) -> bool:
348
+ """True iff ``node`` contains a Lewis counterfactual (Would / Might)."""
349
+ return any(isinstance(n, (Would, Might)) for n in node.walk())
350
+
351
+
352
+ #: Constructs that bind an object/predicate variable. The modal tableau is a
353
+ #: ground (propositional-modal) engine with no quantifier rules, so these are
354
+ #: treated as OPAQUE literals: a branch may still close on a syntactic
355
+ #: complement (sound — φ and ¬φ at one world are contradictory whatever φ
356
+ #: means), while an open branch's model must pass satisfies_modal verification
357
+ #: before any caller sees it, so no wrong verdict can arise from the opacity.
358
+ _QUANTIFIED = (Quantifier, SortedQuantifier, Count, SortedCount,
359
+ Cardinality, SortedCardinality, SecondOrderQuantifier)
360
+
361
+
362
+ def _decompose(f: Node):
363
+ """Classify a formula for the tableau.
364
+
365
+ Returns one of:
366
+ ``("lit",)`` — atom / negated atom / ⊥ (closure only);
367
+ ``("true",)`` — ¬⊥ (always true, discard);
368
+ ``("alpha", [comp, …])`` — assert all components at the same world;
369
+ ``("beta", [[…], […]])`` — branch (each list one branch's components);
370
+ ``("box", relname, body)`` — universal modality over ``relname``;
371
+ ``("dia", relname, body)`` — existential modality over ``relname``;
372
+ ``("unsupported", node)`` — a temporal-closure operator (G / F / U).
373
+ """
374
+ if is_falsum(f):
375
+ return ("lit",)
376
+ if is_true_constant(f):
377
+ return ("true",) # `$true` holds at every world: discard
378
+ if isinstance(f, Atom):
379
+ return ("lit",)
380
+ if isinstance(f, _QUANTIFIED):
381
+ # Opaque literal: no quantifier rules here, but syntactic-complement
382
+ # closure stays sound and open models are verified before release.
383
+ return ("lit",)
384
+
385
+ # --- positive modal operators ---
386
+ if isinstance(f, Box):
387
+ return ("box", _ALETHIC, f.formula)
388
+ if isinstance(f, Diamond):
389
+ return ("dia", _ALETHIC, f.formula)
390
+ if isinstance(f, Knows):
391
+ return ("box", _KNOWS + _agent_key(f.agent), f.formula)
392
+ if isinstance(f, Believes):
393
+ return ("box", _BELIEVES + _agent_key(f.agent), f.formula)
394
+ if isinstance(f, Says):
395
+ return ("box", _SAYS + _agent_key(f.agent), f.formula)
396
+ if isinstance(f, Wants):
397
+ return ("box", _WANTS + _agent_key(f.agent), f.formula)
398
+ if isinstance(f, EverybodyKnows):
399
+ # E_G φ ≡ ⋀_{a∈G} K_a φ: an ALPHA reduction into one Knows(a, φ) per
400
+ # agent, each of which the loop re-decomposes into its own box rule
401
+ # on the NEXT pass — no new relation-key machinery needed (see the
402
+ # module-level comment above _distributed_relname for why D_G, unlike
403
+ # E_G, does need one). An empty group alpha-reduces to [] (no
404
+ # components), which the caller treats as "nothing new asserted" —
405
+ # correctly vacuous, matching everybody_knows's own convention.
406
+ return ("alpha", [Knows(a, f.formula) for a in f.group])
407
+ if isinstance(f, DistributedKnowledge):
408
+ # D_G φ: a genuine box, over the SYNTHETIC intersection relation
409
+ # _close_distributed keeps in sync (see the module-level comment).
410
+ agents = tuple(_agent_key(a) for a in f.group)
411
+ return ("box", _distributed_relname(agents), f.formula)
412
+ if isinstance(f, CommonKnowledge):
413
+ # C_G needs the reflexive-transitive closure of a union relation —
414
+ # an induction/fixpoint rule this labelled tableau does not have, the
415
+ # same G/F/U precedent below. Inert, never a wrong verdict (see
416
+ # _expand_simple's "unsupported" branch).
417
+ return ("unsupported", f)
418
+ if isinstance(f, Obligatory):
419
+ return ("box", _DEONTIC, f.formula)
420
+ if isinstance(f, Permitted):
421
+ return ("dia", _DEONTIC, f.formula)
422
+ if isinstance(f, Next):
423
+ return ("box", _TEMPORAL, f.formula)
424
+ if isinstance(f, _TEMPORAL_CLOSURE):
425
+ return ("unsupported", f)
426
+
427
+ # --- positive connectives ---
428
+ if isinstance(f, And):
429
+ return ("alpha", [f.left, f.right])
430
+ if isinstance(f, Contrast):
431
+ # Concession is truth-functionally conjunction (Contrast's own contract).
432
+ return ("alpha", [f.left, f.right])
433
+ if isinstance(f, Or):
434
+ return ("beta", [[f.left], [f.right]])
435
+ if isinstance(f, Implies):
436
+ return ("beta", [[Not(f.left)], [f.right]])
437
+ if isinstance(f, Iff):
438
+ return ("beta", [[f.left, f.right], [Not(f.left), Not(f.right)]])
439
+ if isinstance(f, Xor):
440
+ return ("beta", [[f.left, Not(f.right)], [Not(f.left), f.right]])
441
+
442
+ # --- negations: push through ---
443
+ if isinstance(f, Not):
444
+ g = f.formula
445
+ if is_falsum(g):
446
+ return ("true",)
447
+ if isinstance(g, Atom):
448
+ return ("lit",)
449
+ if isinstance(g, _QUANTIFIED):
450
+ return ("lit",)
451
+ if isinstance(g, Not):
452
+ return ("alpha", [g.formula])
453
+ if isinstance(g, And):
454
+ return ("beta", [[Not(g.left)], [Not(g.right)]])
455
+ if isinstance(g, Contrast):
456
+ return ("beta", [[Not(g.left)], [Not(g.right)]])
457
+ if isinstance(g, Or):
458
+ return ("alpha", [Not(g.left), Not(g.right)])
459
+ if isinstance(g, Implies):
460
+ return ("alpha", [g.left, Not(g.right)])
461
+ if isinstance(g, Iff):
462
+ return ("beta", [[g.left, Not(g.right)], [Not(g.left), g.right]])
463
+ if isinstance(g, Xor):
464
+ return ("beta", [[g.left, g.right], [Not(g.left), Not(g.right)]])
465
+ if isinstance(g, Box):
466
+ return ("dia", _ALETHIC, Not(g.formula))
467
+ if isinstance(g, Diamond):
468
+ return ("box", _ALETHIC, Not(g.formula))
469
+ if isinstance(g, Knows):
470
+ return ("dia", _KNOWS + _agent_key(g.agent), Not(g.formula))
471
+ if isinstance(g, Believes):
472
+ return ("dia", _BELIEVES + _agent_key(g.agent), Not(g.formula))
473
+ if isinstance(g, Says):
474
+ return ("dia", _SAYS + _agent_key(g.agent), Not(g.formula))
475
+ if isinstance(g, Wants):
476
+ return ("dia", _WANTS + _agent_key(g.agent), Not(g.formula))
477
+ if isinstance(g, EverybodyKnows):
478
+ # ¬E_G φ ≡ ⋁_{a∈G} ¬K_a φ: a BETA branch, one option per agent,
479
+ # each a single Not(Knows(a,φ)) component — which this SAME
480
+ # negation section already turns into a diamond rule on its own
481
+ # next pass (the `if isinstance(g, Knows)` case just above,
482
+ # reached because `_decompose` is called again on each freshly
483
+ # asserted Not(Knows(...))). An empty group beta-branches into
484
+ # ZERO options, which `_solve`'s beta handling immediately reports
485
+ # "closed" (no branch to keep the tableau open) — the correct
486
+ # verdict, since ¬E_∅ φ negates an always-vacuously-true E_∅ φ and
487
+ # so is itself unsatisfiable.
488
+ return ("beta", [[Not(Knows(a, g.formula))] for a in g.group])
489
+ if isinstance(g, DistributedKnowledge):
490
+ # ¬D_G φ (a diamond over the intersection relation) has no rule
491
+ # here — see the module-level comment above _distributed_relname
492
+ # for why. Inert, sound.
493
+ return ("unsupported", f)
494
+ if isinstance(g, CommonKnowledge):
495
+ return ("unsupported", f)
496
+ if isinstance(g, Obligatory):
497
+ return ("dia", _DEONTIC, Not(g.formula))
498
+ if isinstance(g, Permitted):
499
+ return ("box", _DEONTIC, Not(g.formula))
500
+ if isinstance(g, Next):
501
+ return ("dia", _TEMPORAL, Not(g.formula))
502
+ if isinstance(g, _TEMPORAL_CLOSURE):
503
+ return ("unsupported", f)
504
+
505
+ raise NotImplementedError(
506
+ f"modal_tableau: no rule for {type(f).__name__} {f.to_unicode_str()}")
507
+
508
+
509
+ class _OrderedSet(dict):
510
+ """A set that iterates in the order its members were first added.
511
+
512
+ The branch keeps each world's formulas and its box obligations in one. The search takes
513
+ "the first" unexpanded branching formula, diamond or box obligation it meets, and which
514
+ one it takes decides the numbering of the worlds it creates and therefore the model it
515
+ reads off an open branch. A ``set`` of formulas (which hash their names) or of tuples
516
+ holding a relation name iterates in an order that follows ``PYTHONHASHSEED``, so the
517
+ same input gave a different countermodel from one process to the next; here the order is
518
+ a function of the sequence of insertions alone, and every insertion is itself made by an
519
+ ordered traversal, so the model is a function of the input. The rules and the bounds are
520
+ those of a plain ``set``: only the order in which they are tried is fixed.
521
+
522
+ A ``dict`` subclass, so membership, iteration and ``len`` keep the speed of the built-in
523
+ container; only the set operations the search uses are spelled out.
524
+ """
525
+
526
+ __slots__ = ()
527
+
528
+ def add(self, member) -> None:
529
+ self[member] = None
530
+
531
+ def copy(self) -> "_OrderedSet":
532
+ duplicate = _OrderedSet()
533
+ dict.update(duplicate, self)
534
+ return duplicate
535
+
536
+ def __le__(self, other) -> bool:
537
+ """Subset test, as for a ``set`` (``other`` may be any set-like)."""
538
+ return self.keys() <= (other.keys() if isinstance(other, dict) else other)
539
+
540
+ def __repr__(self) -> str:
541
+ return f"_OrderedSet({list(self)!r})"
542
+
543
+
544
+ class _Branch:
545
+ """A single open tableau branch: labelled formulas, edges, box obligations."""
546
+
547
+ __slots__ = ("tv", "rels", "boxes", "wcount", "expanded", "facts")
548
+
549
+ def __init__(self):
550
+ self.tv = {0: _OrderedSet()} # world -> the labelled formulas, in insertion order
551
+ self.rels = {} # relname -> set of (w, v) edges
552
+ self.boxes = _OrderedSet() # (world, relname, body) obligations, in insertion order
553
+ self.wcount = 1 # next fresh world id
554
+ self.expanded = set() # (world, formula) already consumed
555
+ self.facts = frozenset() # atoms true at EVERY world (sorted constants' membership)
556
+
557
+ def copy(self) -> "_Branch":
558
+ b = _Branch.__new__(_Branch)
559
+ b.tv = {w: s.copy() for w, s in self.tv.items()}
560
+ b.rels = {r: set(e) for r, e in self.rels.items()}
561
+ b.boxes = self.boxes.copy()
562
+ b.wcount = self.wcount
563
+ b.expanded = set(self.expanded)
564
+ b.facts = self.facts
565
+ return b
566
+
567
+
568
+ class _Ctx:
569
+ """Search budget and frame configuration shared across the branch tree."""
570
+
571
+ def __init__(self, frame: str, systems, max_worlds: int, max_steps: int,
572
+ timeout: Optional[int] = None, mentioned: Tuple[str, ...] = (),
573
+ roots: Tuple[Node, ...] = ()):
574
+ self.frame = frame
575
+ self.systems = systems or {}
576
+ # every relation name the formulas read, whether or not the search used it: the model
577
+ # that is read off has to give each one the shape its system asks for
578
+ self.mentioned = tuple(mentioned)
579
+ # the formulas asserted at the root world: a model read off an open branch has to make
580
+ # them true there, or the branch (which holds a construct it has no rule for) is no help
581
+ self.roots = tuple(roots)
582
+ self.max_worlds = max_worlds
583
+ self.steps = max_steps
584
+ self.exhausted = False
585
+ # ``timeout`` is in milliseconds, counted from the moment the search starts.
586
+ self.deadline = None if timeout is None else time.perf_counter() + timeout / 1000.0
587
+
588
+ def tick(self) -> bool:
589
+ """Charge one step; False once the step budget is gone or the deadline has passed."""
590
+ self.steps -= 1
591
+ if self.steps <= 0 or (self.deadline is not None and time.perf_counter() > self.deadline):
592
+ self.exhausted = True
593
+ self.steps = 0
594
+ return False
595
+ return True
596
+
597
+ def poll(self) -> None:
598
+ """Raise :class:`~unicode_logic_kit._deadline.DeadlineReached` once the deadline has passed.
599
+
600
+ For the loops that run between two :meth:`tick` calls (the frame closure, the box
601
+ rule): they read the clock themselves, so one step of the search cannot outlast the
602
+ deadline however many worlds it has to close. No charge is made against the budget.
603
+ :func:`_run` catches the exception and gives up the whole search.
604
+ """
605
+ if self.deadline is not None and time.perf_counter() > self.deadline:
606
+ self.exhausted = True
607
+ self.steps = 0
608
+ raise DeadlineReached
609
+
610
+ def conds(self, relname: str) -> Tuple[str, ...]:
611
+ """Frame conditions for a relation name, per the configured systems."""
612
+ if relname == _ALETHIC:
613
+ return _FRAMES[self.frame]
614
+ if relname == _DEONTIC:
615
+ return _FRAMES[self.systems.get("deontic", "KD")]
616
+ if relname == _TEMPORAL:
617
+ return _FRAMES[self.systems.get("temporal", "K")]
618
+ if relname.startswith(_KNOWS):
619
+ return _FRAMES[self.systems.get("epistemic", "K")]
620
+ if relname.startswith(_BELIEVES):
621
+ return _FRAMES[self.systems.get("doxastic", "K")]
622
+ return ()
623
+
624
+
625
+ def _assert(b: _Branch, w: int, f: Node) -> bool:
626
+ """Assert ``w: f``; return True iff it was new."""
627
+ s = b.tv.setdefault(w, _OrderedSet())
628
+ if f in s:
629
+ return False
630
+ s.add(f)
631
+ return True
632
+
633
+
634
+ def _closes(b: _Branch) -> bool:
635
+ """True iff some world holds a formula and its negation (or ⊥).
636
+
637
+ An atom of ``b.facts`` is true at EVERY world, so a world that holds its
638
+ negation is contradictory too, whatever else it holds.
639
+ """
640
+ for s in b.tv.values():
641
+ for f in s:
642
+ if is_falsum(f):
643
+ return True
644
+ if isinstance(f, Not) and is_true_constant(f.formula):
645
+ return True # ¬$true is false at every world, like ⊥ and $false
646
+ if _neg(f) in s:
647
+ return True
648
+ if b.facts and isinstance(f, Not) and f.formula in b.facts:
649
+ return True
650
+ return False
651
+
652
+
653
+ def _relations_read_by(node: Node) -> Tuple[str, ...]:
654
+ """The relation names a modal operator reads: ``node`` itself, never its operands.
655
+
656
+ The same names :func:`_decompose` files its box and diamond rules under (the group
657
+ operators read the relation of each agent of the group), and the ones
658
+ :func:`~unicode_logic_kit.semantics.kripke.satisfies_modal` reads in the model. Every other
659
+ node reads none.
660
+ """
661
+ if isinstance(node, (Box, Diamond)):
662
+ return (_ALETHIC,)
663
+ if isinstance(node, (Obligatory, Permitted)):
664
+ return (_DEONTIC,)
665
+ if isinstance(node, (Next,) + _TEMPORAL_CLOSURE):
666
+ return (_TEMPORAL,)
667
+ if isinstance(node, Knows):
668
+ return (_KNOWS + _agent_key(node.agent),)
669
+ if isinstance(node, Believes):
670
+ return (_BELIEVES + _agent_key(node.agent),)
671
+ if isinstance(node, Says):
672
+ return (_SAYS + _agent_key(node.agent),)
673
+ if isinstance(node, Wants):
674
+ return (_WANTS + _agent_key(node.agent),)
675
+ if isinstance(node, (EverybodyKnows, DistributedKnowledge, CommonKnowledge)):
676
+ return tuple(_KNOWS + _agent_key(a) for a in node.group)
677
+ return ()
678
+
679
+
680
+ def _mentioned_relations(formulas) -> Tuple[str, ...]:
681
+ """Every relation name any of ``formulas`` reads, in the order they first occur.
682
+
683
+ A relation is mentioned when an operator that reads it occurs ANYWHERE in a formula,
684
+ also in a disjunct the open branch of the search does not take: the model that is read
685
+ off an open branch has to give that relation the shape its system asks for, though the
686
+ branch has no edge of it.
687
+ """
688
+ names = _OrderedSet()
689
+ for f in formulas:
690
+ for node in f.walk():
691
+ for rel in _relations_read_by(node):
692
+ names.add(rel)
693
+ return tuple(names)
694
+
695
+
696
+ def _relnames(b: _Branch):
697
+ """Relation names that are 'live' on this branch (have edges or box obligations),
698
+ in the order they first appeared (a ``set`` of names would follow the hash seed)."""
699
+ names = _OrderedSet()
700
+ for rel in b.rels:
701
+ names.add(rel)
702
+ for (_w, rel, _body) in b.boxes:
703
+ names.add(rel)
704
+ return names
705
+
706
+
707
+ def _frame_close(b: _Branch, ctx: _Ctx) -> bool:
708
+ """Apply reflexive/symmetric/transitive/euclidean edge rules; return True if changed.
709
+
710
+ The closure of a large edge set takes many times the work of one search step, so it
711
+ reads the clock itself (:meth:`_Ctx.poll`) once per edge it takes up and gives the
712
+ whole search up at the deadline.
713
+ """
714
+ changed = False
715
+ worlds = list(b.tv)
716
+ for rel in list(_relnames(b)):
717
+ conds = ctx.conds(rel)
718
+ if not conds:
719
+ continue
720
+ edges = b.rels.setdefault(rel, set())
721
+ if "refl" in conds:
722
+ for w in worlds:
723
+ ctx.poll()
724
+ if (w, w) not in edges:
725
+ edges.add((w, w))
726
+ changed = True
727
+ if "sym" in conds:
728
+ for (w, v) in list(edges):
729
+ ctx.poll()
730
+ if (v, w) not in edges:
731
+ edges.add((v, w))
732
+ changed = True
733
+ if "eucl" in conds:
734
+ out = {}
735
+ for (w, v) in edges:
736
+ out.setdefault(w, []).append(v)
737
+ for w, succs in out.items():
738
+ for v in succs:
739
+ ctx.poll()
740
+ for u in succs:
741
+ if (v, u) not in edges:
742
+ edges.add((v, u))
743
+ changed = True
744
+ if "trans" in conds:
745
+ added = True
746
+ while added:
747
+ added = False
748
+ for (w, v) in list(edges):
749
+ ctx.poll()
750
+ for (v2, u) in list(edges):
751
+ if v == v2 and (w, u) not in edges:
752
+ edges.add((w, u))
753
+ added = True
754
+ changed = True
755
+ return changed
756
+
757
+
758
+ def _apply_boxes(b: _Branch, ctx: Optional[_Ctx] = None) -> bool:
759
+ """Push every box obligation to its successors; return True if anything was new.
760
+
761
+ With a ``ctx`` the clock is read once per obligation (see :meth:`_Ctx.poll`).
762
+ """
763
+ changed = False
764
+ for (w, rel, body) in list(b.boxes):
765
+ if ctx is not None:
766
+ ctx.poll()
767
+ for (a, v) in b.rels.get(rel, ()):
768
+ if a == w and _assert(b, v, body):
769
+ changed = True
770
+ return changed
771
+
772
+
773
+ def _expand_simple(b: _Branch) -> bool:
774
+ """Apply double-negation / α / box-record rules; return True if anything changed.
775
+
776
+ A temporal-closure operator is marked inert (see the ``unsupported`` branch)
777
+ rather than raising, keeping every verdict sound and every crash impossible.
778
+ """
779
+ changed = False
780
+ for w in list(b.tv):
781
+ for f in list(b.tv[w]):
782
+ if (w, f) in b.expanded:
783
+ continue
784
+ kind = _decompose(f)
785
+ tag = kind[0]
786
+ if tag in ("lit", "true"):
787
+ b.expanded.add((w, f))
788
+ elif tag == "alpha":
789
+ for comp in kind[1]:
790
+ if _assert(b, w, comp):
791
+ changed = True
792
+ b.expanded.add((w, f))
793
+ changed = True
794
+ elif tag == "box":
795
+ _, rel, body = kind
796
+ if (w, rel, body) not in b.boxes:
797
+ b.boxes.add((w, rel, body))
798
+ changed = True
799
+ b.expanded.add((w, f))
800
+ elif tag == "unsupported":
801
+ # A temporal-closure operator (G/F/U/H/P/Y/S over the closure of
802
+ # the temporal relation) has no rule here. Leave it INERT rather
803
+ # than raising: closure of a branch is monotone (a contradiction
804
+ # among the expanded formulas makes the full set unsatisfiable
805
+ # regardless of the inert ones), so "closed" verdicts stay sound;
806
+ # and an open branch's model only ever reaches a caller after
807
+ # satisfies_modal — which evaluates these operators exactly —
808
+ # verifies it, so no spurious countermodel can leak. The price is
809
+ # honest incompleteness: a branch kept open only by an inert
810
+ # formula yields "unknown", never a wrong verdict.
811
+ b.expanded.add((w, f))
812
+ # beta / dia handled by the search loop
813
+ return changed
814
+
815
+
816
+ def _find_beta(b: _Branch):
817
+ """Return ``(w, f, options)`` for an unexpanded branching formula, or None."""
818
+ for w in b.tv:
819
+ for f in b.tv[w]:
820
+ if (w, f) in b.expanded:
821
+ continue
822
+ kind = _decompose(f)
823
+ if kind[0] == "beta":
824
+ return (w, f, kind[1])
825
+ return None
826
+
827
+
828
+ def _find_diamond(b: _Branch):
829
+ """Return ``(w, f, relname, body)`` for an unexpanded diamond, or None."""
830
+ for w in b.tv:
831
+ for f in b.tv[w]:
832
+ if (w, f) in b.expanded:
833
+ continue
834
+ kind = _decompose(f)
835
+ if kind[0] == "dia":
836
+ return (w, f, kind[1], kind[2])
837
+ return None
838
+
839
+
840
+ def _blocked(b: _Branch, w: int, relname: str, ctx: _Ctx) -> bool:
841
+ """Subset-blocking for transitive relations: an earlier world subsumes ``w``.
842
+
843
+ Only applied when the relation is transitive (where unbounded regress is
844
+ otherwise possible); sound for the K4 family and, with the counter-model
845
+ verification downstream, safe for S5/B too.
846
+ """
847
+ if "trans" not in ctx.conds(relname):
848
+ return False
849
+ sw = b.tv.get(w, _OrderedSet())
850
+ for u in b.tv:
851
+ if u < w and sw <= b.tv[u]:
852
+ return True
853
+ return False
854
+
855
+
856
+ def _find_seriality(b: _Branch, ctx: _Ctx):
857
+ """A serial relation with a box obligation at a world that has no successor."""
858
+ for (w, rel, _body) in b.boxes:
859
+ if "serial" not in ctx.conds(rel):
860
+ continue
861
+ if not any(a == w for (a, _v) in b.rels.get(rel, ())):
862
+ return (w, rel)
863
+ return None
864
+
865
+
866
+ def _build_model(b: _Branch, ctx: _Ctx) -> KripkeModel:
867
+ """Read an open saturated branch off as a Kripke model of the frame class of ``ctx``.
868
+
869
+ The valuation is the atoms of each world. A serial relation also needs a successor for a
870
+ world that has none, and the search only gave one to a world with a box obligation, so
871
+ every other dead end of such a relation sees itself here: the self-loop adds no obligation
872
+ (the world has no box of that relation to satisfy) and keeps a transitive, symmetric or
873
+ euclidean relation closed (a world with no successor has no path through it, and a
874
+ euclidean relation with an edge into the world has the loop already).
875
+
876
+ A relation the formulas read but the branch never used (its operator sits in a disjunct
877
+ the branch does not take) has no edge on the branch, and an absent relation is the empty
878
+ relation, which is neither reflexive nor serial. It is completed the same way: when its
879
+ system asks for reflexivity or seriality every world sees itself. Nothing the branch
880
+ asserts reads that relation (an assertion of one of its operators would have made it
881
+ live), so the loops cannot change what the branch makes true; a transitive, symmetric
882
+ or euclidean relation that has only loops is still all three. A relation whose system
883
+ asks for none of reflexivity and seriality stays empty, which is all of them vacuously.
884
+
885
+ The caller checks the result against the formula and the frame (see :func:`_fits_frame`
886
+ and :func:`modal_countermodel`), which is what keeps the one construct a loop can change
887
+ (a distributed-knowledge box over relations that are all dead ends at the world) honest.
888
+ """
889
+ valuation = {}
890
+ always = {key_text(fact) for fact in b.facts}
891
+ for w, s in b.tv.items():
892
+ valuation[w] = {key_text(f) for f in s
893
+ if isinstance(f, Atom) and not is_truth_constant(f)} | always
894
+ relations = {r: set(e) for r, e in b.rels.items()}
895
+ worlds = set(b.tv) | {0}
896
+ completed = _OrderedSet()
897
+ for rel in _relnames(b):
898
+ completed.add(rel)
899
+ for rel in ctx.mentioned:
900
+ completed.add(rel)
901
+ for rel in completed:
902
+ conds = ctx.conds(rel)
903
+ if "refl" in conds or "serial" in conds:
904
+ edges = relations.setdefault(rel, set())
905
+ if "refl" in conds:
906
+ edges.update((w, w) for w in worlds)
907
+ seeing = {a for (a, _v) in edges}
908
+ edges.update((w, w) for w in worlds if w not in seeing)
909
+ return KripkeModel(worlds, relations, valuation)
910
+
911
+
912
+ def _frame_condition_holds(condition: str, edges, worlds) -> bool:
913
+ """Whether the finite frame ``(worlds, edges)`` satisfies one of the conditions this tableau has rules for.
914
+
915
+ The five conditions of :data:`_TABLEAU_CONDITIONS`, read straight off their definitions in
916
+ :data:`~unicode_logic_kit.fol.frames.FRAME_CONDITIONS` in time linear in the edges and their
917
+ out-degrees. (:func:`~unicode_logic_kit.fol.frames.holds_on_finite_frame` answers the same
918
+ question for every condition of the registry, but its cost grows with about the fourth power
919
+ of the number of worlds: 26 ms for a transitive chain of 40 worlds against under 1 ms here,
920
+ and a tableau may return a model of several hundred worlds. The tests hold this check to
921
+ the registry's on every frame of up to three worlds.)
922
+ """
923
+ successors: dict = {}
924
+ for (a, v) in edges:
925
+ successors.setdefault(a, set()).add(v)
926
+ if condition == "refl":
927
+ return all(w in successors.get(w, ()) for w in worlds)
928
+ if condition == "serial":
929
+ return all(w in successors for w in worlds)
930
+ if condition == "sym":
931
+ return all(a in successors.get(v, ()) for (a, v) in edges)
932
+ if condition == "trans":
933
+ return all(u in successors[a] for (a, v) in edges for u in successors.get(v, ()))
934
+ if condition == "eucl":
935
+ return all(u in successors.get(v, ()) for succs in successors.values()
936
+ for v in succs for u in succs)
937
+ raise ValueError(f"modal_tableau: no check for the frame condition {condition!r}")
938
+
939
+
940
+ def _fits_frame(model: KripkeModel, ctx: _Ctx) -> bool:
941
+ """True iff every relation of ``model`` and every relation the formulas read satisfies
942
+ the frame conditions of its system.
943
+
944
+ A relation the formulas read that the model does not list is the empty relation (the
945
+ model's own reading of a missing name), and it is checked as such: a check over the
946
+ listed relations alone passes a model with no entry for a reflexive or serial relation.
947
+ """
948
+ names = list(model.relations)
949
+ names.extend(rel for rel in ctx.mentioned if rel not in model.relations)
950
+ for rel in names:
951
+ edges = model.relations.get(rel, ())
952
+ for condition in ctx.conds(rel):
953
+ if not _frame_condition_holds(condition, edges, model.worlds):
954
+ return False
955
+ return True
956
+
957
+
958
+ def _makes_roots_true(model: KripkeModel, ctx: _Ctx) -> bool:
959
+ """False iff ``model``, read as it is, makes one of the root formulas false at world 0.
960
+
961
+ A branch can be open with a formula on it that this tableau has no rule for (a negated
962
+ distributed-knowledge formula, a temporal closure operator): the formula stays on the
963
+ branch and the model read off it need not make it true. Whether it does is not something
964
+ the branch can tell, so the evaluator of the kit is asked. A formula the evaluator cannot
965
+ read in this model is left to the caller, which makes the same check and says so.
966
+ """
967
+ for f in ctx.roots:
968
+ try:
969
+ if not satisfies_modal(f, model, 0):
970
+ return False
971
+ except (NotImplementedError, ValueError, TypeError, KeyError, RecursionError):
972
+ return True
973
+ return True
974
+
975
+
976
+ def _solve(b: _Branch, ctx: _Ctx):
977
+ """Depth-first saturation of one branch.
978
+
979
+ Returns ``("closed", None)``, ``("open", model)``, or ``("unknown", None)``.
980
+ """
981
+ while True:
982
+ if not ctx.tick():
983
+ return ("unknown", None)
984
+ if _closes(b):
985
+ return ("closed", None)
986
+
987
+ # 1) propositional + box + frame saturation to fixpoint
988
+ progressed = True
989
+ while progressed:
990
+ if not ctx.tick():
991
+ return ("unknown", None)
992
+ progressed = False
993
+ if _expand_simple(b):
994
+ progressed = True
995
+ if _frame_close(b, ctx):
996
+ progressed = True
997
+ if _close_distributed(b):
998
+ progressed = True
999
+ if _apply_boxes(b, ctx):
1000
+ progressed = True
1001
+ if _closes(b):
1002
+ return ("closed", None)
1003
+
1004
+ # 2) a branching formula?
1005
+ beta = _find_beta(b)
1006
+ if beta is not None:
1007
+ w, f, options = beta
1008
+ all_closed = True
1009
+ for opt in options:
1010
+ child = b.copy()
1011
+ child.expanded.add((w, f))
1012
+ for comp in opt:
1013
+ _assert(child, w, comp)
1014
+ res, model = _solve(child, ctx)
1015
+ if res == "open":
1016
+ return ("open", model)
1017
+ if res != "closed":
1018
+ all_closed = False
1019
+ return ("closed", None) if all_closed else ("unknown", None)
1020
+
1021
+ # 3) an unfulfilled diamond?
1022
+ dia = _find_diamond(b)
1023
+ if dia is not None:
1024
+ w, f, relname, body = dia
1025
+ b.expanded.add((w, f))
1026
+ if _blocked(b, w, relname, ctx):
1027
+ continue
1028
+ if b.wcount >= ctx.max_worlds:
1029
+ return ("unknown", None)
1030
+ v = b.wcount
1031
+ b.wcount += 1
1032
+ b.tv.setdefault(v, _OrderedSet())
1033
+ b.rels.setdefault(relname, set()).add((w, v))
1034
+ _assert(b, v, body)
1035
+ continue
1036
+
1037
+ # 4) seriality witness for a box-bearing world with no successor
1038
+ ser = _find_seriality(b, ctx)
1039
+ if ser is not None:
1040
+ w, relname = ser
1041
+ if b.wcount >= ctx.max_worlds:
1042
+ return ("unknown", None)
1043
+ v = b.wcount
1044
+ b.wcount += 1
1045
+ b.tv.setdefault(v, _OrderedSet())
1046
+ b.rels.setdefault(relname, set()).add((w, v))
1047
+ continue
1048
+
1049
+ # 5) saturated and open: the model that is read off must be one of the frame class and
1050
+ # must make the root formulas true. A branch whose model is not is "unknown" and the
1051
+ # search goes on with the branches that are left, as it does for any other bound.
1052
+ model = _build_model(b, ctx)
1053
+ if _fits_frame(model, ctx) and _makes_roots_true(model, ctx):
1054
+ return ("open", model)
1055
+ return ("unknown", None)
1056
+
1057
+
1058
+ #: How this route reads an atom — the clause :func:`_reject_equality` hands the shared
1059
+ #: refusal so the message says why identity cannot be read here.
1060
+ _EQUALITY_ROUTE = "the propositional modal tableau"
1061
+ _EQUALITY_ATOM_READING = ("an atom is a propositional letter: a branch closes on a "
1062
+ "syntactic complement and an open branch is read off as "
1063
+ "a valuation of rendered atom keys")
1064
+
1065
+
1066
+ def _reject_equality(formulas) -> None:
1067
+ """Refuse an equality / disequality atom ANYWHERE in ``formulas``, by name.
1068
+
1069
+ A whole-tree scan of every formula, run by :func:`_run` before anything else
1070
+ touches them (see the module docstring's "Equality is NOT interpreted here").
1071
+ It walks the formulas exactly as the caller wrote them — announcement operators
1072
+ and all, ahead of :func:`~unicode_logic_kit.fol.pal.reduce_announcements` — so an
1073
+ atom inside an announcement or inside a quantifier this tableau would treat as an
1074
+ opaque literal is refused too, not only one in the propositional skeleton.
1075
+ """
1076
+ for f in formulas:
1077
+ reject_equality_in(f, "modal_tableau", _EQUALITY_ROUTE,
1078
+ atom_reading=_EQUALITY_ATOM_READING)
1079
+
1080
+
1081
+ #: The cross-family bridge names the HOL / qml routes accept. Listed here only so
1082
+ #: the refusal below can name them; this module implements NONE of them.
1083
+ _KNOWN_BRIDGES = ("knowledge_implies_belief", "sincerity", "ought_implies_can")
1084
+
1085
+
1086
+ def _check_bridges(bridges) -> None:
1087
+ """Refuse any ``bridges=`` request, naming the routes that can honour it.
1088
+
1089
+ A bridge is a frame condition on TWO relations at once; this tableau's
1090
+ structural rules (``_frame_close`` / ``_find_seriality``) each work inside a
1091
+ single relation, so there is no rule to apply and no sound way to approximate
1092
+ one. Accepting the argument and ignoring it would decide a strictly WEAKER
1093
+ logic than the caller asked for and would make this module disagree with the
1094
+ HOL routes on the same formula — so it raises instead, in the same style as the
1095
+ GL guard below: name the boundary, name what does express it.
1096
+ """
1097
+ if not bridges:
1098
+ return
1099
+ if isinstance(bridges, str):
1100
+ bridges = [bridges]
1101
+ raise NotImplementedError(
1102
+ f"modal_tableau: cross-family bridges {sorted(set(bridges))!r} are not "
1103
+ "supported by this tableau — a bridge constrains two DIFFERENT "
1104
+ "accessibility relations at once (e.g. rb ⊆ rk for K_aφ → "
1105
+ "B_aφ), and every structural rule here acts inside a single relation. "
1106
+ "Use a route that emits the bridge as an axiom: fol.qml.qml_is_valid / "
1107
+ "qml_axioms, or hol.isabelle_modal.to_isabelle_modal (with "
1108
+ "hol.isabelle_runner.isabelle_decide_modal) / "
1109
+ f"hol.thf_modal.to_thf_modal_full. Known bridges: {list(_KNOWN_BRIDGES)}.")
1110
+
1111
+
1112
+ def _check_frame(frame: str, systems) -> None:
1113
+ _check_one_frame(frame)
1114
+ for fam, sys in (systems or {}).items():
1115
+ if fam not in ("epistemic", "doxastic", "deontic", "temporal"):
1116
+ raise ValueError(
1117
+ f"modal_tableau: unknown system family {fam!r} (use epistemic / "
1118
+ "doxastic / deontic / temporal).")
1119
+ _check_one_frame(sys, what=f"system {sys!r} for {fam}")
1120
+
1121
+
1122
+ def _check_one_frame(frame: str, what: str = "") -> None:
1123
+ """Resolve a frame name and refuse every condition this tableau has no
1124
+ rule for — by name, with the route that does carry it named too."""
1125
+ try:
1126
+ conds = resolve_frame(frame)
1127
+ except ValueError as exc:
1128
+ raise ValueError(f"modal_tableau: {exc}") from None
1129
+ require_supported(
1130
+ f"modal_tableau ({what})" if what else "modal_tableau",
1131
+ conds, _TABLEAU_CONDITIONS,
1132
+ hint="This labelled tableau has rules for reflexivity, transitivity, "
1133
+ "symmetry, seriality and euclideanness only. Use the "
1134
+ "first-order route fol.qml (Z3) for the other first-order "
1135
+ "conditions, or the higher-order embeddings — "
1136
+ "hol.isabelle_modal.to_isabelle_modal / isabelle_decide_modal "
1137
+ "and hol.thf_modal.to_thf_modal_full — for the ones that are "
1138
+ "not first-order definable at all (GL, S4.1, Grz); for a "
1139
+ "bounded REFUTATION (not a proof) of those three, "
1140
+ "atp.kripke_enum.modal_enum_search decides them directly via "
1141
+ "their finite frame characterisation.")
1142
+
1143
+
1144
+ def _refuse_unsupported_constructs(formulas) -> None:
1145
+ """Refuse, by name, a ↓ binder, a nominal / ``@`` or a counterfactual anywhere in ``formulas``."""
1146
+ for f in formulas:
1147
+ if _contains_down(f):
1148
+ # Checked BEFORE the general hybrid-constructs guard below so a
1149
+ # ↓-formula gets its own clear, undecidability-naming message
1150
+ # (N1) rather than being lumped under the general nominals/@
1151
+ # rejection — even though _contains_hybrid would ALSO already
1152
+ # catch it for free (Down.variable is a Nominal, walked
1153
+ # automatically — see _contains_down's own docstring).
1154
+ raise NotImplementedError(
1155
+ "modal_tableau: the ↓ binder is not supported by this labelled "
1156
+ "tableau — H(@,↓) validity is undecidable, and this tableau's "
1157
+ "rule set (like hybrid_is_valid's Z3 route) only ever "
1158
+ "soundly-and-completely covers DECIDABLE fragments. Use "
1159
+ "fol.modal_translation.down_is_valid (Z3, PROVED-only) or "
1160
+ "atp.kripke_enum.KripkeEnumBackend / modal_enum_search "
1161
+ "(bounded search, REFUTED-only), or evaluate directly with "
1162
+ "semantics.kripke.satisfies_modal.")
1163
+ if _contains_hybrid(f):
1164
+ raise NotImplementedError(
1165
+ "modal_tableau: hybrid constructs (nominals/@) are not supported "
1166
+ "by the modal tableau; use hybrid_is_valid or a KripkeModel.")
1167
+ if _contains_counterfactual(f):
1168
+ raise NotImplementedError(
1169
+ "modal_tableau: the counterfactuals □→/◇→ are evaluated over a "
1170
+ "similarity ordering (Lewis spheres), not an accessibility "
1171
+ "relation, so this tableau cannot decide them. Use cf_valid / "
1172
+ "cf_countermodel (bounded sphere-model search), cf_satisfies "
1173
+ "over a CounterfactualModel, or isabelle_decide_counterfactual.")
1174
+
1175
+
1176
+ def _run(formulas, frame: str, systems, max_worlds: int, max_steps: int,
1177
+ bridges=None, timeout: Optional[int] = None, causes: Optional[List[str]] = None):
1178
+ """Build the root branch from ``formulas`` at world 0 and search it.
1179
+
1180
+ ``timeout`` (milliseconds, default none) is one more bound next to ``max_worlds`` and
1181
+ ``max_steps``, checked at every step of the search and inside the frame closure and the
1182
+ box rule, which can outlast a step on a large edge set; so is Python's recursion limit
1183
+ (the search recurses once per branching formula along a branch, and every walk over a
1184
+ formula, before and during the search, once per level of its nesting). Past either bound
1185
+ the answer is ``("unknown", None)``, never an exception: also for a formula nested too
1186
+ deeply for those walks, whose guards below then cannot be run to their end.
1187
+
1188
+ ``formulas`` is first run through :func:`~unicode_logic_kit.fol.pal.reduce_announcements`
1189
+ (a no-op on a formula with no Announce/AnnounceDiamond node), so every public
1190
+ entry point of this module DECIDES public-announcement formulas — no
1191
+ modal-tableau rule for Announce/AnnounceDiamond exists or is needed, since the
1192
+ reduction eliminates them into the ordinary modal fragment this tableau
1193
+ already handles, BEFORE tableau search ever begins. A temporal operator (or
1194
+ Would/Might/Nominal/At/a quantifier) found INSIDE an announcement's scope
1195
+ still raises — that is pal.reduce_announcements's own clean, precise
1196
+ NotImplementedError (unsound/undefined relativization, not this module's
1197
+ concern), propagated unchanged; see that module's docstring for why each
1198
+ case is rejected.
1199
+
1200
+ Hybrid constructs are rejected up front — a nominal names ONE world, a
1201
+ constraint this labelled tableau has no rule for, and treating it as an
1202
+ ordinary atom would produce wrong verdicts (e.g. it would refute ``@i i``).
1203
+ A ``bridges=`` request is rejected here for the same reason (see
1204
+ :func:`_check_bridges`), and so is an equality / disequality atom anywhere in
1205
+ ``formulas`` (see :func:`_reject_equality` — scanned FIRST, over the formulas as
1206
+ given, so no later guard or search can answer before it has looked). Every
1207
+ public entry point funnels through here, so all three guards cover them all.
1208
+ """
1209
+ formulas = list(formulas)
1210
+ # Every walk below is recursive, so a formula nested deeper than the interpreter's stack
1211
+ # allows ends one of them in a ``RecursionError``: the answer is then "unknown" (the
1212
+ # formula was not read to its end, so nothing may be said about it). The arguments that
1213
+ # do not depend on the formulas are still checked first, so a wrong ``frame`` is refused
1214
+ # whatever the depth.
1215
+ too_deep = False
1216
+ try:
1217
+ _reject_equality(formulas)
1218
+ formulas = [reduce_announcements(f) for f in formulas]
1219
+ except RecursionError:
1220
+ too_deep = True
1221
+ _check_frame(frame, systems)
1222
+ _check_bridges(bridges)
1223
+ if too_deep:
1224
+ if causes is not None:
1225
+ causes.append("nesting")
1226
+ return ("unknown", None)
1227
+ try:
1228
+ _refuse_unsupported_constructs(formulas)
1229
+ # The operators of an agent are filed under the relation named after the agent: two
1230
+ # different agent terms of one name (the numeral 1 and the constant '1') would be one
1231
+ # agent, and a branch could close for a formula about two.
1232
+ refuse_alike_agents(formulas, "modal_tableau")
1233
+ # A sorted constant ``c:S`` is the constant ``c`` and is an element of ``S`` at every
1234
+ # world (``semantics.kripke``'s module docstring): ``Mortal(c:S)`` and ``Mortal(c)``
1235
+ # are ONE letter, and ``S(c)`` is a letter true everywhere -- a branch with its
1236
+ # negation at any world is closed, and a model read off an open branch makes it
1237
+ # true at every world, so the countermodel is one the many-sorted reading allows.
1238
+ membership: List[Node] = []
1239
+ formulas = [_forget_constant_sorts(f, membership) for f in formulas]
1240
+ ctx = _Ctx(frame, systems, max_worlds, max_steps, timeout,
1241
+ mentioned=_mentioned_relations(formulas), roots=formulas)
1242
+ root = _Branch()
1243
+ root.facts = frozenset(membership)
1244
+ for f in formulas:
1245
+ _assert(root, 0, f)
1246
+ return _solve(root, ctx)
1247
+ except RecursionError:
1248
+ if causes is not None:
1249
+ causes.append("nesting")
1250
+ return ("unknown", None)
1251
+ except DeadlineReached:
1252
+ return ("unknown", None)
1253
+
1254
+
1255
+ def modal_tableau_closed(formulas, frame: str = "K", systems=None,
1256
+ max_worlds: int = 400, max_steps: int = 200000,
1257
+ bridges=None, timeout: Optional[int] = None) -> bool:
1258
+ """Return True iff ``formulas`` are jointly unsatisfiable at a world (the tableau closes).
1259
+
1260
+ Interprets the list as a set of formulas true at the same (root) world under the
1261
+ chosen ``frame`` (alethic system) and ``systems`` (per-family systems for
1262
+ epistemic / doxastic / deontic / temporal relations). Sound: a True is a real
1263
+ closed tableau. A False means "no closed tableau within the bound", never a
1264
+ positive satisfiability claim — use :func:`modal_countermodel` for that.
1265
+
1266
+ ``bridges`` exists only to be REFUSED: any non-empty request raises
1267
+ ``NotImplementedError`` pointing at the routes that implement cross-family
1268
+ bridges (see :func:`_check_bridges`). An ``=`` / ``≠`` atom anywhere in the formula raises ``NotImplementedError`` (see the
1269
+ module docstring's "Equality is NOT interpreted here").
1270
+ """
1271
+ res, _ = _run(formulas, frame, systems, max_worlds, max_steps, bridges, timeout)
1272
+ return res == "closed"
1273
+
1274
+
1275
+ def is_modal_valid(formula: Node, frame: str = "K", systems=None,
1276
+ max_worlds: int = 400, max_steps: int = 200000,
1277
+ bridges=None, timeout: Optional[int] = None) -> bool:
1278
+ """Return True iff ``formula`` is modally valid over ``frame`` — ``¬formula`` closes.
1279
+
1280
+ Sound: only the closed tableau yields True. An open or bound-exhausted search
1281
+ yields False (the formula is then invalid-or-unknown; :func:`modal_decide`
1282
+ distinguishes the two with a verified counter-model). A non-empty ``bridges``
1283
+ raises ``NotImplementedError`` (see :func:`_check_bridges`). An ``=`` / ``≠`` atom anywhere in the formula raises ``NotImplementedError`` (see the
1284
+ module docstring's "Equality is NOT interpreted here").
1285
+ """
1286
+ res, _ = _run([Not(formula)], frame, systems, max_worlds, max_steps, bridges, timeout)
1287
+ return res == "closed"
1288
+
1289
+
1290
+ def modal_prove(premises, conclusion: Node, frame: str = "K", systems=None,
1291
+ max_worlds: int = 400, max_steps: int = 200000,
1292
+ bridges=None, timeout: Optional[int] = None) -> bool:
1293
+ """Return True iff ``premises`` locally entail ``conclusion`` over ``frame``.
1294
+
1295
+ Local consequence: the tableau for ``premises ∪ {¬conclusion}`` at one world
1296
+ closes. Sound (a True is a closed tableau); incomplete only up to the bound.
1297
+ A non-empty ``bridges`` raises ``NotImplementedError``
1298
+ (see :func:`_check_bridges`). An ``=`` / ``≠`` atom anywhere in the formula raises ``NotImplementedError`` (see the
1299
+ module docstring's "Equality is NOT interpreted here").
1300
+ """
1301
+ res, _ = _run(list(premises) + [Not(conclusion)], frame, systems,
1302
+ max_worlds, max_steps, bridges, timeout)
1303
+ return res == "closed"
1304
+
1305
+
1306
+ def modal_countermodel(formula: Node, frame: str = "K", systems=None,
1307
+ max_worlds: int = 400, max_steps: int = 200000,
1308
+ bridges=None, timeout: Optional[int] = None):
1309
+ """Return a Kripke model falsifying ``formula`` over ``frame``, or None.
1310
+
1311
+ None means the formula is valid (the tableau closed) **or** the search was
1312
+ inconclusive within the bound. The returned model is *verified* twice: it is only
1313
+ handed back when :func:`satisfies_modal` confirms the formula is false at its
1314
+ root world, and when every relation of it and every relation the formula reads satisfies
1315
+ the frame conditions of its system (a serial relation gives each of its dead ends a
1316
+ successor, itself, and a relation the formula reads that the search never used gets the
1317
+ loops its system asks for; see the module docstring), so a counter-model is never
1318
+ spurious and never a structure of another frame class. A relation the formula does not
1319
+ read is not part of the model. A non-empty ``bridges``
1320
+ raises ``NotImplementedError`` (see :func:`_check_bridges`). An ``=`` / ``≠`` atom anywhere in the formula raises ``NotImplementedError`` (see the
1321
+ module docstring's "Equality is NOT interpreted here").
1322
+ """
1323
+ res, model = _run([Not(formula)], frame, systems, max_worlds, max_steps, bridges, timeout)
1324
+ if res != "open" or model is None:
1325
+ return None
1326
+ try:
1327
+ refuted = not satisfies_modal(formula, model, 0)
1328
+ except (NotImplementedError, ValueError, TypeError, KeyError, RecursionError):
1329
+ # The verifier cannot evaluate the formula in this model (e.g. an opaque
1330
+ # quantified construct with no domain information, or a formula nested deeper
1331
+ # than the evaluator can walk) — the candidate is unverifiable, so it must not
1332
+ # be handed back as a counter-model.
1333
+ refuted = False
1334
+ return model if refuted else None
1335
+
1336
+
1337
+ def modal_decide(formula: Node, frame: str = "K", systems=None,
1338
+ max_worlds: int = 400, max_steps: int = 200000,
1339
+ bridges=None, timeout: Optional[int] = None) -> str:
1340
+ """Decide ``formula`` over ``frame``: ``"valid"`` / ``"invalid"`` / ``"unknown"``.
1341
+
1342
+ * ``"valid"`` — the tableau for ``¬formula`` closed (a sound proof).
1343
+ * ``"invalid"`` — an open branch yielded a counter-model **verified** by
1344
+ :func:`satisfies_modal` and a model of the frame (see :func:`modal_countermodel`).
1345
+ * ``"unknown"`` — the search hit the world/step bound or the deadline, the formula
1346
+ is nested too deeply for the recursive walks, or an open branch's
1347
+ model failed verification (so neither verdict is safe to assert).
1348
+
1349
+ Mirrors the valid / invalid / unknown contract of the local-Isabelle runner
1350
+ (:func:`~unicode_logic_kit.hol.isabelle_runner.isabelle_decide_modal`), but runs
1351
+ fully in-process with no external prover. A non-empty ``bridges`` raises
1352
+ ``NotImplementedError`` rather than silently deciding the bridge-free logic
1353
+ (see :func:`_check_bridges`). An ``=`` / ``≠`` atom anywhere in the formula raises ``NotImplementedError`` (see the
1354
+ module docstring's "Equality is NOT interpreted here").
1355
+ """
1356
+ return _decide_explained(formula, frame, systems, max_worlds, max_steps, bridges, timeout)[0]
1357
+
1358
+
1359
+ def _decide_explained(formula: Node, frame: str = "K", systems=None,
1360
+ max_worlds: int = 400, max_steps: int = 200000,
1361
+ bridges=None, timeout: Optional[int] = None) -> Tuple[str, bool]:
1362
+ """:func:`modal_decide`'s answer, and whether the recursion limit ended one of its walks.
1363
+
1364
+ The second component is true only next to ``"unknown"``: the formula was not read to its
1365
+ end (or its candidate countermodel could not be checked to its end) because a walk over
1366
+ it recursed deeper than the interpreter allows. A caller that reports the answer can then
1367
+ name the bound that was hit instead of guessing between it and the search budget.
1368
+ """
1369
+ causes: List[str] = []
1370
+ res, model = _run([Not(formula)], frame, systems, max_worlds, max_steps, bridges, timeout,
1371
+ causes=causes)
1372
+ if res == "closed":
1373
+ return "valid", False
1374
+ if res == "open" and model is not None:
1375
+ try:
1376
+ if not satisfies_modal(formula, model, 0):
1377
+ return "invalid", False
1378
+ except RecursionError:
1379
+ causes.append("nesting") # unverifiable candidate → honest "unknown"
1380
+ except (NotImplementedError, ValueError, TypeError, KeyError):
1381
+ pass # unverifiable candidate → honest "unknown"
1382
+ return "unknown", bool(causes)