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,1084 @@
1
+ """A decision procedure for propositional linear-time temporal logic (LTL+Past).
2
+
3
+ **Scoping correction this module exists to state up front.** The kit's Kripke
4
+ semantics for ``Next``/``Always``/``Eventually``/``Until``/``Historically``/
5
+ ``Once``/``Previous``/``Since`` (:mod:`unicode_logic_kit.semantics.kripke`) is
6
+ NOT standard linear-time LTL: ``"temporal"`` is an arbitrary (not necessarily
7
+ linear, not necessarily total) accessibility relation, ``Until``/``Since`` are
8
+ existential finite-path searches over it, and ``fol.qml``'s default temporal
9
+ axioms (refl + trans + ``N ⊆ T``) do not pin it down to a line either — see
10
+ ``docs/guide/quantified-modal.md``'s worked example, where
11
+ ``resolution.prove([], Ⓖ P → P)`` and ``qml_is_valid`` already disagree on a
12
+ temporal formula for exactly this reason, without either being unsound. This
13
+ module decides a DIFFERENT, narrower question: validity and satisfiability
14
+ under the STANDARD reading, where the temporal frame is fixed to the unique
15
+ discrete strict order on the natural numbers (0 < 1 < 2 < …) — "real" LTL, the
16
+ one PSPACE-complete decision problem the literature means by the name. It
17
+ answers a strictly more specific question than every other route in this kit
18
+ that touches these operator names, and says so by construction: it has no
19
+ ``frame=`` parameter, because there is only one frame here.
20
+
21
+ **What the operator names mean on this frame** (matching this kit's own node
22
+ docstrings and ``semantics/kripke.py``'s documented semantics, so the same
23
+ name means the same thing everywhere in the kit, only the frame class
24
+ differs):
25
+
26
+ * ``Next`` (X): φ holds at the immediately following position.
27
+ * ``Always`` (G) / ``Eventually`` (F): φ holds at every / some position from
28
+ NOW on, current position INCLUDED (``Gφ → φ`` and ``φ → Fφ`` are theorems).
29
+ * ``Until`` (U), non-strict/strong: ``φUψ`` holds iff ψ holds at some position
30
+ ``n ≥`` now with φ holding at every position strictly before ``n`` — ψ may
31
+ hold RIGHT NOW (n = now is allowed), matching
32
+ ``_until_holds``'s ``n ≥ 0`` base case exactly.
33
+ * ``Historically`` (H) / ``Once`` (O): the past duals of G / F — φ at every /
34
+ some position from the BEGINNING of time up to and including now.
35
+ * ``Since`` (S), non-strict/strong: the backward mirror of Until — ``φSψ``
36
+ holds iff ψ held at some position ``n ≤`` now with φ holding at every
37
+ position strictly after ``n`` up to and including now; ψ may hold right now.
38
+ * ``Previous`` (Y), WEAK at position 0: universal over the (at most one)
39
+ immediate predecessor, per its own docstring ("vacuously true at a world
40
+ with no past") — so ``Yφ`` is TRUE at position 0 for every φ, including
41
+ ``Y(p ∧ ¬p)``. There is no existential ("strong") previous operator in the
42
+ kit's AST; on a linear frame the existential reading is expressible as
43
+ ``¬Y¬φ`` and this module uses exactly that identity internally.
44
+
45
+ **Initial vs. floating validity.** With past operators these genuinely
46
+ differ: ``Previous(p ∧ ¬p)`` — "there is no earlier position" — is TRUE at
47
+ position 0 of every model (the weak-Y vacuity above) but FALSE at every
48
+ later position (which does have a predecessor), so it is valid when
49
+ "valid" means "true at the start of every model" but not when it means
50
+ "true at every position of every model". ``mode="initial"`` (the default on
51
+ every public function here) decides the first, more standard reading for a
52
+ logic with past operators — the one every textbook LTL-with-past validity
53
+ claim means — by requiring the witness/refutation to be anchored at a
54
+ position with no predecessor. ``mode="floating"`` decides the second: a
55
+ position may be any point of any model, reachable by SOME finite legitimate
56
+ history from an actual start (this is cheap to add once the tableau graph
57
+ exists — see :func:`_run` — so both are offered instead of picking one
58
+ silently). Every SATISFIABLE floating query is also satisfiable in the
59
+ initial sense once you either need a genuine predecessor (unreachable at
60
+ position 0) or don't (reachable at position 0 too) — the two notions coincide
61
+ exactly on the past-operator-free (pure future) fragment.
62
+
63
+ **Algorithm.** A Fischer–Ladner-style closure of the input over
64
+ Next/Always/Eventually/Until (forward fixpoints, unfolded via their own
65
+ ``Next``-wrapped continuation) and Historically/Once/Previous/Since (backward
66
+ fixpoints, unfolded via their own ``Previous``-wrapped continuation — Once and
67
+ Since need the existential ``¬Previous(¬·)`` form of that continuation, since
68
+ ``Previous`` itself is only ever weak/universal); every syntactic shape in the
69
+ closure gets a LOCAL boolean equation (a Wolper-style "elementary atom")
70
+ relating it to smaller subformulas plus exactly one Next/Previous-wrapped
71
+ term, which is therefore INDEPENDENT of every other atom's choices — so, in
72
+ contrast to a general modal tableau, every combination of the FREE (ground
73
+ atom / Next / Previous) elements determines a full, locally consistent atom by
74
+ plain deterministic evaluation (:func:`_all_atoms`), no branching search
75
+ needed. Next/Previous membership between adjacent atoms is pinned by an
76
+ Next/Previous-consistency EDGE relation (:func:`_build_graph`) exactly like a
77
+ box modality's successor obligation, but two-way, since Next and Previous are
78
+ mirror images of the same edge. Satisfiability is then GENERALIZED BÜCHI
79
+ emptiness on this finite graph — Wolper's classical reduction — decided by
80
+ finding a reachable, non-trivial strongly connected component that intersects
81
+ every outstanding eventuality's fulfillment set (:func:`_fairness_sets`,
82
+ :func:`_has_fair_witness`). Past obligations need no separate fairness check,
83
+ because their truth is already pinned, position by position, by the
84
+ (backward, terminating-at-0) Previous-edge chain as the path is walked
85
+ forward. Two FORWARD shapes do need one: an explicit ``Eventually``/``Until``
86
+ promise, and — easy to miss, see :func:`_fairness_sets`'s own docstring for
87
+ the countermodel that catches getting it wrong — the IMPLICIT ``F(¬φ)``
88
+ hiding inside a false ``Always(φ)``, since ``¬Gφ`` is itself an eventuality
89
+ even though this module tracks only ``Gφ`` as a closure element.
90
+
91
+ **Soundness and completeness.** This is a complete decision procedure for the
92
+ supported fragment (Atom/And/Or/Not/Implies/Iff/Xor plus the eight temporal
93
+ operators above) on the standard linear frame — PSPACE-complete in general,
94
+ and the closure/atom construction here is worst-case exponential in formula
95
+ size (as any sound LTL decision procedure must be), guarded by ``max_atoms``:
96
+ exceeding it yields ``"unknown"`` (reason ``bound_hit``), never a wrong
97
+ ``"valid"``/``"invalid"``. Every returned countermodel/witness
98
+ (:class:`LTLTrace`, an explicit finite-prefix-plus-cycle lasso) is verified
99
+ before release by :func:`ltl_trace_satisfies` — a direct, from-the-semantic-
100
+ equations evaluator over the lasso, independent of the closure/atom/graph
101
+ machinery above (mirroring how :func:`modal_tableau.modal_countermodel`
102
+ verifies against :func:`~unicode_logic_kit.semantics.kripke.satisfies_modal`
103
+ before release) — so a bug in the graph construction can make this module
104
+ report ``"unknown"`` too often, never hand back a spurious witness. Any node
105
+ outside the supported fragment (Box, Knows, a quantifier, …) is refused by
106
+ name with ``NotImplementedError`` rather than approximated; use
107
+ :mod:`unicode_logic_kit.atp.modal_tableau`, :func:`~unicode_logic_kit.fol.qml.qml_is_valid`,
108
+ or :func:`~unicode_logic_kit.hol.isabelle_runner.isabelle_decide_modal` for
109
+ anything genuinely modal.
110
+
111
+ **Equality is NOT interpreted here.** An atom is a propositional letter: the elementary
112
+ atoms of the closure are sets of rendered atom keys, and a trace is read off as a valuation of
113
+ those keys. ``a = a`` or ``a ≠ b`` would therefore be an unconstrained letter, and
114
+ ``⊢ a = a`` would be refuted by a lasso in which the letter ``a = a`` is false although
115
+ identity is reflexive. Every entry point refuses an equality or disequality atom anywhere in
116
+ its formulas by name (the shared :func:`~unicode_logic_kit.semantics._modal_reject.reject_equality_in`,
117
+ the refusal :mod:`unicode_logic_kit.atp.modal_tableau` and the Kripke evaluator give), before any
118
+ search. Decide identity with :func:`~unicode_logic_kit.fol.qml.qml_is_valid` or another
119
+ first-order route.
120
+
121
+ **Where this is a strict completeness gain.** Temporal induction
122
+ ``(φ ∧ G(φ → Xφ)) → Gφ`` is the standard textbook example that ``qml_is_valid``
123
+ cannot reach — its own docstring says so explicitly: "reaching an arbitrary
124
+ T-successor from the first step needs induction over the closure, which no
125
+ first-order theory states." This module proves it (see
126
+ ``tests/test_ltl_tableau.py``), because the Fischer–Ladner closure computes
127
+ that induction directly rather than approximating it with finitely many
128
+ unfoldings.
129
+
130
+ **Sorted constants and bounds.** A sorted constant ``c:S`` is the constant ``c`` and lies
131
+ in ``S`` at EVERY position (a constant is a rigid designator): ``Mortal(c:S)`` and
132
+ ``Mortal(c)`` are one letter, ``S(c)`` is a letter true everywhere, and a countermodel is
133
+ only released if it makes it so (:func:`_lift_sorted_constants`). Besides ``max_atoms`` every
134
+ entry point takes an optional wall-clock ``timeout`` in milliseconds, read while the atoms
135
+ and the graph between them are built and in every step after that (reachability, pruning,
136
+ strongly connected components, fairness, the witness walk); past it the answer is ``"unknown"``.
137
+
138
+ Public API: :func:`ltl_tableau_closed`, :func:`ltl_valid`, :func:`ltl_decide`,
139
+ :func:`ltl_countermodel`, :func:`ltl_trace_satisfies`, :class:`LTLTrace`.
140
+ """
141
+
142
+ import time
143
+ from dataclasses import dataclass
144
+ from typing import Dict, FrozenSet, List, Optional, Sequence, Tuple
145
+
146
+ from .._deadline import DeadlineReached, passed as _passed
147
+ from ..fol.nodes import (
148
+ Node, Atom, Not, And, Or, Implies, Iff, Xor,
149
+ Next, Always, Eventually, Until,
150
+ Historically, Once, Previous, Since,
151
+ )
152
+ from ..fol._atom_keys import AtomKeys, find_key
153
+ from ..fol._msfl_nodes import key_text
154
+ from ..fol._truth_constants import truth_value
155
+ from ..semantics._modal_reject import reject_equality_in
156
+ from .lj import _forget_constant_sorts
157
+
158
+ __all__ = [
159
+ "LTLTrace",
160
+ "ltl_tableau_closed", "ltl_valid", "ltl_decide", "ltl_countermodel",
161
+ "ltl_trace_satisfies",
162
+ "LtlTableauBackend",
163
+ ]
164
+
165
+ # The fragment this module decides: classical connectives plus the eight
166
+ # temporal operators named in the module docstring. Anything else is refused
167
+ # by name in _closure().
168
+ _TEMPORAL_FUTURE = (Always, Eventually, Until)
169
+ _TEMPORAL_PAST = (Historically, Once, Since)
170
+ _SUPPORTED = (Atom, And, Or, Implies, Iff, Xor,
171
+ Next, Previous) + _TEMPORAL_FUTURE + _TEMPORAL_PAST
172
+
173
+ #: Safety cap on the number of FREE closure elements (ground atoms plus
174
+ #: Next/Previous-shaped terms): the atom set has size ``2 ** len(free)``, so
175
+ #: this bounds both memory and the O(atoms^2) graph-construction cost.
176
+ #: Exceeding it yields "unknown"/bound_hit rather than hanging — the same
177
+ #: honest-incompleteness contract modal_tableau's max_worlds/max_steps give.
178
+ _DEFAULT_MAX_ATOMS = 4096
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class LTLTrace:
183
+ """An explicit ultimately-periodic witness word (a "lasso").
184
+
185
+ ``prefix + cycle*`` is the infinite word: positions ``0 .. len(prefix)-1``
186
+ are the (possibly empty) finite lead-in, then ``cycle`` repeats forever.
187
+ Each position's valuation is a frozenset of ground-atom keys
188
+ (``atom_key(atom)``, the same convention
189
+ :class:`~unicode_logic_kit.semantics.kripke.KripkeModel` uses) true there.
190
+ A trace the kit returns is keyed that way; ``ltl_trace_satisfies`` also reads
191
+ one that a caller keyed by the text of the atom as a formula
192
+ (``atom.to_unicode_str()``).
193
+
194
+ ``witness_position`` is the 0-based index (into the infinite word, so it
195
+ may fall inside ``cycle``) at which the formula this trace witnesses was
196
+ checked. It is ``0`` for every ``mode="initial"`` result (the only
197
+ position that mode ever anchors at) and may be greater than
198
+ ``len(prefix)`` for a ``mode="floating"`` result, where ``prefix`` still
199
+ starts at a genuine position-0 (no-predecessor) state so that PAST
200
+ operators evaluated at ``witness_position`` see a real, finite history.
201
+ """
202
+
203
+ prefix: Tuple[FrozenSet[str], ...]
204
+ cycle: Tuple[FrozenSet[str], ...]
205
+ witness_position: int = 0
206
+
207
+ def at(self, n: int) -> FrozenSet[str]:
208
+ """Return the valuation at position ``n`` (>= 0) of the infinite word."""
209
+ if n < 0:
210
+ raise ValueError(f"LTLTrace.at: position {n} is negative")
211
+ if n < len(self.prefix):
212
+ return self.prefix[n]
213
+ if not self.cycle:
214
+ raise ValueError("LTLTrace.at: position past the prefix but cycle is empty")
215
+ return self.cycle[(n - len(self.prefix)) % len(self.cycle)]
216
+
217
+ def to_dict(self) -> dict:
218
+ """Serialise to a JSON-compatible dict."""
219
+ return {
220
+ "kind": "ltl_lasso",
221
+ "prefix": [sorted(v) for v in self.prefix],
222
+ "cycle": [sorted(v) for v in self.cycle],
223
+ "witness_position": self.witness_position,
224
+ }
225
+
226
+
227
+ # --------------------------------------------------------------------------- #
228
+ # Closure construction.
229
+ # --------------------------------------------------------------------------- #
230
+
231
+ #: How this route reads an atom: the clause the shared equality refusal needs to say why
232
+ #: identity cannot be read here.
233
+ _EQUALITY_ROUTE = "the propositional LTL tableau"
234
+ _EQUALITY_ATOM_READING = ("an atom is a propositional letter: the elementary atoms are sets of "
235
+ "rendered atom keys and a trace is a valuation of those keys")
236
+
237
+
238
+ def _reject_equality(formulas: Sequence[Node], caller: str) -> None:
239
+ """Refuse an equality / disequality atom ANYWHERE in ``formulas``, by name.
240
+
241
+ A whole-tree scan, run before anything else reads the formulas (see the module
242
+ docstring's "Equality is NOT interpreted here"): a branch of the search that never
243
+ reaches the atom would otherwise answer without having looked at it.
244
+ """
245
+ for formula in formulas:
246
+ reject_equality_in(formula, caller, _EQUALITY_ROUTE,
247
+ atom_reading=_EQUALITY_ATOM_READING)
248
+
249
+
250
+ def _strip_not(node: Node) -> Node:
251
+ """Unwrap every leading ``Not`` (so double negation collapses to nothing)."""
252
+ while isinstance(node, Not):
253
+ node = node.formula
254
+ return node
255
+
256
+
257
+ def _closure(seeds: Sequence[Node]) -> FrozenSet[Node]:
258
+ """Return the Fischer–Ladner-style closure of ``seeds`` (see module docstring).
259
+
260
+ Every element is stored in its "base" (non-``Not``) shape; a query for its
261
+ negated reading goes through :func:`_holds`, never a second closure entry
262
+ — so ``φ`` and ``¬φ`` are never redundantly double-counted. Raises
263
+ ``NotImplementedError`` naming the first node outside ``_SUPPORTED`` found
264
+ anywhere in ``seeds`` (Box, Knows, a quantifier, PAL, hybrid, …).
265
+ """
266
+ cl: set = set()
267
+ stack: List[Node] = list(seeds)
268
+ while stack:
269
+ n = _strip_not(stack.pop())
270
+ if n in cl:
271
+ continue
272
+ if not isinstance(n, _SUPPORTED):
273
+ raise NotImplementedError(
274
+ f"ltl_tableau: no rule for {type(n).__name__} "
275
+ f"({n.to_unicode_str()!r}) — this module decides only the "
276
+ "propositional temporal-closure fragment (Atom/And/Or/Not/"
277
+ "Implies/Iff/Xor + Next/Always/Eventually/Until/Historically/"
278
+ "Once/Previous/Since) over the STANDARD LINEAR frame; use "
279
+ "atp.modal_tableau, fol.qml.qml_is_valid, or "
280
+ "hol.isabelle_runner.isabelle_decide_modal for anything else.")
281
+ cl.add(n)
282
+ if isinstance(n, Atom):
283
+ continue
284
+ if isinstance(n, (And, Or, Implies, Iff, Xor)):
285
+ stack.append(n.left)
286
+ stack.append(n.right)
287
+ elif isinstance(n, (Next, Previous)):
288
+ stack.append(n.formula)
289
+ elif isinstance(n, Always):
290
+ stack.append(n.formula)
291
+ stack.append(Next(n))
292
+ elif isinstance(n, Eventually):
293
+ stack.append(n.formula)
294
+ stack.append(Next(n))
295
+ elif isinstance(n, Until):
296
+ stack.append(n.left)
297
+ stack.append(n.right)
298
+ stack.append(Next(n))
299
+ elif isinstance(n, Historically):
300
+ stack.append(n.formula)
301
+ stack.append(Previous(n))
302
+ elif isinstance(n, Once):
303
+ stack.append(n.formula)
304
+ stack.append(Previous(Not(n)))
305
+ elif isinstance(n, Since):
306
+ stack.append(n.left)
307
+ stack.append(n.right)
308
+ stack.append(Previous(Not(n)))
309
+ return frozenset(cl)
310
+
311
+
312
+ def _holds(atom: FrozenSet[Node], node: Node) -> bool:
313
+ """Not-aware ATOM membership query: is ``node`` true according to the
314
+ frozenset ``atom`` of an atom's true closure elements? Strips every
315
+ leading ``Not`` first. Used everywhere an already-BUILT atom is read
316
+ (graph construction, the initial/seeded filters, fairness sets, …)."""
317
+ neg = False
318
+ while isinstance(node, Not):
319
+ node = node.formula
320
+ neg = not neg
321
+ return (node in atom) != neg
322
+
323
+
324
+ def _truth(node: Node, vals: Dict[Node, bool]) -> bool:
325
+ """Recursively DERIVE ``node``'s truth in ``vals`` (memoizing into ``vals``
326
+ as it goes — this is the one place a closure element's value is computed
327
+ from scratch, via :func:`_dval` on its equation's operands).
328
+
329
+ ``vals`` starts pre-seeded with the FREE elements' chosen truth values
330
+ (ground atoms and Next/Previous-shaped closure members — see the module
331
+ docstring's "every combination ... determines a full atom" argument).
332
+ Every other closure element's equation references only structurally
333
+ smaller subformulas (already resolvable by recursion) and exactly one
334
+ already-free Next/Previous-wrapped term, so this always terminates.
335
+ """
336
+ if node in vals:
337
+ return vals[node]
338
+ if isinstance(node, And):
339
+ v = _dval(vals, node.left) and _dval(vals, node.right)
340
+ elif isinstance(node, Or):
341
+ v = _dval(vals, node.left) or _dval(vals, node.right)
342
+ elif isinstance(node, Implies):
343
+ v = (not _dval(vals, node.left)) or _dval(vals, node.right)
344
+ elif isinstance(node, Iff):
345
+ v = _dval(vals, node.left) == _dval(vals, node.right)
346
+ elif isinstance(node, Xor):
347
+ v = _dval(vals, node.left) != _dval(vals, node.right)
348
+ elif isinstance(node, Always):
349
+ v = _dval(vals, node.formula) and _dval(vals, Next(node))
350
+ elif isinstance(node, Eventually):
351
+ v = _dval(vals, node.formula) or _dval(vals, Next(node))
352
+ elif isinstance(node, Until):
353
+ v = _dval(vals, node.right) or (
354
+ _dval(vals, node.left) and _dval(vals, Next(node)))
355
+ elif isinstance(node, Historically):
356
+ v = _dval(vals, node.formula) and _dval(vals, Previous(node))
357
+ elif isinstance(node, Once):
358
+ v = _dval(vals, node.formula) or (not _dval(vals, Previous(Not(node))))
359
+ elif isinstance(node, Since):
360
+ v = _dval(vals, node.right) or (
361
+ _dval(vals, node.left) and not _dval(vals, Previous(Not(node))))
362
+ else: # pragma: no cover — every non-free _SUPPORTED shape is handled above
363
+ raise AssertionError(f"ltl_tableau: {node!r} should already be a free element")
364
+ vals[node] = v
365
+ return v
366
+
367
+
368
+ def _dval(vals: Dict[Node, bool], node: Node) -> bool:
369
+ """Not-aware truth query INTO an in-progress ``vals`` computation: strips
370
+ every leading ``Not``, then recurses through :func:`_truth` (never a bare
371
+ dict lookup) so a not-yet-computed dependency is derived on demand."""
372
+ neg = False
373
+ while isinstance(node, Not):
374
+ node = node.formula
375
+ neg = not neg
376
+ return _truth(node, vals) != neg
377
+
378
+
379
+ def _all_atoms(cl: FrozenSet[Node], max_atoms: int, deadline: Optional[float] = None):
380
+ """Enumerate every locally consistent atom over ``cl``, or ``None`` if the
381
+ free-element count would make ``2 ** k`` exceed ``max_atoms`` or the
382
+ ``time.perf_counter()`` ``deadline`` passes first."""
383
+ # `$true` / `$false` are constants: they are never free (not varied), and every
384
+ # atom gives them their one value.
385
+ constants: Dict[Node, bool] = {}
386
+ for c in cl:
387
+ value = truth_value(c)
388
+ if value is not None:
389
+ constants[c] = value
390
+ free = sorted((c for c in cl
391
+ if isinstance(c, (Atom, Next, Previous)) and c not in constants),
392
+ key=repr)
393
+ if 2 ** len(free) > max_atoms:
394
+ return None
395
+ atoms = []
396
+ for bits in _bit_combinations(len(free)):
397
+ if deadline is not None and time.perf_counter() > deadline:
398
+ return None
399
+ vals: Dict[Node, bool] = dict(zip(free, bits))
400
+ vals.update(constants)
401
+ for c in cl:
402
+ _truth(c, vals)
403
+ atoms.append(frozenset(c for c in cl if vals[c]))
404
+ return atoms
405
+
406
+
407
+ def _bit_combinations(k: int):
408
+ """Yield every length-``k`` tuple of bools, ``False``/``True`` per slot."""
409
+ if k == 0:
410
+ yield ()
411
+ return
412
+ for rest in _bit_combinations(k - 1):
413
+ yield (False,) + rest
414
+ yield (True,) + rest
415
+
416
+
417
+ # --------------------------------------------------------------------------- #
418
+ # Graph construction: Next/Previous-consistency edges between atoms.
419
+ # --------------------------------------------------------------------------- #
420
+
421
+ def _build_graph(atoms: List[FrozenSet[Node]],
422
+ next_terms: List[Next], prev_terms: List[Previous],
423
+ deadline: Optional[float] = None):
424
+ """Return ``edges``: ``edges[i]`` is the set of ``j`` with atoms[i] -> atoms[j]
425
+ a valid step — for every ``Next(g)``: ``Next(g) in A <=> g holds in B``;
426
+ for every ``Previous(g)``: ``Previous(g) in B <=> g holds in A``. Bucketed
427
+ by B's "as-a-next-target" signature so this is faster than the naive
428
+ O(atoms^2 * |terms|) in the common case of few distinct signatures.
429
+ Returns ``None`` instead if the ``time.perf_counter()`` instant ``deadline``
430
+ passes while the edges are built.
431
+ """
432
+ n = len(atoms)
433
+
434
+ def next_body_sig(atom):
435
+ return tuple(_holds(atom, t.formula) for t in next_terms)
436
+
437
+ def next_own_sig(atom):
438
+ return tuple(t in atom for t in next_terms)
439
+
440
+ def prev_body_sig(atom):
441
+ return tuple(_holds(atom, t.formula) for t in prev_terms)
442
+
443
+ def prev_own_sig(atom):
444
+ return tuple(t in atom for t in prev_terms)
445
+
446
+ buckets: Dict[tuple, List[int]] = {}
447
+ prev_own = [None] * n
448
+ prev_body = [None] * n
449
+ for j, B in enumerate(atoms):
450
+ buckets.setdefault(next_body_sig(B), []).append(j)
451
+ prev_own[j] = prev_own_sig(B)
452
+ for i in range(n):
453
+ prev_body[i] = prev_body_sig(atoms[i])
454
+
455
+ edges: List[set] = [set() for _ in range(n)]
456
+ for i, A in enumerate(atoms):
457
+ if deadline is not None and time.perf_counter() > deadline:
458
+ return None
459
+ for j in buckets.get(next_own_sig(A), ()):
460
+ if prev_own[j] == prev_body[i]:
461
+ edges[i].add(j)
462
+ return edges
463
+
464
+
465
+ # --------------------------------------------------------------------------- #
466
+ # Reachability, pruning, SCC, generalized-Büchi fairness, witness extraction.
467
+ # --------------------------------------------------------------------------- #
468
+
469
+ def _check(deadline: Optional[float]) -> None:
470
+ """Raise :class:`~unicode_logic_kit._deadline.DeadlineReached` if ``deadline`` has passed.
471
+
472
+ The graph algorithms below call it once per node they take up, so none of them runs
473
+ past the deadline by more than one node's edges; :func:`_run` catches the exception.
474
+ """
475
+ if _passed(deadline):
476
+ raise DeadlineReached
477
+
478
+
479
+ def _bfs_reachable(starts, edges, deadline: Optional[float] = None) -> set:
480
+ """Every index reachable from ``starts`` (inclusive) following ``edges``."""
481
+ seen = set(starts)
482
+ frontier = list(starts)
483
+ while frontier:
484
+ _check(deadline)
485
+ i = frontier.pop()
486
+ for j in edges[i]:
487
+ if j not in seen:
488
+ seen.add(j)
489
+ frontier.append(j)
490
+ return seen
491
+
492
+
493
+ def _bfs_path(start: int, targets: set, edges,
494
+ deadline: Optional[float] = None) -> Optional[List[int]]:
495
+ """Shortest path (list of indices, ``start`` first) from ``start`` to any
496
+ index in ``targets``, following only edges whose BOTH ends are keys of
497
+ ``edges`` (i.e. edges already restricted to the relevant induced
498
+ subgraph); ``None`` if unreachable."""
499
+ if start in targets:
500
+ return [start]
501
+ parent: Dict[int, Optional[int]] = {start: None}
502
+ frontier = [start]
503
+ while frontier:
504
+ nxt = []
505
+ for i in frontier:
506
+ _check(deadline)
507
+ for j in edges.get(i, ()):
508
+ if j in parent:
509
+ continue
510
+ parent[j] = i
511
+ if j in targets:
512
+ path = [j]
513
+ back = parent[j]
514
+ while back is not None:
515
+ path.append(back)
516
+ back = parent[back]
517
+ path.reverse()
518
+ return path
519
+ nxt.append(j)
520
+ frontier = nxt
521
+ return None
522
+
523
+
524
+ def _prune(nodes: set, edges, deadline: Optional[float] = None) -> set:
525
+ """Remove nodes with no outgoing edge staying inside the current set,
526
+ to a fixpoint — exactly the nodes that can start SOME infinite path."""
527
+ alive = set(nodes)
528
+ changed = True
529
+ while changed:
530
+ changed = False
531
+ for i in list(alive):
532
+ _check(deadline)
533
+ if not (edges[i] & alive):
534
+ alive.discard(i)
535
+ changed = True
536
+ return alive
537
+
538
+
539
+ def _sccs(nodes: set, edges, deadline: Optional[float] = None) -> List[set]:
540
+ """Strongly connected components of the subgraph induced by ``nodes``
541
+ (iterative Kosaraju: two passes, no recursion-depth risk)."""
542
+ order: List[int] = []
543
+ visited = set()
544
+ for start in nodes:
545
+ if start in visited:
546
+ continue
547
+ stack = [(start, iter(edges[start] & nodes))]
548
+ visited.add(start)
549
+ while stack:
550
+ _check(deadline)
551
+ node, it = stack[-1]
552
+ advanced = False
553
+ for nxt in it:
554
+ if nxt not in visited:
555
+ visited.add(nxt)
556
+ stack.append((nxt, iter(edges[nxt] & nodes)))
557
+ advanced = True
558
+ break
559
+ if not advanced:
560
+ order.append(node)
561
+ stack.pop()
562
+
563
+ reverse: Dict[int, set] = {i: set() for i in nodes}
564
+ for i in nodes:
565
+ _check(deadline)
566
+ for j in edges[i] & nodes:
567
+ reverse[j].add(i)
568
+
569
+ seen = set()
570
+ components = []
571
+ for node in reversed(order):
572
+ if node in seen:
573
+ continue
574
+ comp = set()
575
+ pending = [node]
576
+ seen.add(node)
577
+ while pending:
578
+ _check(deadline)
579
+ cur = pending.pop()
580
+ comp.add(cur)
581
+ for prev in reverse[cur]:
582
+ if prev not in seen:
583
+ seen.add(prev)
584
+ pending.append(prev)
585
+ components.append(comp)
586
+ return components
587
+
588
+
589
+ def _fairness_sets(cl: FrozenSet[Node], atoms: List[FrozenSet[Node]],
590
+ deadline: Optional[float] = None):
591
+ """Generalized-Büchi acceptance sets: atom-indices where an outstanding
592
+ promise is either not made or already kept. A fair path must hit EVERY
593
+ one of these infinitely often. Two kinds of promise need one:
594
+
595
+ * an EXPLICIT future eventuality — ``Eventually(g)``/``Until(l, r)`` in
596
+ ``cl`` — kept once ``g``/``r`` holds;
597
+ * an IMPLICIT one hiding inside a FALSE ``Always(g)``: ``¬Gφ`` is,
598
+ semantically, ``F(¬φ)`` — a promise of an eventual ``¬φ`` — but this
599
+ module tracks only ``Gφ`` itself as a closure element (see
600
+ :func:`_closure`), so an atom's plain ABSENCE of ``Always(g)`` carries
601
+ that promise with no explicit node to hang a fairness set on. Without
602
+ one, the atom/edge construction alone is only LOCALLY consistent at
603
+ every step (``¬Gφ`` at ``n`` needs only ``¬Gφ`` or ``¬φ`` at ``n+1``),
604
+ which a path can satisfy forever by always deferring — asserting
605
+ ``Gφ`` false at every position while ``φ`` holds at every position too,
606
+ never actually witnessing ``¬φ`` — a "lying" run a bounded lasso search
607
+ catches immediately (this fairness set is what makes ``Fp ↔ ¬G¬p``
608
+ come back valid rather than handing back exactly such a countermodel).
609
+
610
+ Past duals (``Historically``/``Once``/``Since``, in either polarity) need
611
+ NONE of this: their truth is pinned by the Previous-edge chain as a path
612
+ is walked forward, and that chain always terminates at an actual
613
+ position-0 base case in finitely many backward steps — so nothing past
614
+ can ever be "promised and deferred forever" the way a FORWARD obligation
615
+ can (see the module docstring).
616
+ """
617
+ sets = []
618
+ for e in cl:
619
+ _check(deadline)
620
+ if isinstance(e, Eventually):
621
+ sets.append({i for i, atom in enumerate(atoms)
622
+ if e not in atom or _holds(atom, e.formula)})
623
+ elif isinstance(e, Until):
624
+ sets.append({i for i, atom in enumerate(atoms)
625
+ if e not in atom or _holds(atom, e.right)})
626
+ elif isinstance(e, Always):
627
+ sets.append({i for i, atom in enumerate(atoms)
628
+ if e in atom or not _holds(atom, e.formula)})
629
+ return sets
630
+
631
+
632
+ def _has_fair_witness(starts: set, atoms, edges, fairness_sets,
633
+ deadline: Optional[float] = None):
634
+ """Does SOME infinite path from ``starts`` hit every fairness set
635
+ infinitely often? Returns ``(True, entry, scc, reach_edges)`` — ``entry``
636
+ a chosen SCC member and ``reach_edges`` the induced-subgraph edge map
637
+ used to build the witness path — or ``(False, None, None, None)``.
638
+ """
639
+ reach = _bfs_reachable(starts, edges, deadline)
640
+ reach_edges = {i: (edges[i] & reach) for i in reach}
641
+ alive = _prune(reach, {i: reach_edges[i] for i in reach}, deadline)
642
+ if not alive:
643
+ return False, None, None, None
644
+ alive_edges = {i: (reach_edges[i] & alive) for i in alive}
645
+ for comp in _sccs(alive, alive_edges, deadline):
646
+ _check(deadline)
647
+ nontrivial = len(comp) > 1 or any(i in alive_edges[i] for i in comp)
648
+ if not nontrivial:
649
+ continue
650
+ if all(comp & fs for fs in fairness_sets):
651
+ entry = next(iter(comp))
652
+ return True, entry, comp, alive_edges
653
+ return False, None, None, None
654
+
655
+
656
+ def _cycle_visiting_all(entry: int, scc: set, edges,
657
+ deadline: Optional[float] = None) -> List[int]:
658
+ """A closed walk ``entry -> ... -> entry`` (indices, entry both ends, at
659
+ least one edge taken) visiting every node of ``scc`` at least once —
660
+ always exists since ``scc`` is strongly connected (or, for a singleton
661
+ ``scc``, has a self-loop — the other half of "non-trivial", checked by
662
+ the caller). Chains shortest paths through an arbitrary but fixed order
663
+ of ``scc``'s members, so it trivially hits every fairness set's
664
+ intersection with ``scc`` along the way."""
665
+ local_edges = {i: (edges[i] & scc) for i in scc}
666
+ others = [n for n in scc if n != entry]
667
+ if not others:
668
+ # A singleton non-trivial SCC is exactly a self-loop.
669
+ return [entry, entry]
670
+ order = [entry] + others + [entry]
671
+ walk = [entry]
672
+ for k in range(len(order) - 1):
673
+ _check(deadline)
674
+ path = _bfs_path(walk[-1], {order[k + 1]}, local_edges, deadline)
675
+ walk.extend(path[1:])
676
+ return walk
677
+
678
+
679
+ def _valuation(atom: FrozenSet[Node]) -> FrozenSet[str]:
680
+ """Ground-atom keys true at ``atom`` (mirrors modal_tableau's ``_build_model``)."""
681
+ return frozenset(key_text(a) for a in atom
682
+ if isinstance(a, Atom) and truth_value(a) is None)
683
+
684
+
685
+ # --------------------------------------------------------------------------- #
686
+ # The core engine.
687
+ # --------------------------------------------------------------------------- #
688
+
689
+ def _run(seeds: Sequence[Node], mode: str, max_atoms: int, timeout: Optional[int] = None):
690
+ """Decide joint satisfiability of ``seeds`` under ``mode``.
691
+
692
+ Returns ``("unsat", None)``, ``("sat", LTLTrace)``, or ``("unknown", None)``
693
+ (the atom count exceeded ``max_atoms``, or the ``timeout`` in milliseconds ran
694
+ out). The clock is read while the atoms and the graph between them are built and
695
+ in every step after that (the reachability searches, the pruning, the strongly
696
+ connected components, the fairness test and the witness walk), so the call as a
697
+ whole ends at its deadline.
698
+ """
699
+ if mode not in ("initial", "floating"):
700
+ raise ValueError(f"ltl_tableau: unknown mode {mode!r} (use 'initial' or 'floating')")
701
+ _reject_equality(seeds, "ltl_tableau")
702
+
703
+ deadline = None if timeout is None else time.perf_counter() + timeout / 1000.0
704
+ try:
705
+ return _decide(seeds, mode, max_atoms, deadline)
706
+ except DeadlineReached:
707
+ return "unknown", None
708
+
709
+
710
+ def _decide(seeds: Sequence[Node], mode: str, max_atoms: int, deadline: Optional[float]):
711
+ """The search of :func:`_run`; raises :class:`~unicode_logic_kit._deadline.DeadlineReached`
712
+ once ``deadline`` (a ``perf_counter`` instant, or ``None``) has passed."""
713
+ cl = _closure(seeds)
714
+ atoms = _all_atoms(cl, max_atoms, deadline)
715
+ if atoms is None:
716
+ return "unknown", None
717
+ next_terms = [c for c in cl if isinstance(c, Next)]
718
+ prev_terms = [c for c in cl if isinstance(c, Previous)]
719
+ edges = _build_graph(atoms, next_terms, prev_terms, deadline)
720
+ if edges is None:
721
+ return "unknown", None
722
+
723
+ init = set()
724
+ seeded = set()
725
+ for i, atom in enumerate(atoms):
726
+ _check(deadline)
727
+ if all(pv in atom for pv in prev_terms):
728
+ init.add(i)
729
+ if all(_holds(atom, s) for s in seeds):
730
+ seeded.add(i)
731
+
732
+ if mode == "initial":
733
+ starts = init & seeded
734
+ prefix_from_init: Dict[int, List[int]] = {i: [i] for i in starts}
735
+ else:
736
+ reach_from_init = _bfs_reachable(init, edges, deadline)
737
+ starts = reach_from_init & seeded
738
+ if not starts:
739
+ prefix_from_init = {}
740
+ else:
741
+ # BFS from EVERY init atom at once, recording one shortest
742
+ # path per reached index (used only for the witness, below).
743
+ parent: Dict[int, Optional[int]] = {i: None for i in init}
744
+ frontier = list(init)
745
+ while frontier:
746
+ nxt = []
747
+ for i in frontier:
748
+ _check(deadline)
749
+ for j in edges[i]:
750
+ if j not in parent:
751
+ parent[j] = i
752
+ nxt.append(j)
753
+ frontier = nxt
754
+
755
+ def _path_to(i):
756
+ path = [i]
757
+ while parent[path[-1]] is not None:
758
+ path.append(parent[path[-1]])
759
+ path.reverse()
760
+ return path
761
+
762
+ prefix_from_init = {i: _path_to(i) for i in starts}
763
+
764
+ if not starts:
765
+ return "unsat", None
766
+
767
+ fairness_sets = _fairness_sets(cl, atoms, deadline)
768
+ ok, entry, scc, alive_edges = _has_fair_witness(starts, atoms, edges, fairness_sets, deadline)
769
+ if not ok:
770
+ return "unsat", None
771
+
772
+ # Pick a start atom that can actually reach the fair SCC on its own (one
773
+ # must exist: `entry` was found reachable from `starts` taken together,
774
+ # and a multi-source BFS only ever discovers a node through ONE origin).
775
+ start_idx = None
776
+ for i in starts:
777
+ local_reach = _bfs_reachable({i}, edges, deadline)
778
+ if entry in local_reach:
779
+ start_idx = i
780
+ break
781
+
782
+ lead_in = prefix_from_init[start_idx]
783
+ reach_of_start = _bfs_reachable({start_idx}, edges, deadline)
784
+ to_scc_edges = {i: (edges[i] & reach_of_start) for i in reach_of_start}
785
+ path_to_scc = _bfs_path(lead_in[-1], scc, to_scc_edges, deadline)
786
+ entry_actual = path_to_scc[-1]
787
+ cycle_walk = _cycle_visiting_all(entry_actual, scc, alive_edges, deadline)
788
+
789
+ full_prefix_idx = lead_in[:-1] + path_to_scc # ends AT entry_actual
790
+ # `cycle_walk` is the CLOSED walk entry_actual -> ... -> entry_actual, so
791
+ # its own first element duplicates full_prefix_idx's last one (same
792
+ # atom, not a second hop) — drop THAT one, not the last: the position
793
+ # right after the prefix must be reached from entry_actual by an actual
794
+ # edge (cycle_walk[1]), and the cycle's own last element must be the one
795
+ # with a valid edge BACK to cycle_walk[1] (i.e. entry_actual itself,
796
+ # cycle_walk[-1]) to close the loop soundly.
797
+ cycle_idx = cycle_walk[1:] # first hop .. back to entry_actual
798
+
799
+ prefix = tuple(_valuation(atoms[i]) for i in full_prefix_idx)
800
+ cycle = tuple(_valuation(atoms[i]) for i in cycle_idx)
801
+ witness_position = len(lead_in) - 1
802
+ trace = LTLTrace(prefix=prefix, cycle=cycle, witness_position=witness_position)
803
+ return "sat", trace
804
+
805
+
806
+ # --------------------------------------------------------------------------- #
807
+ # Independent evaluator (used to verify every witness before release).
808
+ # --------------------------------------------------------------------------- #
809
+
810
+ def ltl_trace_satisfies(formula: Node, trace: LTLTrace,
811
+ position: Optional[int] = None, horizon: int = 200) -> bool:
812
+ """Evaluate ``formula`` at ``position`` (default ``trace.witness_position``)
813
+ of ``trace``, directly from the linear-time satisfaction equations — no
814
+ connection to the closure/atom/graph machinery in this module, so it is a
815
+ genuine independent check on any witness this module produces.
816
+
817
+ ``horizon`` bounds how far the FUTURE (G/F/U) search looks ahead; it is
818
+ always sound to stop early because the trace is eventually periodic (an
819
+ exhaustive check across one full extra period after the point it last
820
+ changed value is conclusive forever after). Past operators (H/O/Y/S)
821
+ recurse strictly backward to position 0, always exactly (never
822
+ approximated) and always terminating, since every position has only
823
+ finitely many predecessors. ``horizon`` defaults generously (the tests
824
+ here use prefixes/cycles of at most a handful of positions), so 200 is
825
+ already far more than any nesting of temporal operators this module's own
826
+ tests exercise could need to stabilise a forward search across.
827
+
828
+ An equality or disequality atom is refused by name (``NotImplementedError``), as
829
+ in every entry point of this module: a trace is a valuation of rendered atom keys,
830
+ which gives identity no meaning. So are two different atoms that print alike (the
831
+ numeral ``1`` and a constant named ``1``, a free variable ``x`` and a constant named
832
+ ``x``): one key of the trace could not tell them apart.
833
+
834
+ A sorted constant ``c:S`` is the constant ``c``: ``Mortal(carl:Human)`` is read at the
835
+ key ``'Mortal(carl)'``, the key every countermodel trace of this module holds. That
836
+ ``Human(carl)`` is true at every position is a property of the trace which this
837
+ evaluator, like every evaluator of a given structure, does not check (the decision
838
+ functions add it as a premise).
839
+ """
840
+ _reject_equality([formula], "ltl_trace_satisfies")
841
+ AtomKeys("ltl_trace_satisfies").letters([formula])
842
+ return _trace_satisfies(formula, trace, position, horizon)
843
+
844
+
845
+ def _trace_satisfies(formula: Node, trace: LTLTrace,
846
+ position: Optional[int] = None, horizon: int = 200) -> bool:
847
+ """The evaluation of :func:`ltl_trace_satisfies`, for a formula already known to hold no
848
+ equality atom and no two atoms of one key (the decision functions verify the witness of
849
+ a search that kept its atoms as nodes, and read a pair of clashing atoms as ``unknown``
850
+ rather than refuse)."""
851
+ if position is None:
852
+ position = trace.witness_position
853
+ memo: Dict[Tuple[Node, int], bool] = {}
854
+
855
+ def ev(node: Node, i: int) -> bool:
856
+ key = (node, i)
857
+ if key in memo:
858
+ return memo[key]
859
+ if isinstance(node, Not):
860
+ v = not ev(node.formula, i)
861
+ elif isinstance(node, Atom):
862
+ constant = truth_value(node)
863
+ v = constant if constant is not None else find_key(trace.at(i), node) is not None
864
+ elif isinstance(node, And):
865
+ v = ev(node.left, i) and ev(node.right, i)
866
+ elif isinstance(node, Or):
867
+ v = ev(node.left, i) or ev(node.right, i)
868
+ elif isinstance(node, Implies):
869
+ v = (not ev(node.left, i)) or ev(node.right, i)
870
+ elif isinstance(node, Iff):
871
+ v = ev(node.left, i) == ev(node.right, i)
872
+ elif isinstance(node, Xor):
873
+ v = ev(node.left, i) != ev(node.right, i)
874
+ elif isinstance(node, Next):
875
+ v = ev(node.formula, i + 1)
876
+ elif isinstance(node, Previous):
877
+ v = True if i == 0 else ev(node.formula, i - 1)
878
+ elif isinstance(node, Always):
879
+ v = all(ev(node.formula, m) for m in range(i, i + horizon))
880
+ elif isinstance(node, Eventually):
881
+ v = any(ev(node.formula, m) for m in range(i, i + horizon))
882
+ elif isinstance(node, Until):
883
+ v = False
884
+ for m in range(i, i + horizon):
885
+ if ev(node.right, m):
886
+ v = True
887
+ break
888
+ if not ev(node.left, m):
889
+ break
890
+ elif isinstance(node, Historically):
891
+ v = all(ev(node.formula, m) for m in range(0, i + 1))
892
+ elif isinstance(node, Once):
893
+ v = any(ev(node.formula, m) for m in range(0, i + 1))
894
+ elif isinstance(node, Since):
895
+ v = False
896
+ for m in range(i, -1, -1):
897
+ if ev(node.right, m):
898
+ v = True
899
+ break
900
+ if not ev(node.left, m):
901
+ break
902
+ else:
903
+ raise NotImplementedError(
904
+ f"ltl_trace_satisfies: no rule for {type(node).__name__}")
905
+ memo[key] = v
906
+ return v
907
+
908
+ return ev(formula, position)
909
+
910
+
911
+ # --------------------------------------------------------------------------- #
912
+ # Public entry points — mirroring modal_tableau's surface.
913
+ # --------------------------------------------------------------------------- #
914
+
915
+ def _fold_goal(formula: Node, premises: Sequence[Node]) -> Node:
916
+ """Fold ``premises ⊨ formula`` into ``(∧ premises) → formula`` (empty
917
+ premises: ``formula`` unchanged) — same convention as
918
+ ``atp.protocol._implication``, reimplemented locally so this module stays
919
+ a self-contained new addition rather than reaching into protocol.py."""
920
+ premises = list(premises)
921
+ if not premises:
922
+ return formula
923
+ conj = premises[0]
924
+ for p in premises[1:]:
925
+ conj = And(conj, p)
926
+ return Implies(conj, formula)
927
+
928
+
929
+ def _lift_sorted_constants(formulas: Sequence[Node], mode: str):
930
+ """``formulas`` with every sorted constant plain, and the seeds that keep its sort true of it.
931
+
932
+ A sorted constant ``c:S`` is the constant ``c`` and an element of ``S`` at EVERY
933
+ position (a constant is a rigid designator), so ``Mortal(c:S)`` and ``Mortal(c)``
934
+ are one letter and the guard atom ``S(c)`` is a letter true everywhere. The seeds
935
+ are ``Always S(c)`` (and, for ``mode="floating"``, where the anchor may have a
936
+ past, ``Historically S(c)`` too), one set per distinct ``c:S``. Without them
937
+ ``Human(carl:Human)`` has a model in which carl is no ``Human``, and a valid
938
+ formula is reported invalid. A formula without a sorted constant is returned as
939
+ it is, with no seed.
940
+ """
941
+ membership: List[Node] = []
942
+ plain = [_forget_constant_sorts(f, membership) for f in formulas]
943
+ seeds: List[Node] = []
944
+ for atom in dict.fromkeys(membership):
945
+ seeds.append(Always(atom))
946
+ if mode == "floating":
947
+ seeds.append(Historically(atom))
948
+ return plain, seeds
949
+
950
+
951
+ def ltl_tableau_closed(formulas: Sequence[Node], mode: str = "initial",
952
+ max_atoms: int = _DEFAULT_MAX_ATOMS,
953
+ timeout: Optional[int] = None) -> bool:
954
+ """True iff ``formulas`` are jointly UNSATISFIABLE under ``mode`` (see the
955
+ module docstring for "initial" vs. "floating"). ``False`` means either a
956
+ genuine model exists or the search hit ``max_atoms`` — use
957
+ :func:`ltl_decide`/:func:`ltl_countermodel` to tell those apart.
958
+
959
+ A sorted constant ``c:S`` lies in ``S`` at every position (see
960
+ :func:`_lift_sorted_constants`). ``timeout`` (milliseconds, default none) ends
961
+ the construction of the atoms and of the graph between them: ``False`` then."""
962
+ plain, seeds = _lift_sorted_constants(list(formulas), mode)
963
+ status, _ = _run(plain + seeds, mode, max_atoms, timeout)
964
+ return status == "unsat"
965
+
966
+
967
+ def ltl_valid(formula: Node, premises: Sequence[Node] = (), mode: str = "initial",
968
+ max_atoms: int = _DEFAULT_MAX_ATOMS, timeout: Optional[int] = None) -> bool:
969
+ """True iff ``premises`` entail ``formula`` under the standard linear-time
970
+ reading — i.e. ``¬((∧ premises) → formula)`` has no model. Sound and
971
+ complete for the supported fragment up to ``max_atoms``; use
972
+ :func:`ltl_decide` to distinguish a genuine "invalid" from "unknown". ``timeout``
973
+ is as for :func:`ltl_tableau_closed`."""
974
+ (goal,), seeds = _lift_sorted_constants([_fold_goal(formula, premises)], mode)
975
+ status, _ = _run([Not(goal)] + seeds, mode, max_atoms, timeout)
976
+ return status == "unsat"
977
+
978
+
979
+ def ltl_decide(formula: Node, premises: Sequence[Node] = (), mode: str = "initial",
980
+ max_atoms: int = _DEFAULT_MAX_ATOMS, timeout: Optional[int] = None) -> str:
981
+ """Decide ``premises ⊨ formula``: ``"valid"`` / ``"invalid"`` / ``"unknown"``.
982
+
983
+ * ``"valid"`` — no model of the negation exists (a sound proof).
984
+ * ``"invalid"`` — a witness trace was found AND independently verified by
985
+ :func:`ltl_trace_satisfies` to falsify ``formula`` (at ``witness_position``).
986
+ * ``"unknown"`` — ``max_atoms`` was hit, the ``timeout`` (milliseconds, default
987
+ none) ran out, or (should never happen — an
988
+ internal-consistency safety net, not a real incompleteness source) the
989
+ witness failed independent verification.
990
+
991
+ A sorted constant ``c:S`` lies in ``S`` at every position (see
992
+ :func:`_lift_sorted_constants`), and a witness is verified to be such a model.
993
+ """
994
+ (goal,), seeds = _lift_sorted_constants([_fold_goal(formula, premises)], mode)
995
+ status, trace = _run([Not(goal)] + seeds, mode, max_atoms, timeout)
996
+ if status == "unsat":
997
+ return "valid"
998
+ if status == "sat" and trace is not None:
999
+ if (not _trace_satisfies(goal, trace)
1000
+ and all(_trace_satisfies(seed, trace) for seed in seeds)):
1001
+ return "invalid"
1002
+ return "unknown"
1003
+
1004
+
1005
+ def ltl_countermodel(formula: Node, premises: Sequence[Node] = (), mode: str = "initial",
1006
+ max_atoms: int = _DEFAULT_MAX_ATOMS,
1007
+ timeout: Optional[int] = None) -> Optional[LTLTrace]:
1008
+ """Return an :class:`LTLTrace` falsifying ``premises ⊨ formula``, or ``None``.
1009
+
1010
+ ``None`` means "valid" (the negation is unsatisfiable) **or** the search
1011
+ was inconclusive within ``max_atoms``. The returned trace is verified: it
1012
+ is only handed back once :func:`ltl_trace_satisfies` confirms ``formula``
1013
+ is false at ``witness_position``, so a countermodel is never spurious. ``timeout``
1014
+ is as for :func:`ltl_tableau_closed`.
1015
+ """
1016
+ (goal,), seeds = _lift_sorted_constants([_fold_goal(formula, premises)], mode)
1017
+ status, trace = _run([Not(goal)] + seeds, mode, max_atoms, timeout)
1018
+ if status != "sat" or trace is None:
1019
+ return None
1020
+ if (not _trace_satisfies(goal, trace)
1021
+ and all(_trace_satisfies(seed, trace) for seed in seeds)):
1022
+ return trace
1023
+ return None
1024
+
1025
+
1026
+ # --------------------------------------------------------------------------- #
1027
+ # ProverBackend registration.
1028
+ # --------------------------------------------------------------------------- #
1029
+
1030
+ from .protocol import ProverBackend, Verdict, PROVED, REFUTED, UNKNOWN # noqa: E402
1031
+
1032
+
1033
+ class LtlTableauBackend(ProverBackend):
1034
+ """This module as a :class:`~unicode_logic_kit.atp.protocol.ProverBackend`.
1035
+
1036
+ Registered under ``"ltl-tableau"``. Tagged ``logics={"modal"}`` for
1037
+ discovery alongside ``modal-tableau``/``qml`` (:func:`available_backends`),
1038
+ but NOT part of ``default_chain("modal")`` — this backend answers a
1039
+ strictly NARROWER question (the standard linear frame) than the rest of
1040
+ that chain, so it must be reached by name, exactly like the external
1041
+ provers, rather than silently joining a portfolio that assumes a shared
1042
+ frame class. It can never REFUTE something ``qml_is_valid`` already
1043
+ proved (the linear frame is a strict subset of qml's refl+trans+N⊆T
1044
+ frame class, so validity there implies validity here) and it can PROVE
1045
+ strictly more (temporal induction — see the module docstring) — so a
1046
+ disagreement between this backend and ``qml``/``modal-tableau`` is never
1047
+ possible in the unsound direction.
1048
+ """
1049
+
1050
+ name = "ltl-tableau"
1051
+ logics = frozenset({"modal"})
1052
+ external = False
1053
+
1054
+ def available(self) -> bool:
1055
+ return True
1056
+
1057
+ def decide(self, formula: Node, premises: Sequence[Node] = (),
1058
+ timeout: int = 10000, **options) -> Verdict:
1059
+ mode = options.pop("mode", "initial")
1060
+ max_atoms = options.pop("max_atoms", _DEFAULT_MAX_ATOMS)
1061
+ start = time.perf_counter()
1062
+ try:
1063
+ status = ltl_decide(formula, premises, mode=mode, max_atoms=max_atoms,
1064
+ timeout=timeout)
1065
+ except NotImplementedError as exc:
1066
+ return Verdict(UNKNOWN, self.name, logic="modal",
1067
+ reason="unsupported", detail=str(exc))
1068
+ elapsed = time.perf_counter() - start
1069
+ if status == "valid":
1070
+ return Verdict(PROVED, self.name, logic="modal", wall_time=elapsed)
1071
+ if status == "invalid":
1072
+ # the witness is a second search; it gets what is left of the limit
1073
+ trace = ltl_countermodel(formula, premises, mode=mode, max_atoms=max_atoms,
1074
+ timeout=max(1, int(timeout - elapsed * 1000)))
1075
+ witness = trace.to_dict() if trace is not None else None
1076
+ return Verdict(REFUTED, self.name, logic="modal", wall_time=elapsed,
1077
+ countermodel=witness)
1078
+ if elapsed * 1000 >= timeout:
1079
+ return Verdict(UNKNOWN, self.name, logic="modal", reason="timeout",
1080
+ wall_time=elapsed,
1081
+ detail=f"no verdict within the {timeout} ms limit")
1082
+ return Verdict(UNKNOWN, self.name, logic="modal", reason="bound_hit",
1083
+ wall_time=elapsed,
1084
+ detail=f"closure exceeded max_atoms={max_atoms}")