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