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,1139 @@
1
+ """Kripke (possible-worlds) semantics for the propositional modal fragment.
2
+
3
+ A :class:`KripkeModel` is a possible-worlds frame: a set of worlds, a family of
4
+ named accessibility relations between worlds, and a per-world valuation of the
5
+ ground atoms. :func:`satisfies_modal` computes the truth value of a modal
6
+ formula *at a world*, following the standard Kripke satisfaction relation.
7
+
8
+ Only the **propositional / ground** modal fragment is interpreted here (this is
9
+ v1): the modal operators wrap classical connectives and ground atoms. A ground
10
+ atom is identified by its key (:func:`~unicode_logic_kit.fol.atom_key`: the text it prints as,
11
+ with every constant written by its name, e.g. ``"P"`` or ``"Likes(a, b)"``, also where the
12
+ formula text writes the constant in quotes, ``Likes('a', 'b')``); a world's valuation
13
+ is the set of atom keys true there, and an atom whose key is written either way (its key or
14
+ its text as a formula, ``atom.to_unicode_str()``) is found, so a missing key is false.
15
+ Object quantifiers
16
+ (plain ``Quantifier`` and sorted ``SortedQuantifier``, see "Many-sorted formulas"
17
+ below) ARE interpreted, over per-world domains; Łukasiewicz operators and lambda nodes are rejected
18
+ with NotImplementedError — fuzzy modal logic is future work.
19
+
20
+ Many-sorted formulas: ``satisfies_modal`` relativizes the WHOLE input formula
21
+ ONCE, up front, before any dispatch — the same "relativize once, up front"
22
+ choice ``fol.qml.qml_translate`` / ``semantics.intuitionistic._prepare_many_sorted``
23
+ make and for the identical reason: a ``SortedConstant`` (``alice:Human``) can
24
+ occur anywhere in the formula, not only directly under a ``SortedQuantifier``,
25
+ so relativizing lazily (only when the recursive descent happens to walk into a
26
+ ``SortedQuantifier`` node) would leave a bare sorted constant's ``:Sort``
27
+ suffix in place, and the valuation lookup for it would then silently miss.
28
+ Relativizing uses ``Node._relativize`` (the same reduction ``fol.to_fol``
29
+ uses): a ``SortedQuantifier`` (``∀x:S φ`` / ``∃x:S φ``) becomes a guarded plain
30
+ ``Quantifier`` — ``∀x (S(x) → φ)`` / ``∃x (S(x) ∧ φ)`` — over the current
31
+ world's domain ``D_w``; a bare ``SortedConstant`` becomes a plain ``Constant``.
32
+ Two consequences of this reduction, both deliberate design choices, not gaps:
33
+
34
+ - **Sorts are world-relative, not rigid.** The sort guard ``S(x)`` is an
35
+ ordinary atom, looked up in ``valuation`` exactly like any other atom, so an
36
+ individual can be ``S`` at one world and not at another — the same
37
+ "actualist" reading this module already gives the bare per-world domain
38
+ ``D_w`` (see ``domains``/``domain_at`` above). A caller that wants a sort
39
+ RIGID (the same extension at every world) states that itself, as an extra
40
+ frame condition on the relation being evaluated over (e.g. asserting
41
+ ``S(d) ↔ S'(d)`` between every pair of accessible worlds in the model it
42
+ builds) — this evaluator does not assume or enforce it.
43
+ - **Non-emptiness is the caller's responsibility, exactly as it already is for
44
+ the unsorted domain.** This evaluator never assumes a sort — or a bare
45
+ per-world domain — is non-empty; ``∀x:S φ → ∃x:S φ`` can come out FALSE
46
+ here if the model happens to make ``S`` empty at ``world``. The classical
47
+ many-sorted routes (``api.prove`` et al.) instead ALWAYS assume every sort
48
+ is non-empty, by adding ``fol.nonempty_sort_axioms`` as extra premises (see
49
+ that function's docstring) — so a caller who wants THIS evaluator to agree
50
+ with a classical MSFOL verdict on a modal-free sorted formula must build the
51
+ ``KripkeModel`` so each mentioned sort's guard atom holds of at least one
52
+ individual in ``D_w`` at every world that matters, the same way domains and
53
+ valuations are already the caller's construction to get right. See
54
+ ``tests/test_sorted_modal.py`` for a worked differential against
55
+ ``api.prove`` built this way.
56
+ - **A sorted constant being in its sort is a property of the MODEL, too.**
57
+ ``c:S`` denotes an element of ``S``, and a constant is a rigid designator, so
58
+ in a legal model the guard atom ``S(c)`` is true at EVERY world — whatever the
59
+ world's domain says about whether ``c`` exists there. The evaluator reads the
60
+ guard from the valuation like any other atom and does not check it: in a model
61
+ that leaves ``Human(socrates)`` out of some world's valuation,
62
+ ``∀x:Human Mortal(x) → Mortal(socrates:Human)`` can be FALSE there, a model
63
+ the many-sorted routes (``qml_is_valid``, ``api.prove``) never consider. The
64
+ routes assert the fact as a background axiom (``fol.sort_membership_axioms``,
65
+ lifted per world by ``fol.qml``) and an evaluator of ONE given model cannot, so
66
+ a caller who compares this evaluator with them builds the model that way, or
67
+ asks :func:`sorted_constant_violations` which worlds of a model fall short. A
68
+ plain constant ``socrates`` has no sort, and nothing is asserted about it.
69
+
70
+ Equality is NOT interpreted. ``=`` and ``≠`` need a semantics of TERMS — what
71
+ ``a`` and ``b`` denote, so that ``a = b`` can be decided as identity of those
72
+ denotations — and this evaluator has none: it never interprets a term, it looks
73
+ an atom up by its rendered key in a world's valuation set. Run on ``a = b`` that
74
+ lookup would silently read the identity as an uninterpreted proposition keyed
75
+ ``"a = b"``, so ``a = a`` would come out FALSE unless a caller happened to
76
+ list it, and ``□(a = b) → a = b`` would be decided by the frame alone. The kit's
77
+ rule is that an unsupported fragment is refused loudly, never approximated, so
78
+ :func:`satisfies_modal` (and so :func:`ctl_ex` / :func:`ctl_af` / :func:`ctl_eg`
79
+ / :func:`ctl_au`, and every evaluator built on it) raises ``NotImplementedError``
80
+ naming the atom as soon as ANY ``=`` / ``≠`` atom occurs ANYWHERE in the formula
81
+ — the check scans the whole tree before evaluating, because evaluating lazily
82
+ would let a short-circuit (``P ∨ a = b`` at a world where ``P`` holds), a
83
+ vacuous ``□`` at a dead end, or an empty domain skip the atom and return a verdict
84
+ that never looked at it. Decide identity with :func:`unicode_logic_kit.fol.qml.qml_is_valid`
85
+ (quantified modal logic, where ``=`` is rigid identity over the object domain) or
86
+ a first-order route; the other arithmetic comparisons (``<``, ``≤`` …) are
87
+ ordinary keyed atoms here, exactly as before.
88
+
89
+ Relation-name convention (keys of :attr:`KripkeModel.relations`):
90
+
91
+ - ``"alethic"`` — the accessibility relation for Box □ / Diamond ◇.
92
+ - ``"K:" + agent`` — the epistemic relation for ``Knows(agent, …)``, and
93
+ also what ``EverybodyKnows``/``DistributedKnowledge``/
94
+ ``CommonKnowledge`` (group operators E_G/D_G/C_G, see
95
+ below) combine by union/intersection/reflexive-
96
+ transitive-closure — there is no separate relation
97
+ family for the group operators.
98
+ - ``"B:" + agent`` — the doxastic relation for ``Believes(agent, …)``.
99
+ - ``"Say:" + agent`` — the assertive relation for ``Says(agent, …)`` (non-factive).
100
+ - ``"Want:" + agent`` — the bouletic relation for ``Wants(agent, …)`` (non-veridical).
101
+ - ``"temporal"`` — the one-step successor relation for Next / Always /
102
+ Eventually / Until.
103
+ - ``"deontic"`` — the (serial) accessibility relation for Obligatory O /
104
+ Permitted P (Standard Deontic Logic, the system KD).
105
+
106
+ A missing relation denotes the empty relation; a missing world valuation denotes
107
+ the empty set (every atom false there). Inputs are never mutated: the closure
108
+ and path helpers build fresh sets.
109
+
110
+ Hybrid logic H(@) is interpreted through the optional ``nominals`` mapping
111
+ (name → world): ``Nominal(i)`` is true exactly at the world the assignment
112
+ names, and ``At(i, φ)`` evaluates φ *at* that world, wherever the evaluation
113
+ currently stands. A nominal without an assignment raises a ValueError naming
114
+ it (rather than silently defaulting), since a nominal must name exactly one
115
+ world for the hybrid semantics to make sense.
116
+
117
+ The ↓ binder (N1, full H(@,↓)) — ``Down(x, φ)`` (``↓x.φ``) — is interpreted
118
+ the same way, with no extra machinery: evaluating ``Down(x, φ)`` at world
119
+ ``w`` locally REBINDS the nominal named ``x`` to ``w`` for the evaluation of
120
+ ``φ`` (still at ``w``), by recursing with a model whose ``nominals`` mapping
121
+ is ``model.nominals`` overridden at key ``x``. Because that override is a
122
+ plain dict-key overwrite — not a textual rewrite of ``φ`` — the usual
123
+ name-scoping rules of a binder fall out automatically, with no separate
124
+ alpha-renaming/fresh-name step: a nested ``Down(x, …)`` inside ``φ`` overrides
125
+ the SAME key again for its own (deeper) scope, so it shadows the outer
126
+ binding exactly the way a nested ``∀x`` would (``↓x.↓x.φ`` behaves as bare
127
+ ``φ``, the outer binding entirely inert); a DIFFERENTLY-named nested binder
128
+ ``Down(y, …)`` with ``y ≠ x`` extends the dict at a different key and leaves
129
+ ``x`` untouched, so it cannot capture an outer ``@x``/bare-``x`` occurrence
130
+ (``↓x.(P ∧ ↓y.@x Q)`` — the inner ``↓y`` cannot affect the outer ``@x``); and
131
+ because the rebound world ``w`` is fixed in the dict rather than tracked
132
+ positionally, an occurrence of ``x`` reached through a LATER modality (a
133
+ ``Box``/``Diamond`` inside ``φ`` moving evaluation to some other world
134
+ ``w2``) still resolves to the ORIGINAL ``w`` — the state variable, once
135
+ bound, is rigid for the rest of its scope, exactly like a nominal already is
136
+ (this is what makes ``↓x.□¬x`` the FO irreflexivity condition: for every
137
+ successor ``w2`` of ``w``, ``¬x`` means "``w2`` is not the world ``x``
138
+ names", i.e. not ``w`` itself).
139
+
140
+ H(@,↓) validity is UNDECIDABLE, but evaluation at a GIVEN finite model is not
141
+ touched by that — ``satisfies_modal`` decides ``Down`` exactly as it decides
142
+ every other construct here, so it remains the terminating, always-available
143
+ "route A" oracle for ↓ (hand-built models, and the brute-force battery in
144
+ ``tests/test_hybrid_down.py``), and the oracle
145
+ :func:`~unicode_logic_kit.atp.kripke_enum.modal_enum_search`'s bounded search
146
+ re-verifies every countermodel it reports against. See
147
+ :mod:`unicode_logic_kit.fol._hybrid_nodes` (the ``Down`` node) and
148
+ :mod:`unicode_logic_kit.fol.modal_translation` (``down_is_valid``, the
149
+ Z3/PROVED-only "route B" half) for the rest of the architecture.
150
+
151
+ Group epistemic operators — ``EverybodyKnows`` (E_G φ), ``DistributedKnowledge``
152
+ (D_G φ), and ``CommonKnowledge`` (C_G φ), each carrying a ``group`` tuple of
153
+ agent terms (:mod:`unicode_logic_kit.fol._modal_nodes`, parsed from
154
+ ``E_{a,b,…}``/``D_{a,b,…}``/``C_{a,b,…}`` surface syntax) — are THIN dispatches
155
+ into :mod:`unicode_logic_kit.semantics.action_models`'s
156
+ :func:`~unicode_logic_kit.semantics.action_models.everybody_knows` /
157
+ :func:`~unicode_logic_kit.semantics.action_models.distributed_knowledge_holds` /
158
+ :func:`~unicode_logic_kit.semantics.action_models.common_knowledge_holds`, which
159
+ implement the actual union/intersection/closure semantics over the group's
160
+ ``"K:"+agent`` relations — this module owns none of that logic, only the
161
+ node-to-function wiring (see those functions' docstrings for the semantics,
162
+ including ``distributed_knowledge_holds``'s deliberately-different empty-group
163
+ convention: it RAISES rather than defaulting).
164
+
165
+ Public announcement logic (PAL) is interpreted directly — ``Announce`` (``[φ!]ψ``)
166
+ and ``AnnounceDiamond`` (``⟨φ!⟩ψ``) are the only two constructs here that do NOT
167
+ just recurse at a fixed world or along a fixed relation: they build the
168
+ φ-restricted model ``M|φ`` (:func:`unicode_logic_kit.semantics.dynamic_epistemic.announce`)
169
+ and evaluate ``ψ`` there. This is the ORACLE that
170
+ :func:`unicode_logic_kit.fol.pal.reduce_announcements` (a purely SYNTACTIC
171
+ elimination of Announce/AnnounceDiamond, sound only for the propositional-modal
172
+ fragment interpreted here) is differentially tested against.
173
+
174
+ Documented temporal semantics:
175
+
176
+ - ``Next φ``: φ holds at **all** immediate ``"temporal"``-successors of the
177
+ current world. On a deterministic / linear frame (each world has at most one
178
+ successor) this is exactly "φ at the unique next state"; on a branching frame
179
+ it is read universally (the "for all next states" reading).
180
+ - ``Always φ`` (G): φ holds at every world reachable from the current world via
181
+ the **reflexive-transitive** closure of ``"temporal"`` (the current world
182
+ included).
183
+ - ``Eventually φ`` (F): φ holds at **some** such reachable world (current world
184
+ included).
185
+ - ``Until(φ, ψ)``: there is a finite ``"temporal"`` path
186
+ ``w0 → w1 → … → wn`` (n ≥ 0) starting at the current world with ψ true at
187
+ ``wn`` and φ true at every earlier world ``w0 … w(n-1)``. This is the
188
+ finite-reachability reading of strong Until; the search is depth-first with a
189
+ visited guard so cycles in the frame terminate.
190
+
191
+ Next / Always / Eventually / Until above are all LINEAR-time: each has exactly
192
+ one path reading baked into its single AST node (no A/E path-quantifier prefix
193
+ exists anywhere in the AST). Branching-time CTL model checking —
194
+ :func:`ctl_ex`, :func:`ctl_af`, :func:`ctl_eg`, :func:`ctl_au` — adds the four
195
+ readings that baked-in choice leaves out (EX, the existential dual of Next's
196
+ universal reading; AF/EG, the forward/backward fixpoint pair that reachability
197
+ alone cannot compute; AU, the universal-path generalisation of Until) as plain
198
+ functions taking arbitrary :class:`~unicode_logic_kit.fol.nodes.Node`
199
+ subformulas, evaluated via :func:`satisfies_modal` — exactly the pattern
200
+ :func:`~unicode_logic_kit.semantics.action_models.common_knowledge_holds` and
201
+ :func:`~unicode_logic_kit.semantics.action_models.everybody_knows` already use,
202
+ not new dispatch branches on ``formula``'s type. See the CTL section near the
203
+ end of this module for the fixpoint algorithms and the deadlock convention.
204
+ """
205
+
206
+ import shutil
207
+ import subprocess
208
+ from typing import Any, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, Tuple
209
+
210
+ from ..fol.nodes import (
211
+ Node,
212
+ Atom, Not, And, Or, Xor, Implies, Iff,
213
+ Quantifier,
214
+ Box, Diamond, Knows, Believes, Says, Wants,
215
+ Always, Eventually, Next, Until,
216
+ Historically, Once, Previous, Since,
217
+ Obligatory, Permitted,
218
+ Nominal, At, Down,
219
+ Constant, substitute, sort_membership_axioms,
220
+ )
221
+ from ..fol._modal_nodes import (
222
+ Announce, AnnounceDiamond,
223
+ EverybodyKnows, DistributedKnowledge, CommonKnowledge,
224
+ )
225
+ from ..fol._atom_keys import find_key
226
+ from ..fol._msfl_nodes import key_text
227
+ from ..fol._truth_constants import truth_value as _truth_value
228
+ from ._modal_reject import (
229
+ EQUALITY_PREDICATES, FUZZY_TYPES, LAMBDA_TYPES,
230
+ reject_equality, reject_equality_in, reject_fuzzy, reject_lambda,
231
+ )
232
+
233
+ # Quantifier-type spellings used by the AST.
234
+ _FORALL = ("∀", "forall")
235
+ _EXISTS = ("∃", "exists")
236
+
237
+ # Relation-name prefixes / keys (kept here so the model and the standard
238
+ # translation stay in sync via documentation; the strings are the contract).
239
+ _ALETHIC = "alethic"
240
+ _TEMPORAL = "temporal"
241
+ _DEONTIC = "deontic"
242
+ _KNOWS_PREFIX = "K:"
243
+ _BELIEVES_PREFIX = "B:"
244
+ _SAYS_PREFIX = "Say:"
245
+ _WANTS_PREFIX = "Want:"
246
+
247
+
248
+ def _agent_key(agent: Node) -> str:
249
+ """Relation-key suffix for an epistemic/doxastic agent term.
250
+
251
+ The agent is a term (Variable or Constant). Object quantifiers ground a bound
252
+ agent to a Constant before the modality is reached (``∀x (… → K_x φ)`` becomes
253
+ ``K_<d> φ`` per individual ``d``), so this is the constant/variable name and the
254
+ relation key matches the model's ``"K:"+name`` / ``"B:"+name`` convention. A term
255
+ without a name of its own (a numeral) is keyed by its text with every constant
256
+ written by its name, like an atom: a relation name is a key, not formula text.
257
+ """
258
+ return getattr(agent, "name", None) or key_text(agent)
259
+
260
+
261
+ World = Any
262
+ Edge = Tuple[World, World]
263
+
264
+
265
+ class KripkeModel:
266
+ """A Kripke model: worlds, named accessibility relations, and a valuation.
267
+
268
+ Args:
269
+ worlds: an iterable of worlds (any hashable values). Stored as a frozen
270
+ set; duplicates collapse.
271
+ relations: maps a relation NAME (str) to a set of ``(w, w')`` edges.
272
+ Recognised names: ``"alethic"`` (Box/Diamond), ``"K:"+agent``
273
+ (Knows), ``"B:"+agent`` (Believes), ``"temporal"`` (Next / Always /
274
+ Eventually / Until), ``"deontic"`` (Obligatory / Permitted; serial
275
+ in Standard Deontic Logic). A missing name is the empty relation.
276
+ Each edge set is copied into a frozen set.
277
+ valuation: maps a world to the set of GROUND-ATOM KEYS true there, where
278
+ a key is the text of the atom with every constant written by its name
279
+ (e.g. ``"P"`` or ``"Likes(a, b)"``; :func:`~unicode_logic_kit.fol.atom_key`).
280
+ The text of the atom as a formula, ``"Likes('a', 'b')"``, is read as the
281
+ same key.
282
+ A missing world maps to the empty set (every atom false there). Each
283
+ entry is copied into a frozen set.
284
+ nominals: maps a NOMINAL NAME (str) to the single world it names (the
285
+ hybrid-logic assignment interpreting ``Nominal`` / ``At``). Defaults
286
+ to empty. Every referenced world must be in ``worlds`` — a dangling
287
+ assignment raises ValueError at construction time.
288
+
289
+ All mappings default to empty, so ``KripkeModel({0, 1})`` is a valid
290
+ (atom-free, relation-free) frame. The constructor copies every container, so
291
+ later edits to the caller's structures never leak in.
292
+ """
293
+
294
+ def __init__(
295
+ self,
296
+ worlds: Iterable[World],
297
+ relations: Optional[Mapping[str, Iterable[Edge]]] = None,
298
+ valuation: Optional[Mapping[World, Iterable[str]]] = None,
299
+ domains: Optional[Mapping[World, Iterable[Any]]] = None,
300
+ domain: Optional[Iterable[Any]] = None,
301
+ nominals: Optional[Mapping[str, World]] = None,
302
+ ):
303
+ """Build a Kripke model, copying every container so edits never leak in.
304
+
305
+ ``domains`` maps each world to the set of individuals existing there (the
306
+ per-world object domain ``D_w`` of quantified modal logic); ``domain`` is a
307
+ shorthand for a **constant** domain (the same individuals at every world).
308
+ Supplying either lets :func:`satisfies_modal` interpret object quantifiers
309
+ (``∀x`` / ``∃x``) *actualistically* — at a world ``w`` they range over
310
+ ``D_w`` — so the Barcan formulas come out valid or invalid according to how
311
+ the domains vary. Omit both for the purely propositional fragment.
312
+
313
+ ``nominals`` maps each hybrid nominal name to the ONE world it names;
314
+ every referenced world must exist in ``worlds`` (checked here, so a
315
+ dangling nominal fails fast instead of at evaluation time).
316
+ """
317
+ self.worlds: FrozenSet[World] = frozenset(worlds)
318
+ self.relations: Dict[str, FrozenSet[Edge]] = {
319
+ name: frozenset(edges) for name, edges in (relations or {}).items()
320
+ }
321
+ self.valuation: Dict[World, FrozenSet[str]] = {
322
+ world: frozenset(keys) for world, keys in (valuation or {}).items()
323
+ }
324
+ if domains is not None:
325
+ self.domains: Optional[Dict[World, FrozenSet[Any]]] = {
326
+ world: frozenset(ind) for world, ind in domains.items()
327
+ }
328
+ elif domain is not None:
329
+ const = frozenset(domain)
330
+ self.domains = {world: const for world in self.worlds}
331
+ else:
332
+ self.domains = None
333
+ self.nominals: Dict[str, World] = dict(nominals or {})
334
+ for name, named in self.nominals.items():
335
+ if named not in self.worlds:
336
+ raise ValueError(
337
+ f"KripkeModel: nominal {name!r} is assigned to world "
338
+ f"{named!r}, which is not among the model's worlds."
339
+ )
340
+
341
+ def __repr__(self) -> str:
342
+ """Show world count and the relation / valuation tables for inspection."""
343
+ return (
344
+ f"KripkeModel(worlds={set(self.worlds)!r}, "
345
+ f"relations={ {k: set(v) for k, v in self.relations.items()} !r}, "
346
+ f"valuation={ {k: set(v) for k, v in self.valuation.items()} !r})"
347
+ )
348
+
349
+ def relation(self, name: str) -> FrozenSet[Edge]:
350
+ """Return the edge set of a named relation (empty if undeclared)."""
351
+ return self.relations.get(name, frozenset())
352
+
353
+ def successors(self, name: str, world: World) -> Set[World]:
354
+ """Return the set of ``w'`` with ``(world, w')`` in the named relation."""
355
+ return {w2 for (w1, w2) in self.relation(name) if w1 == world}
356
+
357
+ def atoms_true_at(self, world: World) -> FrozenSet[str]:
358
+ """Return the ground-atom keys true at ``world`` (empty if undeclared)."""
359
+ return self.valuation.get(world, frozenset())
360
+
361
+ def domain_at(self, world: World) -> FrozenSet[Any]:
362
+ """Return the individuals existing at ``world`` (the object domain ``D_w``).
363
+
364
+ Raises ValueError if the model carries no domains (a purely propositional
365
+ model), since object quantifiers cannot then be interpreted.
366
+ """
367
+ if self.domains is None:
368
+ raise ValueError(
369
+ "satisfies_modal: this Kripke model has no object domains, so "
370
+ "object quantifiers (∀x / ∃x) cannot be evaluated — build the model "
371
+ "with domains={world: [...]} (varying) or domain=[...] (constant)."
372
+ )
373
+ return self.domains.get(world, frozenset())
374
+
375
+ def to_dot(self, *, show_valuation: bool = True) -> str:
376
+ """Render the model as a Graphviz DOT digraph string.
377
+
378
+ Pure Python, no external dependency — mirrors the convention of
379
+ :meth:`~unicode_logic_kit.fol._fol_nodes.Node.to_dot` (escape labels the
380
+ same way; return the source text, never shell out). One node per world
381
+ in :attr:`worlds`, declared in ``repr()`` order for determinism (worlds
382
+ are "any hashable value", so a world is stringified defensively via
383
+ ``repr()`` for the DOT node id and via ``str()`` for the visible
384
+ label). When ``show_valuation`` is true (the default) each node's label
385
+ gets a second line with the atoms :meth:`atoms_true_at` returns for
386
+ that world plus any nominal name(s) (from :attr:`nominals`) pointing at
387
+ it, prefixed ``@``; if the model carries object domains (see
388
+ :meth:`domain_at`), a third line shows that world's domain.
389
+
390
+ Every relation in :attr:`relations` contributes one edge per ``(w, w')``
391
+ pair, labelled with the relation's own name — this is the one place a
392
+ naive per-pair rendering would lose information, since a model can
393
+ carry several named relations over the same world set at once (several
394
+ agents' ``K:``/``B:`` relations, ``alethic``, ``temporal``, ``deontic``
395
+ all coexisting); the relation-name label is what keeps them visually
396
+ distinguishable instead of collapsing into indistinguishable arrows.
397
+ Relations and, within each, their edges are emitted in sorted order too,
398
+ so the whole output is deterministic and directly string-comparable.
399
+
400
+ This is a read-only inspection method: it never touches model
401
+ construction or :func:`satisfies_modal`.
402
+ """
403
+ def esc(text: str) -> str:
404
+ """Escape backslash/quote/newline/CR for safe placement inside a
405
+ double-quoted DOT string, on a single physical source line."""
406
+ return (
407
+ text.replace("\\", "\\\\")
408
+ .replace('"', '\\"')
409
+ .replace("\n", "\\n")
410
+ .replace("\r", "\\r")
411
+ )
412
+
413
+ def node_id(world: World) -> str:
414
+ """The DOT node id for a world: its escaped ``repr()``."""
415
+ return esc(repr(world))
416
+
417
+ nominals_at: Dict[World, List[str]] = {}
418
+ for name, named in self.nominals.items():
419
+ nominals_at.setdefault(named, []).append(name)
420
+
421
+ lines = ["digraph Kripke {", " node [shape=box];"]
422
+ for world in sorted(self.worlds, key=repr):
423
+ label_lines = [esc(str(world))]
424
+ if show_valuation:
425
+ bits = sorted(self.atoms_true_at(world))
426
+ bits += [f"@{n}" for n in sorted(nominals_at.get(world, []))]
427
+ label_lines.append(esc(", ".join(bits)))
428
+ if self.domains is not None:
429
+ dom = ", ".join(sorted(str(d) for d in self.domain_at(world)))
430
+ label_lines.append(esc(f"D = {{{dom}}}"))
431
+ label = "\\n".join(label_lines)
432
+ lines.append(f' "{node_id(world)}" [label="{label}"];')
433
+
434
+ for rel_name in sorted(self.relations):
435
+ edges = sorted(
436
+ self.relations[rel_name],
437
+ key=lambda edge: (repr(edge[0]), repr(edge[1])),
438
+ )
439
+ rel_label = esc(rel_name)
440
+ for source, target in edges:
441
+ lines.append(
442
+ f' "{node_id(source)}" -> "{node_id(target)}" '
443
+ f'[label="{rel_label}"];'
444
+ )
445
+
446
+ lines.append("}")
447
+ return "\n".join(lines)
448
+
449
+ def to_svg(self, *, dot_binary: Optional[str] = None) -> str:
450
+ """Render the model to SVG by piping :meth:`to_dot` through Graphviz ``dot``.
451
+
452
+ Resolves the binary via ``dot_binary`` or ``shutil.which("dot")`` and,
453
+ if neither finds one, raises ``RuntimeError`` naming the missing tool
454
+ and how to install it — the same shutil.which-gate-and-fail-loudly
455
+ pattern already used for the optional external provers (eprover,
456
+ minizinc, vampire, prover9, Isabelle) elsewhere in this kit: this never
457
+ falls back to an approximate or partial rendering.
458
+
459
+ Args:
460
+ dot_binary: an explicit path to the ``dot`` executable, overriding
461
+ ``PATH`` discovery.
462
+
463
+ Raises:
464
+ RuntimeError: no ``dot`` binary found, the given/discovered binary
465
+ could not be executed, or the ``dot`` subprocess itself failed
466
+ (its stderr is included in the message).
467
+ """
468
+ binary = dot_binary or shutil.which("dot")
469
+ if binary is None:
470
+ raise RuntimeError(
471
+ "KripkeModel.to_svg: no Graphviz 'dot' binary found on PATH — "
472
+ "install Graphviz (e.g. 'apt install graphviz', 'brew install "
473
+ "graphviz', or see https://graphviz.org/download/) or pass "
474
+ "to_svg(dot_binary=...) explicitly."
475
+ )
476
+ try:
477
+ result = subprocess.run(
478
+ [binary, "-Tsvg"],
479
+ input=self.to_dot(),
480
+ capture_output=True,
481
+ text=True,
482
+ )
483
+ except OSError as exc:
484
+ raise RuntimeError(
485
+ f"KripkeModel.to_svg: could not run {binary!r} ({exc}) — is "
486
+ "this a valid Graphviz 'dot' binary?"
487
+ ) from exc
488
+ if result.returncode != 0:
489
+ raise RuntimeError(
490
+ f"KripkeModel.to_svg: 'dot -Tsvg' failed (exit "
491
+ f"{result.returncode}): {result.stderr.strip()}"
492
+ )
493
+ return result.stdout
494
+
495
+ def _repr_svg_(self) -> Optional[str]:
496
+ """IPython/Jupyter rich-display hook: render to SVG, or opt out quietly.
497
+
498
+ Returns ``None`` (so the notebook falls back to the plain ``__repr__``
499
+ text) instead of raising when Graphviz's ``dot`` binary — or the
500
+ subprocess call to it — is not available, so merely inspecting a
501
+ KripkeModel in a notebook without Graphviz installed never crashes the
502
+ display machinery.
503
+ """
504
+ try:
505
+ return self.to_svg()
506
+ except RuntimeError:
507
+ return None
508
+
509
+
510
+ def reflexive_transitive_closure(
511
+ edges: Iterable[Edge],
512
+ sources: Iterable[World],
513
+ ) -> Set[World]:
514
+ """Return every world reachable from ``sources`` along ``edges``, reflexively.
515
+
516
+ The result contains each source world itself (reflexive) and every world
517
+ reachable from a source by following one or more edges (transitive). A
518
+ breadth-first walk with a visited set; the input edge collection is never
519
+ mutated. Used by Always / Eventually over the ``"temporal"`` relation.
520
+ """
521
+ edge_set = set(edges)
522
+ reachable: Set[World] = set()
523
+ frontier = list(sources)
524
+ while frontier:
525
+ w = frontier.pop()
526
+ if w in reachable:
527
+ continue
528
+ reachable.add(w)
529
+ for (w1, w2) in edge_set:
530
+ if w1 == w and w2 not in reachable:
531
+ frontier.append(w2)
532
+ return reachable
533
+
534
+
535
+ def _until_holds(
536
+ left: Node,
537
+ right: Node,
538
+ model: KripkeModel,
539
+ world: World,
540
+ ) -> bool:
541
+ """Decide ``Until(left, right)`` at ``world`` by finite-path search.
542
+
543
+ Searches for a finite ``"temporal"`` path ``world = w0 → … → wn`` (n ≥ 0)
544
+ with ``right`` true at ``wn`` and ``left`` true at every earlier ``wi``. A
545
+ depth-first search guarded by a visited set: if ``right`` already holds we
546
+ succeed immediately (n = 0); otherwise ``left`` must hold here and the
547
+ search continues into the temporal successors. The visited guard makes the
548
+ search terminate on cyclic frames.
549
+ """
550
+ edges = model.relation(_TEMPORAL)
551
+
552
+ def search(w: World, visited: FrozenSet[World]) -> bool:
553
+ """Return whether some path from ``w`` witnesses the Until."""
554
+ if satisfies_modal(right, model, w):
555
+ return True
556
+ if not satisfies_modal(left, model, w):
557
+ return False
558
+ next_visited = visited | {w}
559
+ for w2 in {b for (a, b) in edges if a == w}:
560
+ if w2 not in next_visited and search(w2, next_visited):
561
+ return True
562
+ return False
563
+
564
+ return search(world, frozenset())
565
+
566
+
567
+ def _predecessors(model: KripkeModel, name: str, world: World) -> Set[World]:
568
+ """Return the set of ``w'`` with ``(w', world)`` in the named relation (its converse successors)."""
569
+ return {w1 for (w1, w2) in model.relation(name) if w2 == world}
570
+
571
+
572
+ def _since_holds(
573
+ left: Node,
574
+ right: Node,
575
+ model: KripkeModel,
576
+ world: World,
577
+ ) -> bool:
578
+ """Decide ``Since(left, right)`` at ``world`` — the backward mirror of Until.
579
+
580
+ Searches for a finite ``"temporal"`` path into the PAST
581
+ ``world = w0 ← w1 ← … ← wn`` (each step ``(w(i+1), wi)`` a temporal edge, n ≥ 0)
582
+ with ``right`` true at ``wn`` and ``left`` true at every later ``wi`` (i < n). A
583
+ depth-first search guarded by a visited set, so cyclic frames terminate.
584
+ """
585
+ edges = model.relation(_TEMPORAL)
586
+
587
+ def search(w: World, visited: FrozenSet[World]) -> bool:
588
+ """Return whether some backward path from ``w`` witnesses the Since."""
589
+ if satisfies_modal(right, model, w):
590
+ return True
591
+ if not satisfies_modal(left, model, w):
592
+ return False
593
+ next_visited = visited | {w}
594
+ for w0 in {a for (a, b) in edges if b == w}:
595
+ if w0 not in next_visited and search(w0, next_visited):
596
+ return True
597
+ return False
598
+
599
+ return search(world, frozenset())
600
+
601
+
602
+ def _nominal_world(model: KripkeModel, name: str) -> World:
603
+ """Return the world the nominal ``name`` names; raise if it is unassigned.
604
+
605
+ A nominal must name exactly one world, so an assignment-free nominal is a
606
+ modelling error — the ValueError names the offending nominal and shows the
607
+ ``nominals=`` fix rather than silently picking a truth value.
608
+ """
609
+ if name not in model.nominals:
610
+ raise ValueError(
611
+ f"satisfies_modal: the nominal {name!r} has no world assignment in "
612
+ f"this model — build the KripkeModel with nominals={{{name!r}: world}}."
613
+ )
614
+ return model.nominals[name]
615
+
616
+
617
+ def satisfies_modal(formula: Node, model: KripkeModel, world: World) -> bool:
618
+ """Return whether ``formula`` is true at ``world`` in the Kripke ``model``.
619
+
620
+ The Kripke satisfaction relation for the propositional / ground modal
621
+ fragment:
622
+
623
+ - ``Atom`` — its Unicode key is in the world's valuation.
624
+ - ``Nominal i`` — true iff ``world`` IS the world ``model.nominals[i]``
625
+ names (a nominal holds at exactly one world).
626
+ - ``At(i, φ)`` — φ holds at the world named ``i``, regardless of the
627
+ current world (the hybrid satisfaction operator ``@i φ``).
628
+ - ``Not / And / Or / Xor / Implies / Iff`` — the classical truth tables,
629
+ recursing at the **same** world.
630
+ - ``Box φ`` — φ holds at every ``"alethic"``-successor; ``Diamond φ`` — at
631
+ some ``"alethic"``-successor.
632
+ - ``Knows(a, φ)`` — φ holds at every ``"K:"+a``-successor (universal).
633
+ - ``Believes(a, φ)`` — φ holds at every ``"B:"+a``-successor (universal).
634
+ - ``EverybodyKnows(G, φ)`` (E_G φ) / ``DistributedKnowledge(G, φ)``
635
+ (D_G φ) / ``CommonKnowledge(G, φ)`` (C_G φ) — dispatched to
636
+ :func:`~unicode_logic_kit.semantics.action_models.everybody_knows` /
637
+ :func:`~unicode_logic_kit.semantics.action_models.distributed_knowledge_holds`
638
+ / :func:`~unicode_logic_kit.semantics.action_models.common_knowledge_holds`
639
+ (see the module docstring).
640
+ - ``Obligatory φ`` — φ holds at every ``"deontic"``-successor (universal);
641
+ ``Permitted φ`` — at some ``"deontic"``-successor.
642
+ - ``Next φ`` — φ holds at every immediate ``"temporal"``-successor.
643
+ - ``Always φ`` / ``Eventually φ`` — φ holds at all / some worlds in the
644
+ reflexive-transitive closure of ``"temporal"`` from ``world``.
645
+ - ``Until(φ, ψ)`` — see :func:`_until_holds` (finite-path strong Until).
646
+ - ``Announce(φ, ψ)`` (``[φ!]ψ``) — ``φ`` false at ``world`` makes this
647
+ vacuously true; otherwise ``ψ`` must hold at ``world`` in the model
648
+ restricted to the ``φ``-worlds (:func:`~unicode_logic_kit.semantics.dynamic_epistemic.announce`).
649
+ - ``AnnounceDiamond(φ, ψ)`` (``⟨φ!⟩ψ``) — the dual: ``φ`` true at ``world``
650
+ AND ``ψ`` holds at ``world`` in the ``φ``-restricted model.
651
+
652
+ A many-sorted ``formula`` (``SortedQuantifier`` / ``SortedConstant``, ``∀x:S φ``
653
+ / ``∃x:S φ`` / a bare ``alice:Human``) is relativized ONCE, here, before
654
+ anything else runs — see the module docstring's "Many-sorted formulas"
655
+ section for what that does and does not assume (world-relative, not rigid;
656
+ non-empty only if the model says so; a sorted constant is in its sort only
657
+ if the model puts ``S(c)`` in every world's valuation — see
658
+ :func:`sorted_constant_violations`).
659
+
660
+ Raises:
661
+ NotImplementedError: on a Łukasiewicz node or a lambda node (checked
662
+ BEFORE relativizing, since relativizing runs a whole-tree
663
+ structural recursion that would otherwise reach one of these
664
+ first and raise a less specific error), and on an equality /
665
+ disequality atom (``=`` / ``≠``) anywhere in the formula — see the
666
+ module docstring's "Equality is NOT interpreted" section.
667
+ """
668
+ # --- many-sorted formulas: relativize the WHOLE formula once, here, before
669
+ # any dispatch below — see the docstring above and the module docstring's
670
+ # "Many-sorted formulas" section. Reject any Łukasiewicz / lambda node
671
+ # FIRST, by walking the whole (pre-relativize) tree: Node._relativize is
672
+ # an unconditional structural descent into every child (Node.map_children),
673
+ # so if a fuzzy/lambda node sat anywhere in the formula -- not only at the
674
+ # very top -- relativizing before this check would reach it first and
675
+ # raise a generic RuntimeError ("call to_msfol() before _relativize")
676
+ # instead of this function's own documented NotImplementedError contract
677
+ # (see test_fuzzy_node_rejected / test_lambda_node_rejected). ---
678
+ for node in formula.walk():
679
+ if isinstance(node, FUZZY_TYPES):
680
+ reject_fuzzy(node, "satisfies_modal")
681
+ if isinstance(node, LAMBDA_TYPES):
682
+ reject_lambda(node, "satisfies_modal")
683
+ # Equality has no interpretation here (module docstring); refuse it by
684
+ # name from the WHOLE tree, up front, so no short-circuit or vacuous
685
+ # branch can let an equality atom through unexamined.
686
+ reject_equality(node, "satisfies_modal")
687
+ formula = formula._relativize([])
688
+
689
+ # --- atomic ---
690
+ if isinstance(formula, Atom):
691
+ constant = _truth_value(formula)
692
+ if constant is not None:
693
+ return constant # `$true` / `$false`: the same at every world
694
+ # The key of the atom first, then its formula text: an element ``a`` of a domain is
695
+ # substituted as ``Constant("a")``, which the formula text writes ``P('a')``, and the
696
+ # valuation holds the key the user typed, ``P(a)``, or the text of the atom as a
697
+ # formula, ``P('a')``, which the guide taught as the key of a hand-built atom.
698
+ return find_key(model.atoms_true_at(world), formula) is not None
699
+
700
+ # --- hybrid: a nominal is true exactly at the world it names; @ jumps there ---
701
+ if isinstance(formula, Nominal):
702
+ return world == _nominal_world(model, formula.name)
703
+ if isinstance(formula, At):
704
+ return satisfies_modal(formula.formula, model,
705
+ _nominal_world(model, formula.nominal.name))
706
+ if isinstance(formula, Down):
707
+ # ↓x.φ at world: locally rebind the nominal x to THIS world for φ's
708
+ # evaluation (still at the same world) — see the module docstring's
709
+ # "The ↓ binder" section for why this plain dict-override, with no
710
+ # separate alpha-renaming step, already gets shadowing / no-capture /
711
+ # rigidity right. KripkeModel's own constructor re-validates the
712
+ # (harmless, since world ∈ model.worlds whenever a caller reached
713
+ # here in the first place) new nominal assignment.
714
+ rebound = KripkeModel(
715
+ model.worlds, model.relations, model.valuation,
716
+ domains=model.domains,
717
+ nominals={**model.nominals, formula.variable.name: world},
718
+ )
719
+ return satisfies_modal(formula.formula, rebound, world)
720
+
721
+ # --- classical connectives (recurse at the same world) ---
722
+ if isinstance(formula, Not):
723
+ return not satisfies_modal(formula.formula, model, world)
724
+ if isinstance(formula, And):
725
+ return (satisfies_modal(formula.left, model, world)
726
+ and satisfies_modal(formula.right, model, world))
727
+ if isinstance(formula, Or):
728
+ return (satisfies_modal(formula.left, model, world)
729
+ or satisfies_modal(formula.right, model, world))
730
+ if isinstance(formula, Xor):
731
+ return (satisfies_modal(formula.left, model, world)
732
+ != satisfies_modal(formula.right, model, world))
733
+ if isinstance(formula, Implies):
734
+ return ((not satisfies_modal(formula.left, model, world))
735
+ or satisfies_modal(formula.right, model, world))
736
+ if isinstance(formula, Iff):
737
+ return (satisfies_modal(formula.left, model, world)
738
+ == satisfies_modal(formula.right, model, world))
739
+
740
+ # --- alethic ---
741
+ if isinstance(formula, Box):
742
+ return all(
743
+ satisfies_modal(formula.formula, model, w2)
744
+ for w2 in model.successors(_ALETHIC, world)
745
+ )
746
+ if isinstance(formula, Diamond):
747
+ return any(
748
+ satisfies_modal(formula.formula, model, w2)
749
+ for w2 in model.successors(_ALETHIC, world)
750
+ )
751
+
752
+ # --- epistemic / doxastic (both universal) ---
753
+ if isinstance(formula, Knows):
754
+ return all(
755
+ satisfies_modal(formula.formula, model, w2)
756
+ for w2 in model.successors(_KNOWS_PREFIX + _agent_key(formula.agent), world)
757
+ )
758
+ if isinstance(formula, Believes):
759
+ return all(
760
+ satisfies_modal(formula.formula, model, w2)
761
+ for w2 in model.successors(_BELIEVES_PREFIX + _agent_key(formula.agent), world)
762
+ )
763
+
764
+ # --- group epistemic (everyone/distributed/common knowledge): thin
765
+ # dispatch into semantics.action_models, exactly like Announce dispatches
766
+ # into semantics.dynamic_epistemic.announce below. Lazy import: action_models
767
+ # imports THIS module at load time (KripkeModel/satisfies_modal/
768
+ # reflexive_transitive_closure), so a module-level import here would cycle. ---
769
+ if isinstance(formula, EverybodyKnows):
770
+ from .action_models import everybody_knows
771
+ agents = [_agent_key(a) for a in formula.group]
772
+ return everybody_knows(model, world, agents, formula.formula)
773
+ if isinstance(formula, DistributedKnowledge):
774
+ from .action_models import distributed_knowledge_holds
775
+ agents = [_agent_key(a) for a in formula.group]
776
+ return distributed_knowledge_holds(model, world, agents, formula.formula)
777
+ if isinstance(formula, CommonKnowledge):
778
+ from .action_models import common_knowledge_holds
779
+ agents = [_agent_key(a) for a in formula.group]
780
+ return common_knowledge_holds(model, world, agents, formula.formula)
781
+
782
+ # --- assertive / bouletic (both universal K-modalities, no frame conditions:
783
+ # Says is non-factive / non-doxastic, Wants is non-veridical) ---
784
+ if isinstance(formula, Says):
785
+ return all(
786
+ satisfies_modal(formula.formula, model, w2)
787
+ for w2 in model.successors(_SAYS_PREFIX + _agent_key(formula.agent), world)
788
+ )
789
+ if isinstance(formula, Wants):
790
+ return all(
791
+ satisfies_modal(formula.formula, model, w2)
792
+ for w2 in model.successors(_WANTS_PREFIX + _agent_key(formula.agent), world)
793
+ )
794
+
795
+ # --- deontic (Standard Deontic Logic / KD over a serial "deontic" relation) ---
796
+ if isinstance(formula, Obligatory):
797
+ return all(
798
+ satisfies_modal(formula.formula, model, w2)
799
+ for w2 in model.successors(_DEONTIC, world)
800
+ )
801
+ if isinstance(formula, Permitted):
802
+ return any(
803
+ satisfies_modal(formula.formula, model, w2)
804
+ for w2 in model.successors(_DEONTIC, world)
805
+ )
806
+
807
+ # --- temporal ---
808
+ if isinstance(formula, Next):
809
+ return all(
810
+ satisfies_modal(formula.formula, model, w2)
811
+ for w2 in model.successors(_TEMPORAL, world)
812
+ )
813
+ if isinstance(formula, Always):
814
+ reachable = reflexive_transitive_closure(model.relation(_TEMPORAL), [world])
815
+ return all(
816
+ satisfies_modal(formula.formula, model, w2) for w2 in reachable
817
+ )
818
+ if isinstance(formula, Eventually):
819
+ reachable = reflexive_transitive_closure(model.relation(_TEMPORAL), [world])
820
+ return any(
821
+ satisfies_modal(formula.formula, model, w2) for w2 in reachable
822
+ )
823
+ if isinstance(formula, Until):
824
+ return _until_holds(formula.left, formula.right, model, world)
825
+
826
+ # --- past tense (over the CONVERSE of the one-step "temporal" relation) ---
827
+ if isinstance(formula, Previous):
828
+ return all(
829
+ satisfies_modal(formula.formula, model, w2)
830
+ for w2 in _predecessors(model, _TEMPORAL, world)
831
+ )
832
+ if isinstance(formula, Historically):
833
+ reverse = [(b, a) for (a, b) in model.relation(_TEMPORAL)]
834
+ reachable = reflexive_transitive_closure(reverse, [world])
835
+ return all(satisfies_modal(formula.formula, model, w2) for w2 in reachable)
836
+ if isinstance(formula, Once):
837
+ reverse = [(b, a) for (a, b) in model.relation(_TEMPORAL)]
838
+ reachable = reflexive_transitive_closure(reverse, [world])
839
+ return any(satisfies_modal(formula.formula, model, w2) for w2 in reachable)
840
+ if isinstance(formula, Since):
841
+ return _since_holds(formula.left, formula.right, model, world)
842
+
843
+ # --- public announcement logic (PAL): a genuine MODEL UPDATE, not a fixed
844
+ # accessibility relation — this is the ORACLE unicode_logic_kit.fol.pal
845
+ # .reduce_announcements is differentially tested against (see that module's
846
+ # docstring for the correctness argument relating the two). Both build the
847
+ # restricted model M|announcement via
848
+ # unicode_logic_kit.semantics.dynamic_epistemic.announce (imported lazily to
849
+ # avoid a circular import: dynamic_epistemic imports THIS module at load
850
+ # time) and recurse into ``formula.formula`` there, at the SAME world. ---
851
+ if isinstance(formula, Announce):
852
+ if not satisfies_modal(formula.announcement, model, world):
853
+ return True # untruthful announcement is not made: vacuously true
854
+ from .dynamic_epistemic import announce # lazy: avoid import cycle
855
+ return satisfies_modal(formula.formula, announce(model, formula.announcement), world)
856
+ if isinstance(formula, AnnounceDiamond):
857
+ if not satisfies_modal(formula.announcement, model, world):
858
+ return False # dual of Announce: false whenever the announcement is
859
+ from .dynamic_epistemic import announce # lazy: avoid import cycle
860
+ return satisfies_modal(formula.formula, announce(model, formula.announcement), world)
861
+
862
+ # --- object quantifiers (actualist: range over the CURRENT world's domain D_w) ---
863
+ if isinstance(formula, Quantifier):
864
+ individuals = model.domain_at(world)
865
+ instances = (
866
+ satisfies_modal(substitute(formula.formula, formula.variable, Constant(d)),
867
+ model, world)
868
+ for d in individuals
869
+ )
870
+ if formula.type in _FORALL:
871
+ return all(instances)
872
+ if formula.type in _EXISTS:
873
+ return any(instances)
874
+ raise ValueError(f"satisfies_modal: unknown quantifier type {formula.type!r}")
875
+
876
+ # NOTE: SortedQuantifier / SortedConstant never reach this dispatch chain --
877
+ # the preamble above relativizes the whole formula before any isinstance
878
+ # check runs, so by this point the tree contains only plain Quantifier /
879
+ # Constant nodes (see the module docstring's "Many-sorted formulas"
880
+ # section for what the guarded-Quantifier reduction does and does not
881
+ # assume: world-relative sort guards, non-emptiness and the membership of a
882
+ # sorted constant left to the caller).
883
+
884
+ # Łukasiewicz / lambda nodes were already rejected in the preamble above
885
+ # (deep scan, before relativizing); anything reaching here is a genuinely
886
+ # unhandled node type.
887
+ raise NotImplementedError(
888
+ f"satisfies_modal: unsupported node type {type(formula).__name__}."
889
+ )
890
+
891
+
892
+ def models_at(formula: Node, model: KripkeModel, world: World) -> bool:
893
+ """Convenience alias for :func:`satisfies_modal` reading "model, world ⊨ φ"."""
894
+ return satisfies_modal(formula, model, world)
895
+
896
+
897
+ def sorted_constant_violations(formula: Node,
898
+ model: KripkeModel) -> List[Tuple[str, str, World]]:
899
+ """The worlds of ``model`` at which a sorted constant of ``formula`` is NOT in its sort.
900
+
901
+ ``c:S`` denotes an element of ``S`` and a constant is a rigid designator, so a
902
+ model is LEGAL for ``formula`` only if the guard atom ``S(c)`` is in the
903
+ valuation of every world, for every distinct ``c:S`` of ``formula`` (see the
904
+ module docstring: :func:`satisfies_modal` evaluates one given model and does
905
+ not assert the fact itself). Pass the ORIGINAL formula — ``c:S`` is gone once
906
+ the formula is relativized.
907
+
908
+ Returns ``(constant, sort, world)`` triples, one per sorted constant and per
909
+ world whose valuation lacks the key ``S(c)``, constants in first-occurrence
910
+ order and worlds in ``repr`` order; ``[]`` means the model is legal for the
911
+ formula's sorted constants (and trivially so for a formula without any).
912
+ Non-emptiness of a sort is a different fact, left to the caller as before.
913
+ """
914
+ violations: List[Tuple[str, str, World]] = []
915
+ worlds = sorted(model.worlds, key=repr)
916
+ for atom in sort_membership_axioms(formula):
917
+ # every member is the guard atom ``S(c)`` over the one constant ``c``
918
+ assert isinstance(atom, Atom)
919
+ constant = atom.args[0]
920
+ assert isinstance(constant, Constant)
921
+ for world in worlds:
922
+ if find_key(model.atoms_true_at(world), atom) is None:
923
+ violations.append((constant.name, atom.predicate, world))
924
+ return violations
925
+
926
+
927
+ # ---------------------------------------------------------------------------
928
+ # CTL (branching-time) model checking: EX / AF / EG / AU
929
+ # ---------------------------------------------------------------------------
930
+ #
931
+ # Next / Always / Eventually / Until (documented above) are LINEAR-time
932
+ # operators, each with exactly one path reading baked into its own AST node
933
+ # (fol/_modal_nodes.py): Next and Always read universally (every successor /
934
+ # every reachable world), Eventually and Until existentially (some reachable
935
+ # world / some finite witnessing path). There is no A/E path-quantifier
936
+ # prefix anywhere in the AST, so the four functions below are plain
937
+ # functions — not new isinstance branches in satisfies_modal's dispatch —
938
+ # exactly like common_knowledge_holds / everybody_knows in
939
+ # semantics/action_models.py: arbitrary Node subformulas evaluated via
940
+ # satisfies_modal, no new AST node type and no grammar/parser change.
941
+ #
942
+ # - ctl_ex is the existential dual of the built-in (universal) Next: a plain
943
+ # one-step modality, no fixpoint needed.
944
+ # - ctl_af/ctl_eg are the missing AF/EG dual pair. Note AF is NOT the same
945
+ # computation as reflexive_transitive_closure: a world can satisfy "phi
946
+ # holds on every path" without phi holding at every REACHABLE world (some
947
+ # paths from it may loop back through phi-free territory before others
948
+ # reach a phi-world), so a genuine forward least-fixpoint is needed, not a
949
+ # closure/reachability walk.
950
+ # - ctl_au is the universal-path generalisation of the built-in (existential)
951
+ # Until above.
952
+ #
953
+ # All three fixpoint functions (AF/EG/AU) quantify over the "temporal"
954
+ # relation RESTRICTED to model.worlds: their state space IS model.worlds, so
955
+ # an edge into a world outside it cannot be a step of the fixpoint — unlike
956
+ # Next/Always/Eventually/Until above, which follow model.successors() /
957
+ # model.relation() exactly as given, with no membership filter.
958
+ #
959
+ # Algorithm (standard CTL labelling via fixpoint iteration on the finite
960
+ # temporal relation restricted to model.worlds — Baier & Katoen, Principles of
961
+ # Model Checking, MIT Press 2008, §6.4; Clarke, Grumberg & Peled, Model
962
+ # Checking, MIT Press 1999, ch. 6): each iteration is monotone on the finite
963
+ # set model.worlds, so it reaches a fixpoint in at most |model.worlds| rounds.
964
+ # No external library and no Tarjan/SCC machinery is required for correctness
965
+ # (SCC detection would only be an internal optimisation, and none is used
966
+ # here).
967
+
968
+ def _ctl_temporal_successors(model: "KripkeModel", world: World) -> Set[World]:
969
+ """Temporal successors of ``world`` that are themselves in ``model.worlds``.
970
+
971
+ The CTL fixpoints below (:func:`ctl_af`, :func:`ctl_eg`, :func:`ctl_au`)
972
+ iterate over ``model.worlds`` as their state space, so a temporal edge
973
+ into a world outside it is not a step of any of those fixpoints — unlike
974
+ ``Next``/``Always``/``Eventually``/``Until`` above, which follow
975
+ ``model.successors``/``model.relation`` exactly as given, with no
976
+ membership filter. :func:`ctl_ex` (the one CTL function below that is a
977
+ plain one-step modality, not a path fixpoint) intentionally does NOT use
978
+ this helper — see its own docstring.
979
+ """
980
+ return {w2 for w2 in model.successors(_TEMPORAL, world) if w2 in model.worlds}
981
+
982
+
983
+ def _require_total_temporal(model: "KripkeModel") -> None:
984
+ """Raise ValueError unless every world of ``model.worlds`` has a temporal
985
+ successor inside ``model.worlds``.
986
+
987
+ ``ctl_af``/``ctl_eg``/``ctl_au`` quantify over INFINITE ``"temporal"``
988
+ paths. A world with no successor inside ``model.worlds`` is a dead end no
989
+ infinite path can pass through — treating it the way ``Next`` treats a
990
+ dead end (AX-anything vacuously true) would silently make AF/EG/AU wrong
991
+ there, exactly the kind of silent wrongness the kit's "refuse loudly,
992
+ never approximate" rule exists to prevent (the same discipline
993
+ ``product_update`` follows for a missing agent relation in
994
+ ``action_models.py``). So instead of picking a convention, this requires
995
+ the frame to be total on ``model.worlds`` and names the (deterministically
996
+ first, by ``repr()``, so the message is reproducible) offending world.
997
+
998
+ ``ctl_ex`` needs no such check — it is a genuine one-step modality, as
999
+ vacuously-decided at a dead end as the existing universal ``Next``, see
1000
+ its own docstring.
1001
+ """
1002
+ dead_ends = sorted(
1003
+ (w for w in model.worlds if not _ctl_temporal_successors(model, w)),
1004
+ key=repr,
1005
+ )
1006
+ if dead_ends:
1007
+ raise ValueError(
1008
+ "ctl_af/ctl_eg/ctl_au: the \"temporal\" relation is not total on "
1009
+ f"model.worlds — world {dead_ends[0]!r} has no temporal successor "
1010
+ "inside model.worlds, so no infinite path passes through it. Add "
1011
+ "a temporal successor within model.worlds (a self-loop is the "
1012
+ "usual fix for a terminal/sink state) to make the frame total, "
1013
+ "or use ctl_ex there instead, which needs no totality."
1014
+ )
1015
+
1016
+
1017
+ def ctl_ex(model: KripkeModel, world: World, formula: Node) -> bool:
1018
+ """Return whether EX φ ("some immediate successor satisfies φ") holds at ``world``.
1019
+
1020
+ The existential dual of the kit's built-in ``Next`` (which reads its one
1021
+ AST node universally, i.e. AX — see the module docstring). A plain
1022
+ one-step modality: no fixpoint, and no totality requirement. Exactly like
1023
+ ``Next``, a world with no ``"temporal"``-successor is simply decided by
1024
+ the empty case — ``Next``'s empty ``all(...)`` is vacuously TRUE there,
1025
+ this function's empty ``any(...)`` is FALSE there — not an error.
1026
+
1027
+ Also follows ``Next``'s convention of reading ``model.successors``
1028
+ UNFILTERED: a successor outside ``model.worlds`` is still a witness here
1029
+ (exactly as it is for ``Next``), unlike :func:`ctl_af`/:func:`ctl_eg`/
1030
+ :func:`ctl_au` below, which restrict to ``model.worlds`` because they run
1031
+ a fixpoint over it.
1032
+
1033
+ Raises:
1034
+ NotImplementedError: an equality / disequality atom (``=`` / ``≠``)
1035
+ anywhere in ``formula`` — refused up front, so a world with no
1036
+ successor (where the ``any(...)`` below would never look at
1037
+ ``formula``) refuses it too; see the module docstring.
1038
+ """
1039
+ reject_equality_in(formula, "ctl_ex")
1040
+ return any(
1041
+ satisfies_modal(formula, model, w2)
1042
+ for w2 in model.successors(_TEMPORAL, world)
1043
+ )
1044
+
1045
+
1046
+ def ctl_af(model: KripkeModel, world: World, formula: Node) -> bool:
1047
+ """Return whether AF φ ("φ eventually holds, on every path") holds at ``world``.
1048
+
1049
+ The least fixpoint μZ. φ ∨ AX Z (Baier & Katoen §6.4), computed by
1050
+ forward iteration from Z = ∅: a world enters Z as soon as φ holds there,
1051
+ or every one of its ``model.worlds``-internal temporal successors is
1052
+ already in Z. Z grows monotonically on the finite set ``model.worlds``,
1053
+ so the loop reaches a fixpoint in at most ``|model.worlds|`` rounds.
1054
+
1055
+ Raises:
1056
+ NotImplementedError: an equality / disequality atom (``=`` / ``≠``)
1057
+ anywhere in ``formula`` — see the module docstring.
1058
+ ValueError: the ``"temporal"`` relation is not total on
1059
+ ``model.worlds`` — see :func:`_require_total_temporal`. Without
1060
+ totality, "every successor of w is in Z" would hold vacuously at
1061
+ a dead end and silently pull dead ends into AF's least fixpoint
1062
+ for the wrong reason.
1063
+ """
1064
+ reject_equality_in(formula, "ctl_af")
1065
+ _require_total_temporal(model)
1066
+ phi_worlds = {w for w in model.worlds if satisfies_modal(formula, model, w)}
1067
+ z: Set[World] = set()
1068
+ while True:
1069
+ new_z = set(phi_worlds)
1070
+ for w in model.worlds:
1071
+ if w not in new_z and _ctl_temporal_successors(model, w) <= z:
1072
+ new_z.add(w)
1073
+ if new_z == z:
1074
+ return world in z
1075
+ z = new_z
1076
+
1077
+
1078
+ def ctl_eg(model: KripkeModel, world: World, formula: Node) -> bool:
1079
+ """Return whether EG φ ("φ holds forever, on some path") holds at ``world``.
1080
+
1081
+ The greatest fixpoint νZ. φ ∧ EX Z (Baier & Katoen §6.4), computed by
1082
+ shrinking from Z = {w : φ holds at w}: a world leaves Z as soon as NONE
1083
+ of its ``model.worlds``-internal temporal successors is still in Z. Z
1084
+ shrinks monotonically on the finite set ``model.worlds``, so the loop
1085
+ reaches a fixpoint in at most ``|model.worlds|`` rounds.
1086
+
1087
+ Raises:
1088
+ ValueError: the ``"temporal"`` relation is not total on
1089
+ ``model.worlds`` — see :func:`_require_total_temporal`. This
1090
+ batch's deadlock convention requires totality uniformly across
1091
+ ``ctl_af``/``ctl_eg``/``ctl_au`` (even though EG's own greatest
1092
+ fixpoint would, left alone, correctly drop a dead end out of Z on
1093
+ its own on the first iteration): a dead end is a modelling error
1094
+ to be reported the same way by all three, not something EG alone
1095
+ quietly special-cases while its siblings refuse it.
1096
+ NotImplementedError: an equality / disequality atom (``=`` / ``≠``)
1097
+ anywhere in ``formula`` — see the module docstring.
1098
+ """
1099
+ reject_equality_in(formula, "ctl_eg")
1100
+ _require_total_temporal(model)
1101
+ z = {w for w in model.worlds if satisfies_modal(formula, model, w)}
1102
+ while True:
1103
+ new_z = {w for w in z if _ctl_temporal_successors(model, w) & z}
1104
+ if new_z == z:
1105
+ return world in z
1106
+ z = new_z
1107
+
1108
+
1109
+ def ctl_au(model: KripkeModel, world: World, phi: Node, psi: Node) -> bool:
1110
+ """Return whether A[φ U ψ] ("φ holds until ψ, on every path") holds at ``world``.
1111
+
1112
+ The universal-path generalisation of the kit's built-in (existential)
1113
+ ``Until`` above. The least fixpoint μZ. ψ ∨ (φ ∧ AX Z) (Baier & Katoen
1114
+ §6.4), computed by forward iteration from Z = ∅ exactly like
1115
+ :func:`ctl_af`: a world enters Z as soon as ψ holds there, or φ holds
1116
+ there AND every one of its ``model.worlds``-internal temporal successors
1117
+ is already in Z.
1118
+
1119
+ Raises:
1120
+ NotImplementedError: an equality / disequality atom (``=`` / ``≠``)
1121
+ anywhere in ``phi`` or ``psi`` — see the module docstring.
1122
+ ValueError: the ``"temporal"`` relation is not total on
1123
+ ``model.worlds`` — see :func:`_require_total_temporal`.
1124
+ """
1125
+ reject_equality_in(phi, "ctl_au")
1126
+ reject_equality_in(psi, "ctl_au")
1127
+ _require_total_temporal(model)
1128
+ phi_worlds = {w for w in model.worlds if satisfies_modal(phi, model, w)}
1129
+ psi_worlds = {w for w in model.worlds if satisfies_modal(psi, model, w)}
1130
+ z: Set[World] = set()
1131
+ while True:
1132
+ new_z = set(psi_worlds)
1133
+ for w in model.worlds:
1134
+ if (w not in new_z and w in phi_worlds
1135
+ and _ctl_temporal_successors(model, w) <= z):
1136
+ new_z.add(w)
1137
+ if new_z == z:
1138
+ return world in z
1139
+ z = new_z