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,1135 @@
|
|
|
1
|
+
"""Direct structural evaluation of a formula against a :class:`FiniteStructure`.
|
|
2
|
+
|
|
3
|
+
This is model CHECKING (is this sentence true HERE, on this one finite
|
|
4
|
+
structure?), computed by walking the formula's AST directly — no prenex /
|
|
5
|
+
clausal preprocessing of any kind. The kit already has a Tarskian evaluator
|
|
6
|
+
(:func:`unicode_logic_kit.semantics.tarski.satisfies`); it is correct and
|
|
7
|
+
general but iterates every quantifier over the WHOLE domain and gives no
|
|
8
|
+
special treatment to negated quantifiers or counting. This module targets
|
|
9
|
+
exactly the practical bottleneck of that style of checker when the structure
|
|
10
|
+
is a molecule (one individual per non-hydrogen atom) and the formula is an
|
|
11
|
+
LLM-produced ChEBI class definition. Three design decisions carry the
|
|
12
|
+
module's value:
|
|
13
|
+
|
|
14
|
+
1. **No PNF/CNF normal form, ever.** The existing ChemLog TPTP checker this
|
|
15
|
+
module is meant to out-perform requires prenex form with a CNF matrix, and
|
|
16
|
+
that requirement alone produces two normal-form-*induced* timeout sources
|
|
17
|
+
that direct evaluation simply does not have:
|
|
18
|
+
|
|
19
|
+
* A negated existential ``¬(∃o: o(o) ∧ bDOUBLE(c,o))`` is mechanically
|
|
20
|
+
Skolem/prenex-turned into ``∀x (¬o(x) ∨ ¬bDOUBLE(c,x))``, which then has
|
|
21
|
+
to range over *every* atom in the molecule. Evaluated directly, ``¬∃x φ``
|
|
22
|
+
is "search for one witness of φ, stop at the first hit, otherwise true" —
|
|
23
|
+
the same search :func:`evaluate` uses for a bare ``∃``, just negated at
|
|
24
|
+
the end (see :func:`_eval`, the ``Not`` / ``Quantifier`` cases). It never
|
|
25
|
+
becomes a ``∀`` over the whole domain.
|
|
26
|
+
* A top-level disjunction of two existentials gets DISTRIBUTED across a
|
|
27
|
+
CNF matrix — a realistic ``oxoFattyAcid`` definition turns into 32
|
|
28
|
+
disjunctive clauses that must be resolved "simultaneously". Evaluated
|
|
29
|
+
directly, ``Or`` just short-circuits (Python's ``or`` on the two
|
|
30
|
+
recursive calls in :func:`_eval`): the first disjunct that is true wins,
|
|
31
|
+
full stop.
|
|
32
|
+
|
|
33
|
+
2. **Index-driven candidate generation with dynamically reordered variables.**
|
|
34
|
+
``∃x (o(x) ∧ bDOUBLE(c,x) ∧ …)`` must not iterate the whole domain for
|
|
35
|
+
``x``. :func:`_variable_candidates` derives a SOUND (superset) candidate
|
|
36
|
+
set for a quantified variable from the immediate body by intersecting (i)
|
|
37
|
+
:meth:`FiniteStructure.individuals_with` for every unary atom the variable
|
|
38
|
+
occurs in POSITIVELY (i.e. un-negated, and not hidden behind ``∨`` /
|
|
39
|
+
``→`` / another quantifier — see the harvesting rule below) and (ii)
|
|
40
|
+
:meth:`FiniteStructure.neighbors` (or its local reverse index, see
|
|
41
|
+
:func:`_reverse_neighbors`) for every binary atom relating it to an
|
|
42
|
+
ALREADY-BOUND variable or constant. When several existential variables are
|
|
43
|
+
introduced back to back (``∃x∃y: …``, the common ChemLog shape), the search
|
|
44
|
+
in :func:`_search_exists` picks, at every step, the still-unbound variable
|
|
45
|
+
with the SMALLEST current candidate set (a minimum-remaining-values /
|
|
46
|
+
most-constrained-variable heuristic borrowed from constraint search) —
|
|
47
|
+
concretely: start at the rare heteroatom, then walk outward along its
|
|
48
|
+
bonds, rather than iterating the common atom type first and hoping a bond
|
|
49
|
+
happens to match. This is a HEURISTIC, not a completeness or optimality
|
|
50
|
+
guarantee: it can never make the answer wrong (the harvested constraints
|
|
51
|
+
are all genuinely necessary — see below — so the candidate set is always a
|
|
52
|
+
superset of the true satisfiers, and every candidate is still fully
|
|
53
|
+
re-checked by evaluating the whole body), but a formula whose real
|
|
54
|
+
constraints live behind an ``∨`` or inside a nested ``Count`` gets none of
|
|
55
|
+
this narrowing and falls back to scanning the whole domain for that
|
|
56
|
+
variable — still correct, just not fast.
|
|
57
|
+
|
|
58
|
+
*Harvesting rule (soundness argument):* :func:`_harvest_atoms` collects
|
|
59
|
+
atoms reachable from the body through a chain of nested ``And`` nodes only
|
|
60
|
+
(both branches), and stops (does not descend) at ``Or``, ``Not``,
|
|
61
|
+
``Implies``, ``Iff``, ``Xor``, nested ``Quantifier`` or ``Count``. Anything
|
|
62
|
+
found this way is a conjunct that is unconditionally required for the
|
|
63
|
+
whole formula to hold, so filtering candidates by it can never exclude a
|
|
64
|
+
genuine satisfier — which is exactly what makes it safe to use for a
|
|
65
|
+
NEGATIVE existential search too (``¬∃x φ`` is false only if some candidate
|
|
66
|
+
in this superset satisfies φ; if the whole harvested superset is checked
|
|
67
|
+
and none does, there truly is no witness, precisely because the superset
|
|
68
|
+
was sound).
|
|
69
|
+
|
|
70
|
+
3. **``Count`` is evaluated natively, not expanded.** The kit's ``Count``
|
|
71
|
+
node (``∃≥n``/``∃≤n``/``∃=n``) already keeps its bound symbolically instead
|
|
72
|
+
of unrolling to n nested existentials — but every OTHER consumer
|
|
73
|
+
(``to_z3``/``to_prover9``/``to_tptp``) still has to lower it to the
|
|
74
|
+
standard distinct-witnesses encoding before use, because those back-ends
|
|
75
|
+
only understand plain FOL. This evaluator does not: :func:`_eval_count`
|
|
76
|
+
counts satisfying individuals directly off the candidate set from (2),
|
|
77
|
+
short-circuiting as soon as the bound is reached for ``op="ge"`` (and as
|
|
78
|
+
soon as it is provably exceeded for ``"le"``/``"eq"``). The motivating
|
|
79
|
+
case is ``hasAtLeast40Carbons``, written today as 40 nested existentials
|
|
80
|
+
plus all C(40,2)=780 pairwise inequalities — a formula shape that is a
|
|
81
|
+
primary timeout source in practice. As ``∃≥40 x c(x)`` against a
|
|
82
|
+
structure of ~50 carbons, this evaluator does a single pass over the
|
|
83
|
+
~50-element carbon candidate list and stops at the 40th hit (see
|
|
84
|
+
``tests/test_model_eval.py``'s performance test). Evaluating the SAME
|
|
85
|
+
formula already given in its expanded nested-∃-with-pairwise-≠ form gets
|
|
86
|
+
none of this — the ``≠`` atoms carry no membership information for
|
|
87
|
+
:func:`_harvest_atoms` (deliberately: a disequality is not "individual x
|
|
88
|
+
has property p", so it is not folded into ``individuals_with``), so the
|
|
89
|
+
search degrades back to unindexed backtracking. This is not a bug this
|
|
90
|
+
module could fix by being cleverer about ``≠``: recognising an expanded
|
|
91
|
+
counting encoding and un-expanding it back to ``Count`` is a distinct,
|
|
92
|
+
out-of-scope problem. The fix is architectural — a front-end should emit
|
|
93
|
+
``Count`` directly, which is exactly what makes this evaluator's
|
|
94
|
+
native-``Count`` path pay off.
|
|
95
|
+
|
|
96
|
+
Supported nodes: ``Atom`` (incl. ``=``/``≠``, read as term identity — domain
|
|
97
|
+
individuals are opaque names, so identity is Python ``==``), ``Not``, ``And``,
|
|
98
|
+
``Contrast`` (truth-functionally ``And``: concession is a DISCOURSE relation,
|
|
99
|
+
and the node's own contract says every export treats it as ``∧``, so reading
|
|
100
|
+
it any other way here would invent a semantics that contract denies), ``Or``,
|
|
101
|
+
``Xor``, ``Implies``, ``Iff``, ``Quantifier`` (``forall``/``exists``, ASCII or
|
|
102
|
+
``∀``/``∃``), ``Count`` (``ge``/``le``/``eq``) and ``Cardinality``
|
|
103
|
+
(``|{v : φ}|``, in a comparison — see below). Argument positions accept
|
|
104
|
+
``Variable`` (via ``assignment``), ``Constant`` (via
|
|
105
|
+
:attr:`FiniteStructure.constants`) and, as of the case described below,
|
|
106
|
+
``Function``. Anything else (modal/epistemic/temporal operators,
|
|
107
|
+
Łukasiewicz/fuzzy connectives, lambda terms, second-order quantifiers,
|
|
108
|
+
sorted/many-sorted nodes, ``Measure``, ...) is refused LOUDLY with
|
|
109
|
+
:class:`UnsupportedNode` rather than silently approximated — this kit never
|
|
110
|
+
guesses at semantics it was not told to implement. ``SortedCount`` was
|
|
111
|
+
weighed deliberately rather than just skipped: it needs a sort universe to
|
|
112
|
+
range over, and :class:`FiniteStructure` has no ``sorts`` concept at all
|
|
113
|
+
(unlike ``tarski.Structure``) — inventing one here would mean extending the
|
|
114
|
+
shared structure contract, out of bounds for this module.
|
|
115
|
+
|
|
116
|
+
**``Function`` terms.** A :class:`FiniteStructure` interprets a function
|
|
117
|
+
symbol ``f``/``k`` the same way
|
|
118
|
+
:func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` already
|
|
119
|
+
builds one: as a ``(name, k+1)`` "total relation" extension (the ``k``
|
|
120
|
+
arguments, then the result). :func:`_function_value` reads ``f(t1,...,tk)``
|
|
121
|
+
off that relation — the unique row whose leading ``k`` components equal the
|
|
122
|
+
(recursively evaluated, so nested composition ``f(g(x))`` needs no special
|
|
123
|
+
case) arguments — and refuses LOUDLY, by :class:`ValueError`, if no such row
|
|
124
|
+
exists (the function is PARTIAL on these arguments in a hand-built structure)
|
|
125
|
+
or more than one does (the relation is not FUNCTIONAL): a hand-built
|
|
126
|
+
:class:`FiniteStructure` that puts a partial/non-functional relation under a
|
|
127
|
+
function's key is refused, never silently misread. A ``computed`` function
|
|
128
|
+
relation is supported too (searched over the whole domain for the unique
|
|
129
|
+
witness, mirroring :func:`_reverse_neighbors`'s identical treatment of a
|
|
130
|
+
computed binary predicate — see :func:`_function_value`'s own docstring for
|
|
131
|
+
the full account, including why this reuses :func:`_holds`'s
|
|
132
|
+
:class:`UninterpretedSymbol` pattern for a function this structure does not
|
|
133
|
+
interpret at all). Candidate generation (design decision #2 above) is
|
|
134
|
+
UNAFFECTED: :func:`_variable_candidates`/:func:`_resolved_individual` only
|
|
135
|
+
ever recognise a BARE ``Variable``/``Constant`` occurrence, so a variable
|
|
136
|
+
that occurs only inside a ``Function`` argument (``p(f(x))``) is never
|
|
137
|
+
narrowed by the heuristic — it simply falls back to the documented
|
|
138
|
+
full-domain scan for that variable (see :func:`_resolved_individual`'s own
|
|
139
|
+
docstring for why this stays sound rather than an argument for extending the
|
|
140
|
+
heuristic to peer inside a ``Function``). And the two arithmetic-adjacent
|
|
141
|
+
concerns this addition could in principle have reopened both stay closed by
|
|
142
|
+
construction, not by a new check here: (1) the four arithmetic operator names
|
|
143
|
+
``+``/``-``/``*``/``/`` are excluded from
|
|
144
|
+
:meth:`~unicode_logic_kit.fol.signature.Signature.from_formulas`'s
|
|
145
|
+
``functions`` section (the ``_BUILTIN_FUNCS`` carve-out), so a structure
|
|
146
|
+
built via :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
|
|
147
|
+
never has an extension for them and :func:`_function_value` reports them
|
|
148
|
+
UNINTERPRETED like any other undeclared symbol — no name-based refusal is
|
|
149
|
+
needed in this module; (2) a ``Function`` term is evaluated ONLY by
|
|
150
|
+
:func:`_term_value`/:func:`_function_value`, which answer with individuals,
|
|
151
|
+
and NEVER by :func:`_numeric_value`, which answers with integers and only
|
|
152
|
+
ever receives the operand syntactically marked ``Cardinality``/``Number`` —
|
|
153
|
+
the two paths share no lookup, so this cannot reintroduce the kind of
|
|
154
|
+
numeral-read-as-a-structure-constant confusion
|
|
155
|
+
:mod:`~unicode_logic_kit.semantics.tarski` had to fix for a bare ``Number``
|
|
156
|
+
next to a ``Cardinality`` (see the "Two kinds of term value" bullet below).
|
|
157
|
+
|
|
158
|
+
**Two kinds of term value, kept apart.** ``Cardinality`` denotes a NATURAL
|
|
159
|
+
NUMBER, not a domain individual, and every term in an ARGUMENT position must
|
|
160
|
+
denote an individual (that is what makes it usable as a predicate argument).
|
|
161
|
+
Admitting numbers everywhere really would be a redesign. It is not needed:
|
|
162
|
+
over a finite structure a numeric term can only occur as an operand of a
|
|
163
|
+
COMPARISON, so the two notions never have to mix. :func:`_term_value` still
|
|
164
|
+
answers with individuals (now including ``Function``'s result — see above)
|
|
165
|
+
and still refuses ``Cardinality``; :func:`_numeric_value` answers with
|
|
166
|
+
integers and is reached only from the comparison branch of
|
|
167
|
+
:func:`_atom_value`, which switches on the syntactic shape of the operands —
|
|
168
|
+
a ``Function`` term compared against a ``Cardinality`` (``f(a) > |{x :
|
|
169
|
+
P(x)}|``) reaches :func:`_numeric_value` on the ``Function`` operand and is
|
|
170
|
+
refused there ("does not denote a number"), the identical reading already
|
|
171
|
+
given to comparing a domain individual with a bare numeral. ``|{v : φ}|`` is
|
|
172
|
+
then simply counted over the domain — the same "counting is decidable on a
|
|
173
|
+
finite structure" that makes ``Count`` native here, one level down at the
|
|
174
|
+
term. This is what lets the finite-domain backends
|
|
175
|
+
(:mod:`unicode_logic_kit.atp.clingo_backend`) have their answers CHECKED: a
|
|
176
|
+
solver that decides a cardinality comparison is of no use if the kit cannot
|
|
177
|
+
verify the model it returns.
|
|
178
|
+
|
|
179
|
+
**``all_different`` — a SEMANTICS switch, not a performance knob.** Under
|
|
180
|
+
plain FOL semantics (``all_different=False``, the default), two separately
|
|
181
|
+
quantified existential variables MAY denote the same individual unless the
|
|
182
|
+
formula itself says otherwise. ChemLog's TPTP output instead follows the
|
|
183
|
+
convention that separately introduced existential variables always denote
|
|
184
|
+
PAIRWISE DISTINCT individuals (e.g. ``∃x∃y (c(x) ∧ c(y) ∧ …)`` demands two
|
|
185
|
+
DIFFERENT carbons, with no explicit ``x≠y`` anywhere in the text). Passing
|
|
186
|
+
``all_different=True`` reproduces that convention, but only among
|
|
187
|
+
existentials in an ANCESTOR/DESCENDANT relationship in the formula's syntax
|
|
188
|
+
tree — i.e. ``∃y`` sits somewhere inside the MATRIX that ``∃x`` quantifies
|
|
189
|
+
over, reachable by descending through any mix of ``Not``/``And``/``Or``/
|
|
190
|
+
``Xor``/``Implies``/``Iff``/``∀``/``Count``'s own formula/further ``∃``
|
|
191
|
+
layers. Concretely: every individual already bound to such an ENCLOSING
|
|
192
|
+
active ``Quantifier(type="exists", …)`` — including an outer layer of the
|
|
193
|
+
SAME peeled ``∃x∃x∃x…`` chain reusing one variable name, each layer counted
|
|
194
|
+
separately even though later layers shadow earlier ones in the final
|
|
195
|
+
assignment — is excluded from the candidate set considered for the next one,
|
|
196
|
+
for as long as that enclosing search is still on the call stack (tracked as
|
|
197
|
+
``bound_existentials``, extended only inside :func:`_search_exists` /
|
|
198
|
+
:func:`_search_exists_witness` and threaded unchanged through every other
|
|
199
|
+
node's recursive calls).
|
|
200
|
+
|
|
201
|
+
**This does NOT reach SIBLING existentials** — two ``∃`` nodes where neither
|
|
202
|
+
is nested inside the other's matrix, e.g. ``(∃x φ(x)) ∧ (∃y ψ(y))`` at the
|
|
203
|
+
same ``And``, or two ``∃`` under different disjuncts of an ``Or``, or one in
|
|
204
|
+
each of two ``Count`` bodies. Each such ``∃`` is evaluated with the
|
|
205
|
+
``bound_existentials`` frozenset :func:`_eval` received on entry to that
|
|
206
|
+
connective — a sibling's search accumulates its own bindings only for the
|
|
207
|
+
duration of its own (fully self-contained) call to :func:`_search_exists`,
|
|
208
|
+
and that accumulation is discarded, never propagated sideways, once that
|
|
209
|
+
call returns the connective's boolean result. So ``x`` and ``y`` above MAY
|
|
210
|
+
still denote the same individual even under ``all_different=True`` — this is
|
|
211
|
+
a deliberate, narrow reading of "separately introduced": the module only
|
|
212
|
+
tracks quantifiers that are still SYNTACTICALLY ACTIVE (an ancestor whose
|
|
213
|
+
witness search has not yet returned) at the point ``∃y`` is evaluated, not
|
|
214
|
+
every existential anywhere else in the formula. Widening it to cover
|
|
215
|
+
siblings would need a whole-formula pre-pass collecting every ``∃`` in the
|
|
216
|
+
tree before evaluation starts (or a different, non-local candidate-pruning
|
|
217
|
+
strategy) — a real semantics change, not attempted here; nest the
|
|
218
|
+
quantifiers (``∃x (φ(x) ∧ ∃y ψ(y))``) if sibling-level distinctness is what a
|
|
219
|
+
formula needs.
|
|
220
|
+
|
|
221
|
+
This applies ONLY to plain existential ``Quantifier`` nodes. It does NOT
|
|
222
|
+
reach into ``Count``: ``Count``'s own ``n`` witnesses are already required to
|
|
223
|
+
be pairwise distinct by definition (that is what ``∃≥n`` means) regardless of
|
|
224
|
+
this flag, and this flag does not additionally force a ``Count``'s witnesses
|
|
225
|
+
to avoid individuals used by an outer, separately scoped ``∃`` — model that
|
|
226
|
+
case as nested ``∃`` if you need it. ``∀``-bound variables are never
|
|
227
|
+
affected.
|
|
228
|
+
|
|
229
|
+
**Budget / the three-valued contract.** ``evaluate_detailed(..., budget=k)``
|
|
230
|
+
counts one "step" per node visited (see ``_Ctx.tick``, called once per
|
|
231
|
+
:func:`_eval` invocation and once per node of the existential search tree in
|
|
232
|
+
:func:`_search_exists`); if evaluation would need more than ``k`` steps, it
|
|
233
|
+
stops and reports ``exhausted=True`` with ``holds=None`` — an honest UNKNOWN,
|
|
234
|
+
never a guessed ``False``. :func:`evaluate` (the boolean-only entry point)
|
|
235
|
+
cannot represent "unknown" in its return type, so on exhaustion it raises
|
|
236
|
+
:class:`BudgetExhausted` instead of silently returning a value: a caller that
|
|
237
|
+
only wants a bool must explicitly decide what an unknown answer means to it,
|
|
238
|
+
rather than have this module decide for them. ``witness``/``failing_conjunct``
|
|
239
|
+
extraction (see below) is a secondary pass that reuses the caller's budget but
|
|
240
|
+
degrades to ``None`` on running out, WITHOUT invalidating the already-decided
|
|
241
|
+
``holds`` — see :func:`evaluate_detailed`.
|
|
242
|
+
|
|
243
|
+
**Uninterpreted symbols.** :meth:`FiniteStructure.holds` /
|
|
244
|
+
:meth:`~FiniteStructure.individuals_with` / :meth:`~FiniteStructure.neighbors`
|
|
245
|
+
raise ``KeyError`` for a predicate the structure does not interpret. This
|
|
246
|
+
module catches that and re-raises :class:`UninterpretedSymbol`, a small typed
|
|
247
|
+
exception carrying ``.symbol``/``.arity`` plus a message naming what is
|
|
248
|
+
missing — a vocabulary mismatch between a formula and a structure is a bug in
|
|
249
|
+
the caller's pipeline, not a reason to call the definition merely unsatisfied.
|
|
250
|
+
|
|
251
|
+
**``witness`` / ``failing_conjunct`` — best-effort explanations, not a proof
|
|
252
|
+
object.** They recognise exactly the ``∧``/``∃``/``∨`` shape that a class
|
|
253
|
+
definition like ChEBI's typically has:
|
|
254
|
+
|
|
255
|
+
* ``witness`` (populated only when ``holds`` is ``True``) is built by
|
|
256
|
+
:func:`_find_witness`: an ``And`` merges the witnesses of both conjuncts: an
|
|
257
|
+
``∃`` (chain) re-derives, via the SAME candidate search as the main
|
|
258
|
+
evaluation, ONE variable→individual binding that makes it true, and merges
|
|
259
|
+
in the witness of its matrix under that binding; an ``Or`` reports the
|
|
260
|
+
witness of whichever disjunct is true (preferring the left, matching
|
|
261
|
+
evaluation order). Everything else (``Not``, ``∀``, ``Count``, ``Implies``,
|
|
262
|
+
``Iff``, ``Xor``, a bare ``Atom``) contributes no bindings — not every true
|
|
263
|
+
formula has a natural single-assignment witness, and this does not attempt
|
|
264
|
+
to invent one (a ``Count``'s witness is a SET of individuals, which does
|
|
265
|
+
not fit a ``Dict[str, str]``; a disjunction's untaken branch is simply not
|
|
266
|
+
reported).
|
|
267
|
+
* ``failing_conjunct`` (populated only when ``holds`` is ``False``) is built
|
|
268
|
+
by :func:`_find_blame`: it drills through a chain of nested ``And`` nodes
|
|
269
|
+
(left-to-right, the same short-circuit order the main evaluation used) to
|
|
270
|
+
the first conjunct that is actually false, and stops there — an ``Or``, a
|
|
271
|
+
``Not``, or any other non-``And`` node is reported AS ITSELF (not
|
|
272
|
+
decomposed further: which of two false disjuncts is "the" reason is
|
|
273
|
+
genuinely ambiguous, so this does not guess).
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
from dataclasses import dataclass
|
|
277
|
+
from typing import Dict, FrozenSet, List, Mapping, Optional, Tuple
|
|
278
|
+
|
|
279
|
+
from .structures import FiniteStructure, Individual, Key
|
|
280
|
+
from ..fol._tptp_symbols import is_tptp_boolean_atom as _is_tptp_boolean_atom
|
|
281
|
+
from ..fol._tptp_symbols import truth_constant_word as _truth_constant_word
|
|
282
|
+
from ..fol.nodes import (
|
|
283
|
+
Node, Variable, Constant, Number, Cardinality, Function,
|
|
284
|
+
Atom, Not, And, Contrast, Or, Xor, Implies, Iff, Quantifier, Count,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
__all__ = [
|
|
288
|
+
"EvalResult", "BudgetExhausted", "UninterpretedSymbol", "UnsupportedNode",
|
|
289
|
+
"evaluate", "evaluate_detailed",
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
# Quantifier.type spellings accepted for each quantifier kind (matches tarski.py).
|
|
293
|
+
_FORALL = ("forall", "∀")
|
|
294
|
+
_EXISTS = ("exists", "∃")
|
|
295
|
+
|
|
296
|
+
Assignment = Mapping[str, Individual]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
# Errors
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
class UninterpretedSymbol(LookupError):
|
|
304
|
+
"""A formula mentions a predicate/constant this structure does not interpret.
|
|
305
|
+
|
|
306
|
+
Raised instead of letting :class:`FiniteStructure`'s bare ``KeyError``
|
|
307
|
+
propagate, so a caller sees WHICH symbol (name + arity) is missing without
|
|
308
|
+
having to parse a generic key-error message. ``.symbol`` and ``.arity``
|
|
309
|
+
carry that structurally (``.arity`` is ``None`` for a missing constant).
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def __init__(self, message: str, *, symbol: str, arity: Optional[int]):
|
|
313
|
+
super().__init__(message)
|
|
314
|
+
self.symbol = symbol
|
|
315
|
+
self.arity = arity
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class UnsupportedNode(NotImplementedError):
|
|
319
|
+
"""A node type outside this evaluator's classical, non-modal, non-fuzzy,
|
|
320
|
+
non-lambda, non-second-order, first-order fragment (see the module
|
|
321
|
+
docstring's "Supported nodes" list). Raised loudly rather than
|
|
322
|
+
approximated — this kit never silently treats an operator it was not told
|
|
323
|
+
how to evaluate as a no-op."""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class BudgetExhausted(RuntimeError):
|
|
327
|
+
""":func:`evaluate` could not determine a truth value within its step
|
|
328
|
+
budget. The honest reading is UNKNOWN, not ``False`` — ``evaluate`` cannot
|
|
329
|
+
express that in a ``bool`` return, so it raises instead of guessing. Call
|
|
330
|
+
:func:`evaluate_detailed` directly to receive the three-valued
|
|
331
|
+
:class:`EvalResult` (``holds=None``, ``exhausted=True``) rather than an
|
|
332
|
+
exception. ``.steps`` is how many steps were actually spent."""
|
|
333
|
+
|
|
334
|
+
def __init__(self, message: str, *, steps: int):
|
|
335
|
+
super().__init__(message)
|
|
336
|
+
self.steps = steps
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Result object
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class EvalResult:
|
|
345
|
+
"""Outcome of :func:`evaluate_detailed`.
|
|
346
|
+
|
|
347
|
+
``holds`` is ``Optional[bool]``: ``None`` iff ``exhausted`` is ``True``
|
|
348
|
+
(the budget ran out before a definitive answer — UNKNOWN, not ``False``).
|
|
349
|
+
``witness`` (only ever set when ``holds is True``) and ``failing_conjunct``
|
|
350
|
+
(only ever set when ``holds is False``) are best-effort explanations — see
|
|
351
|
+
the module docstring for exactly what shape of formula they can explain.
|
|
352
|
+
``steps`` is the number of evaluation steps actually performed (see
|
|
353
|
+
``_Ctx.tick``), including any spent on ``witness``/``failing_conjunct``
|
|
354
|
+
extraction. ``exhausted`` is ``True`` iff the ``budget`` passed to
|
|
355
|
+
:func:`evaluate_detailed` was used up before the core truth value itself
|
|
356
|
+
was decided (running out of budget only *during* witness/blame extraction
|
|
357
|
+
does not set this — see :func:`evaluate_detailed`).
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
holds: Optional[bool]
|
|
361
|
+
witness: Optional[Dict[str, str]]
|
|
362
|
+
failing_conjunct: Optional[Node]
|
|
363
|
+
steps: int
|
|
364
|
+
exhausted: bool
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# Budget / evaluation context
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
class _BudgetHit(Exception):
|
|
372
|
+
"""Internal control-flow signal only: the step budget ran out. Always
|
|
373
|
+
caught inside this module; never escapes evaluate()/evaluate_detailed()."""
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class _Ctx:
|
|
377
|
+
"""Mutable state threaded through one :func:`evaluate_detailed` call:
|
|
378
|
+
the step counter/budget, the ``all_different`` flag, and a per-call cache
|
|
379
|
+
for reverse binary-relation lookups (see :func:`_reverse_neighbors`) —
|
|
380
|
+
scoped to one call, not to the structure, because unlike
|
|
381
|
+
:meth:`FiniteStructure.neighbors`'s own cache this module cannot attach
|
|
382
|
+
a cache to the (frozen, unowned-by-us) structure object itself."""
|
|
383
|
+
|
|
384
|
+
__slots__ = ("budget", "steps", "all_different", "reverse_cache",
|
|
385
|
+
"harvest_cache", "function_cache")
|
|
386
|
+
|
|
387
|
+
def __init__(self, budget: Optional[int], all_different: bool):
|
|
388
|
+
self.budget = budget
|
|
389
|
+
self.steps = 0
|
|
390
|
+
self.all_different = all_different
|
|
391
|
+
self.reverse_cache: Dict[Key, Dict[Individual, FrozenSet[Individual]]] = {}
|
|
392
|
+
#: node -> _harvest_atoms(node). The harvest depends on the FORMULA
|
|
393
|
+
#: alone, never on the assignment, but candidate generation asks for
|
|
394
|
+
#: it once per binding attempt — so without this it is recomputed,
|
|
395
|
+
#: by full recursive descent, on every single step. Keyed by the node
|
|
396
|
+
#: itself (nodes are frozen and hashable), scoped to one call.
|
|
397
|
+
self.harvest_cache: Dict[Node, List[Atom]] = {}
|
|
398
|
+
#: (name, arity+1) -> {input_tuple: result} for a STORED function
|
|
399
|
+
#: extension — built once (see :func:`_function_value`) rather than
|
|
400
|
+
#: re-scanned on every occurrence of the same function symbol, the
|
|
401
|
+
#: same reasoning as ``reverse_cache`` for a stored binary relation.
|
|
402
|
+
#: A COMPUTED function key is deliberately never cached here — see
|
|
403
|
+
#: :func:`_function_value`'s own docstring, mirroring
|
|
404
|
+
#: :func:`_reverse_neighbors`'s identical choice for a computed
|
|
405
|
+
#: binary predicate.
|
|
406
|
+
self.function_cache: Dict[Key, Dict[Tuple[Individual, ...], Individual]] = {}
|
|
407
|
+
|
|
408
|
+
def tick(self) -> None:
|
|
409
|
+
self.steps += 1
|
|
410
|
+
if self.budget is not None and self.steps > self.budget:
|
|
411
|
+
raise _BudgetHit()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _extend(assignment: Assignment, name: str, value: Individual) -> Dict[str, Individual]:
|
|
415
|
+
"""A copy of ``assignment`` with ``name`` bound to ``value`` (functional
|
|
416
|
+
style — the caller's dict, and every other branch's, is never mutated)."""
|
|
417
|
+
new = dict(assignment)
|
|
418
|
+
new[name] = value
|
|
419
|
+
return new
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# Terms and atoms
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
def _term_value(term: Node, structure: FiniteStructure, assignment: Assignment,
|
|
427
|
+
ctx: "_Ctx") -> Individual:
|
|
428
|
+
"""Evaluate a TERM to an individual. ``Variable``, ``Constant`` and
|
|
429
|
+
``Function`` are supported (see the module docstring) — a
|
|
430
|
+
:class:`FiniteStructure` interprets predicates AND, as of the ``Function``
|
|
431
|
+
case below, functions (read off the ``(name, arity+1)`` total-relation
|
|
432
|
+
extension :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
|
|
433
|
+
already builds); ``Number``/``Cardinality``/``Measure`` denote NUMBERS,
|
|
434
|
+
not individuals, and have nothing to evaluate against here — see
|
|
435
|
+
:func:`_numeric_value` for those, and the module docstring's "Two kinds
|
|
436
|
+
of term value, kept apart" for why the two are never merged."""
|
|
437
|
+
if isinstance(term, Variable):
|
|
438
|
+
if term.name not in assignment:
|
|
439
|
+
raise ValueError(
|
|
440
|
+
f"model_eval: variable {term.name!r} is free — it is bound by "
|
|
441
|
+
"no enclosing quantifier and was not supplied in assignment=."
|
|
442
|
+
)
|
|
443
|
+
return assignment[term.name]
|
|
444
|
+
if isinstance(term, Constant):
|
|
445
|
+
if term.name not in structure.constants:
|
|
446
|
+
raise UninterpretedSymbol(
|
|
447
|
+
f"model_eval: constant {term.name!r} is not interpreted by this "
|
|
448
|
+
f"structure (known constants: {sorted(structure.constants)}).",
|
|
449
|
+
symbol=term.name, arity=None,
|
|
450
|
+
)
|
|
451
|
+
return structure.constants[term.name]
|
|
452
|
+
if isinstance(term, Function):
|
|
453
|
+
return _function_value(term, structure, assignment, ctx)
|
|
454
|
+
raise UnsupportedNode(
|
|
455
|
+
f"model_eval: term node {type(term).__name__} is not supported — only "
|
|
456
|
+
"Variable, Constant and Function terms are evaluated (no Number/"
|
|
457
|
+
"Cardinality/Measure terms; see the module docstring)."
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _function_value(term: Function, structure: FiniteStructure, assignment: Assignment,
|
|
462
|
+
ctx: "_Ctx") -> Individual:
|
|
463
|
+
"""Evaluate a ``Function`` TERM ``f(t1,...,tk)`` to the individual it denotes.
|
|
464
|
+
|
|
465
|
+
Arguments are evaluated recursively through :func:`_term_value` FIRST —
|
|
466
|
+
which is also what makes nested composition (``f(g(x))``) just work, via
|
|
467
|
+
ordinary Python recursion, with no special-casing here. The function
|
|
468
|
+
itself is then read off the SAME ``(name, arity+1)`` "total relation"
|
|
469
|
+
:func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` already
|
|
470
|
+
reconstructs for a function symbol (inputs, then result — see that
|
|
471
|
+
function's own docstring): the unique row whose leading ``k`` components
|
|
472
|
+
equal the evaluated arguments supplies the result.
|
|
473
|
+
|
|
474
|
+
A STORED extension is indexed into a plain ``{inputs: result}`` dict ONCE
|
|
475
|
+
per ``(name, arity+1)`` key and cached in ``ctx.function_cache`` for the
|
|
476
|
+
rest of this evaluation call (mirroring ``reverse_cache`` for a stored
|
|
477
|
+
binary relation) — building that index is also where FUNCTIONALITY is
|
|
478
|
+
checked: two different rows sharing the same input tuple is a defect in a
|
|
479
|
+
hand-built structure (a genuine solver-produced structure already passed
|
|
480
|
+
:func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`'s own
|
|
481
|
+
functionality check, so this can only fire for a structure built by hand
|
|
482
|
+
with a broken ``Function``-shaped extension). A COMPUTED relation cannot
|
|
483
|
+
be inverted this way (an opaque callable has no rows to scan) and is
|
|
484
|
+
instead searched over the WHOLE domain for the individual ``y`` with
|
|
485
|
+
``computed(*args, y)`` true — uncached, the identical trade-off
|
|
486
|
+
:func:`_reverse_neighbors` already makes for a computed binary predicate,
|
|
487
|
+
for the identical reason. Either way, ANYTHING other than exactly one
|
|
488
|
+
matching result — none (the function is PARTIAL on these arguments) or
|
|
489
|
+
more than one (the relation is not FUNCTIONAL) — is refused loudly with a
|
|
490
|
+
named :class:`ValueError`, mirroring :func:`_holds`'s
|
|
491
|
+
:class:`UninterpretedSymbol` pattern for "this structure does not say
|
|
492
|
+
what I need it to": a hand-built :class:`FiniteStructure` that puts a
|
|
493
|
+
partial or non-functional relation under a function's key must be
|
|
494
|
+
refused, not silently misread as picking an arbitrary row or as
|
|
495
|
+
"uninterpreted".
|
|
496
|
+
|
|
497
|
+
An entirely UNINTERPRETED function symbol (neither stored nor computed —
|
|
498
|
+
this is what a genuinely arithmetic name like ``+``/``-``/``*``/``/``
|
|
499
|
+
hits: :meth:`~unicode_logic_kit.fol.signature.Signature.from_formulas`
|
|
500
|
+
never declares them as user functions, so a structure built via
|
|
501
|
+
:func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` never
|
|
502
|
+
has an extension for them either — this evaluator needs no special check
|
|
503
|
+
of its own to keep them refused, see the module docstring) raises
|
|
504
|
+
:class:`UninterpretedSymbol` exactly like an uninterpreted predicate.
|
|
505
|
+
"""
|
|
506
|
+
args = tuple(_term_value(a, structure, assignment, ctx) for a in term.args)
|
|
507
|
+
name = term.name
|
|
508
|
+
k = len(args)
|
|
509
|
+
key = (name, k + 1)
|
|
510
|
+
|
|
511
|
+
if key in structure.extensions:
|
|
512
|
+
graph = ctx.function_cache.get(key)
|
|
513
|
+
if graph is None:
|
|
514
|
+
graph = {}
|
|
515
|
+
for row in structure.extensions[key]:
|
|
516
|
+
inputs, result = row[:-1], row[-1]
|
|
517
|
+
if inputs in graph and graph[inputs] != result:
|
|
518
|
+
raise ValueError(
|
|
519
|
+
f"model_eval: function {name!r}/{k} is not FUNCTIONAL "
|
|
520
|
+
f"in this structure — both {graph[inputs]!r} and "
|
|
521
|
+
f"{result!r} are claimed as the result for "
|
|
522
|
+
f"{name}{inputs} (a hand-built FiniteStructure must "
|
|
523
|
+
"obey the same 'exactly one result per input tuple' "
|
|
524
|
+
"contract structure_from_solution enforces on a "
|
|
525
|
+
"solver's own output)."
|
|
526
|
+
)
|
|
527
|
+
graph[inputs] = result
|
|
528
|
+
ctx.function_cache[key] = graph
|
|
529
|
+
if args not in graph:
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f"model_eval: function {name!r}/{k} has no result for "
|
|
532
|
+
f"{name}{args} in this structure — it must be TOTAL (a row "
|
|
533
|
+
"for every input tuple), the other half of the "
|
|
534
|
+
"structure_from_solution contract a hand-built structure "
|
|
535
|
+
"must also obey."
|
|
536
|
+
)
|
|
537
|
+
return graph[args]
|
|
538
|
+
|
|
539
|
+
if key in structure.computed:
|
|
540
|
+
decide = structure.computed[key]
|
|
541
|
+
matches = tuple(y for y in structure.domain if decide(*args, y))
|
|
542
|
+
if len(matches) != 1:
|
|
543
|
+
raise ValueError(
|
|
544
|
+
f"model_eval: function {name!r}/{k} is not a total function "
|
|
545
|
+
f"in this structure — {name}{args} has {len(matches)} "
|
|
546
|
+
f"result(s) ({matches!r}) among this structure's computed "
|
|
547
|
+
f"{name!r}/{key[1]} relation, not exactly one."
|
|
548
|
+
)
|
|
549
|
+
return matches[0]
|
|
550
|
+
|
|
551
|
+
raise UninterpretedSymbol(
|
|
552
|
+
f"model_eval: function {name!r}/{k} is not interpreted by this "
|
|
553
|
+
f"structure (known: {[f'{n}/{a}' for n, a in structure.signature()]}).",
|
|
554
|
+
symbol=name, arity=k,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _holds(structure: FiniteStructure, name: str, args: Tuple[Individual, ...]) -> bool:
|
|
559
|
+
"""``structure.holds`` wrapped to turn its bare ``KeyError`` into
|
|
560
|
+
:class:`UninterpretedSymbol`."""
|
|
561
|
+
try:
|
|
562
|
+
return structure.holds(name, args)
|
|
563
|
+
except KeyError:
|
|
564
|
+
raise UninterpretedSymbol(
|
|
565
|
+
f"model_eval: predicate {name!r}/{len(args)} is not interpreted by "
|
|
566
|
+
"this structure (known: "
|
|
567
|
+
f"{[f'{n}/{a}' for n, a in structure.signature()]}).",
|
|
568
|
+
symbol=name, arity=len(args),
|
|
569
|
+
) from None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
#: The comparison predicates, with the integer relation each denotes when its
|
|
573
|
+
#: operands are NUMERIC. ``=``/``≠`` appear here as well as in the identity
|
|
574
|
+
#: path below: which reading applies is decided by the operands, never by the
|
|
575
|
+
#: symbol alone.
|
|
576
|
+
_ORDER_OPS = {
|
|
577
|
+
"=": lambda a, b: a == b, "≠": lambda a, b: a != b,
|
|
578
|
+
"<": lambda a, b: a < b, ">": lambda a, b: a > b,
|
|
579
|
+
"≤": lambda a, b: a <= b, "≥": lambda a, b: a >= b,
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
#: Term nodes that denote a NUMBER rather than an individual. Their presence
|
|
583
|
+
#: in a comparison is what switches that comparison to the numeric reading.
|
|
584
|
+
_NUMERIC_TERMS = (Cardinality, Number)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _numeric_value(term: Node, structure: FiniteStructure, assignment: Assignment,
|
|
588
|
+
bound_existentials: FrozenSet[Individual], ctx: "_Ctx") -> int:
|
|
589
|
+
"""Evaluate a NUMERIC term to an integer.
|
|
590
|
+
|
|
591
|
+
Deliberately separate from :func:`_term_value`, which answers with
|
|
592
|
+
individuals: the two notions of "term value" are incompatible and are kept
|
|
593
|
+
apart rather than merged (see the module docstring). Only the comparison
|
|
594
|
+
branch of :func:`_atom_value` calls this.
|
|
595
|
+
|
|
596
|
+
``|{v : φ}|`` is counted over the whole domain — no candidate narrowing,
|
|
597
|
+
because a COUNT needs every satisfying individual, not one witness, so
|
|
598
|
+
there is nothing to prune. Each individual costs a ``tick``, so the
|
|
599
|
+
evaluation budget covers counting exactly as it covers quantification.
|
|
600
|
+
"""
|
|
601
|
+
if isinstance(term, Number):
|
|
602
|
+
return term.value
|
|
603
|
+
if isinstance(term, Cardinality):
|
|
604
|
+
name = term.variable.name
|
|
605
|
+
total = 0
|
|
606
|
+
for d in structure.domain:
|
|
607
|
+
ctx.tick()
|
|
608
|
+
if _eval(term.formula, structure, _extend(assignment, name, d),
|
|
609
|
+
bound_existentials, ctx):
|
|
610
|
+
total += 1
|
|
611
|
+
return total
|
|
612
|
+
raise UnsupportedNode(
|
|
613
|
+
f"model_eval: {type(term).__name__} does not denote a number, so it "
|
|
614
|
+
"cannot be compared with one — a comparison mixing a cardinality with "
|
|
615
|
+
"a domain individual has no reading here."
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _atom_value(atom: Atom, structure: FiniteStructure, assignment: Assignment,
|
|
620
|
+
bound_existentials: FrozenSet[Individual], ctx: "_Ctx") -> bool:
|
|
621
|
+
"""Truth value of an atomic formula.
|
|
622
|
+
|
|
623
|
+
Three readings, chosen by the operands rather than by the predicate name:
|
|
624
|
+
|
|
625
|
+
* a comparison with at least one NUMERIC operand (``Cardinality`` or
|
|
626
|
+
``Number``) is arithmetic — ``|{x : P(x)}| > |{y : Q(y)}|`` compares two
|
|
627
|
+
counts;
|
|
628
|
+
* ``=``/``≠`` over individuals is term identity, read directly (domain
|
|
629
|
+
individuals are opaque names, so identity is Python ``==``) rather than
|
|
630
|
+
routed through the structure, which never stores an extension for them;
|
|
631
|
+
* everything else, INCLUDING ``<``/``>``/``≤``/``≥`` between ordinary
|
|
632
|
+
terms, is an ordinary predicate looked up via :func:`_holds`. A
|
|
633
|
+
structure is free to interpret ``<`` as any relation it likes, and this
|
|
634
|
+
evaluator does not impose an order on an uninterpreted symbol.
|
|
635
|
+
"""
|
|
636
|
+
if (atom.predicate in _ORDER_OPS and len(atom.args) == 2
|
|
637
|
+
and any(isinstance(a, _NUMERIC_TERMS) for a in atom.args)):
|
|
638
|
+
left = _numeric_value(atom.args[0], structure, assignment,
|
|
639
|
+
bound_existentials, ctx)
|
|
640
|
+
right = _numeric_value(atom.args[1], structure, assignment,
|
|
641
|
+
bound_existentials, ctx)
|
|
642
|
+
return _ORDER_OPS[atom.predicate](left, right)
|
|
643
|
+
if _is_tptp_boolean_atom(atom):
|
|
644
|
+
# TPTP's defined propositions, not a relation of the structure — the
|
|
645
|
+
# reading to_z3, the Tarski evaluator and the TPTP writers give them.
|
|
646
|
+
return _truth_constant_word(atom) == "$true"
|
|
647
|
+
if atom.predicate in ("=", "≠") and len(atom.args) == 2:
|
|
648
|
+
left_i = _term_value(atom.args[0], structure, assignment, ctx)
|
|
649
|
+
right_i = _term_value(atom.args[1], structure, assignment, ctx)
|
|
650
|
+
same = left_i == right_i
|
|
651
|
+
return same if atom.predicate == "=" else not same
|
|
652
|
+
args = tuple(_term_value(a, structure, assignment, ctx) for a in atom.args)
|
|
653
|
+
return _holds(structure, atom.predicate, args)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
# Candidate generation (design decision #2 — see module docstring)
|
|
658
|
+
# ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
def _harvest_atoms(node: Node) -> List[Atom]:
|
|
661
|
+
"""Atoms that are UNCONDITIONALLY required by ``node`` — reachable only
|
|
662
|
+
through a chain of nested ``And`` (both branches); does not descend into
|
|
663
|
+
``Or``/``Not``/``Implies``/``Iff``/``Xor``/nested ``Quantifier``/``Count``.
|
|
664
|
+
See the module docstring's "Harvesting rule" for why this is sound."""
|
|
665
|
+
if isinstance(node, (And, Contrast)):
|
|
666
|
+
return _harvest_atoms(node.left) + _harvest_atoms(node.right)
|
|
667
|
+
if isinstance(node, Atom):
|
|
668
|
+
return [node]
|
|
669
|
+
return []
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _resolved_individual(
|
|
673
|
+
term: Node, structure: FiniteStructure, assignment: Assignment
|
|
674
|
+
) -> Optional[Individual]:
|
|
675
|
+
"""The individual an ALREADY-BOUND term denotes, or ``None`` if it is not
|
|
676
|
+
(yet) resolvable. Not an error path: candidate generation is a heuristic
|
|
677
|
+
that simply skips an atom it cannot yet use — :func:`_term_value` is what
|
|
678
|
+
enforces free-variable/uninterpreted-constant errors during real
|
|
679
|
+
evaluation.
|
|
680
|
+
|
|
681
|
+
Deliberately returns ``None`` — "not yet resolvable" — for a
|
|
682
|
+
:class:`Function` term such as ``f(y)``, even when ``y`` is itself bound:
|
|
683
|
+
this helper only ever recognises a BARE ``Constant``/``Variable``, never
|
|
684
|
+
evaluates a ``Function`` to find its value. That keeps candidate
|
|
685
|
+
generation SOUND with no extra reasoning needed here — a variable that
|
|
686
|
+
occurs only inside a ``Function`` argument (``rel(f(x), y)``, or a unary
|
|
687
|
+
atom ``p(f(x))``) is simply never narrowed by :func:`_variable_candidates`
|
|
688
|
+
(its own ``isinstance(t, Variable)`` checks reject a ``Function``-wrapped
|
|
689
|
+
occurrence the same way they reject any other non-bare term), so such a
|
|
690
|
+
variable always falls back to the full-domain scan documented in
|
|
691
|
+
:func:`_variable_candidates`'s own docstring — correct, just not indexed.
|
|
692
|
+
Peering INSIDE a ``Function`` argument to narrow the candidate set would
|
|
693
|
+
need its own soundness argument (the indexing heuristic was designed and
|
|
694
|
+
proven sound only for predicate atoms over bare terms — see the module
|
|
695
|
+
docstring) and is not attempted here."""
|
|
696
|
+
if isinstance(term, Constant) and term.name in structure.constants:
|
|
697
|
+
return structure.constants[term.name]
|
|
698
|
+
if isinstance(term, Variable) and term.name in assignment:
|
|
699
|
+
return assignment[term.name]
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _reverse_neighbors(
|
|
704
|
+
structure: FiniteStructure, name: str, individual: Individual, ctx: "_Ctx"
|
|
705
|
+
) -> Tuple[Individual, ...]:
|
|
706
|
+
"""The ``x`` with ``name(x, individual)`` true — the REVERSE direction of
|
|
707
|
+
:meth:`FiniteStructure.neighbors` (which only indexes forward). Built from
|
|
708
|
+
the STORED extension when the relation is stored (cheap: a single pass
|
|
709
|
+
over an already-materialised set, cached in ``ctx`` for the rest of this
|
|
710
|
+
evaluation call). A ``computed`` binary predicate has no extension to
|
|
711
|
+
index — there is no way to invert an opaque callable without evaluating
|
|
712
|
+
it on every pair, so that one case falls back to an O(|domain|) scan of
|
|
713
|
+
:meth:`FiniteStructure.holds`. This is a deliberate, honest, documented
|
|
714
|
+
limitation (see the module docstring), not a silent unsoundness: the
|
|
715
|
+
result is still the exact reverse-neighbour set, just not indexed."""
|
|
716
|
+
key = (name, 2)
|
|
717
|
+
if key in structure.extensions:
|
|
718
|
+
index = ctx.reverse_cache.get(key)
|
|
719
|
+
if index is None:
|
|
720
|
+
index = {}
|
|
721
|
+
for a, b in structure.extensions[key]:
|
|
722
|
+
index.setdefault(b, set()).add(a)
|
|
723
|
+
ctx.reverse_cache[key] = index
|
|
724
|
+
members = index.get(individual, ())
|
|
725
|
+
return tuple(x for x in structure.domain if x in members)
|
|
726
|
+
if key in structure.computed:
|
|
727
|
+
decide = structure.computed[key]
|
|
728
|
+
return tuple(x for x in structure.domain if decide(x, individual))
|
|
729
|
+
raise UninterpretedSymbol(
|
|
730
|
+
f"model_eval: predicate {name!r}/2 is not interpreted by this structure "
|
|
731
|
+
f"(known: {[f'{n}/{a}' for n, a in structure.signature()]}).",
|
|
732
|
+
symbol=name, arity=2,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _variable_candidates(
|
|
737
|
+
var_name: str, body: Node, structure: FiniteStructure, assignment: Assignment,
|
|
738
|
+
ctx: "_Ctx",
|
|
739
|
+
) -> Tuple[Individual, ...]:
|
|
740
|
+
"""A SOUND (superset) candidate set for ``var_name``, in ``structure``
|
|
741
|
+
domain order: the intersection of every unary/binary constraint on it
|
|
742
|
+
harvested from ``body`` (see :func:`_harvest_atoms`). Falls back to the
|
|
743
|
+
WHOLE domain if nothing usable was found — still correct (every candidate
|
|
744
|
+
is fully re-checked by evaluating the whole body), just not narrowed.
|
|
745
|
+
|
|
746
|
+
This heuristic was designed and proven sound only for a harvested atom
|
|
747
|
+
whose argument is a BARE ``Variable``/``Constant`` — the ``isinstance(t,
|
|
748
|
+
Variable)`` checks below simply do not match a ``Function``-wrapped
|
|
749
|
+
occurrence (``p(f(x))``, ``rel(f(x), y)``), so such an atom contributes
|
|
750
|
+
NO narrowing for ``x`` (see :func:`_resolved_individual`'s own docstring
|
|
751
|
+
for the fuller argument) and ``x`` falls through to the documented
|
|
752
|
+
full-domain-scan fallback above instead — still sound (never narrows
|
|
753
|
+
WRONG), just not indexed. Extending the heuristic to peer inside a
|
|
754
|
+
``Function`` argument is deliberately NOT attempted here."""
|
|
755
|
+
harvested = ctx.harvest_cache.get(body)
|
|
756
|
+
if harvested is None:
|
|
757
|
+
harvested = _harvest_atoms(body)
|
|
758
|
+
ctx.harvest_cache[body] = harvested
|
|
759
|
+
found: Optional[set] = None
|
|
760
|
+
for atom in harvested:
|
|
761
|
+
if atom.predicate in ("=", "≠"):
|
|
762
|
+
continue # (dis)equality carries no domain-membership information
|
|
763
|
+
if len(atom.args) == 1:
|
|
764
|
+
(t,) = atom.args
|
|
765
|
+
if isinstance(t, Variable) and t.name == var_name:
|
|
766
|
+
try:
|
|
767
|
+
members = set(structure.individuals_with(atom.predicate))
|
|
768
|
+
except KeyError:
|
|
769
|
+
raise UninterpretedSymbol(
|
|
770
|
+
f"model_eval: predicate {atom.predicate!r}/1 is not "
|
|
771
|
+
"interpreted by this structure (known: "
|
|
772
|
+
f"{[f'{n}/{a}' for n, a in structure.signature()]}).",
|
|
773
|
+
symbol=atom.predicate, arity=1,
|
|
774
|
+
) from None
|
|
775
|
+
found = members if found is None else (found & members)
|
|
776
|
+
elif len(atom.args) == 2:
|
|
777
|
+
t0, t1 = atom.args
|
|
778
|
+
is_v0 = isinstance(t0, Variable) and t0.name == var_name
|
|
779
|
+
is_v1 = isinstance(t1, Variable) and t1.name == var_name
|
|
780
|
+
if is_v0 and not is_v1:
|
|
781
|
+
other = _resolved_individual(t1, structure, assignment)
|
|
782
|
+
if other is not None:
|
|
783
|
+
members = set(_reverse_neighbors(structure, atom.predicate, other, ctx))
|
|
784
|
+
found = members if found is None else (found & members)
|
|
785
|
+
elif is_v1 and not is_v0:
|
|
786
|
+
other = _resolved_individual(t0, structure, assignment)
|
|
787
|
+
if other is not None:
|
|
788
|
+
try:
|
|
789
|
+
members = set(structure.neighbors(atom.predicate, other))
|
|
790
|
+
except KeyError:
|
|
791
|
+
raise UninterpretedSymbol(
|
|
792
|
+
f"model_eval: predicate {atom.predicate!r}/2 is not "
|
|
793
|
+
"interpreted by this structure (known: "
|
|
794
|
+
f"{[f'{n}/{a}' for n, a in structure.signature()]}).",
|
|
795
|
+
symbol=atom.predicate, arity=2,
|
|
796
|
+
) from None
|
|
797
|
+
found = members if found is None else (found & members)
|
|
798
|
+
# both/neither position is var_name: this atom carries no usable
|
|
799
|
+
# constraint yet (rel(x,x) self-pairs, or both ends still unbound).
|
|
800
|
+
# arity 0 or > 2: not a per-individual membership constraint, skip.
|
|
801
|
+
if found is None:
|
|
802
|
+
return structure.domain
|
|
803
|
+
return tuple(d for d in structure.domain if d in found)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _peel_exists_chain(node: Quantifier) -> Tuple[List[str], Node]:
|
|
807
|
+
"""Peel a maximal run of adjacent ``∃`` layers starting at ``node`` (which
|
|
808
|
+
must itself be existential); return the bound variable names in outer-to-
|
|
809
|
+
inner order and the innermost non-quantifier matrix."""
|
|
810
|
+
names = [node.variable.name]
|
|
811
|
+
body = node.formula
|
|
812
|
+
while isinstance(body, Quantifier) and body.type in _EXISTS:
|
|
813
|
+
names.append(body.variable.name)
|
|
814
|
+
body = body.formula
|
|
815
|
+
return names, body
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _pick_most_constrained(
|
|
819
|
+
remaining: List[str], matrix: Node, structure: FiniteStructure,
|
|
820
|
+
assignment: Assignment, bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
|
|
821
|
+
) -> Tuple[str, Tuple[Individual, ...]]:
|
|
822
|
+
"""The most-constrained-variable heuristic: among ``remaining``, the
|
|
823
|
+
variable whose CURRENT candidate set (given what is bound so far) is
|
|
824
|
+
smallest — recomputed at every call, since a bond-neighbour constraint
|
|
825
|
+
only becomes usable once the other end of the bond is bound. Ties keep
|
|
826
|
+
the first (leftmost) variable with the smallest set seen so far."""
|
|
827
|
+
best_name, best_cands = None, None
|
|
828
|
+
for name in remaining:
|
|
829
|
+
cands = _variable_candidates(name, matrix, structure, assignment, ctx)
|
|
830
|
+
if ctx.all_different:
|
|
831
|
+
cands = tuple(c for c in cands if c not in bound_existentials)
|
|
832
|
+
if best_cands is None or len(cands) < len(best_cands):
|
|
833
|
+
best_name, best_cands = name, cands
|
|
834
|
+
if not best_cands:
|
|
835
|
+
break # an empty candidate set cannot be beaten
|
|
836
|
+
return best_name, best_cands
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _search_exists(
|
|
840
|
+
remaining: List[str], matrix: Node, structure: FiniteStructure,
|
|
841
|
+
assignment: Assignment, bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
|
|
842
|
+
) -> bool:
|
|
843
|
+
"""Backtracking search for a satisfying binding of ``remaining`` existential
|
|
844
|
+
variables over ``matrix``, dynamically reordered by
|
|
845
|
+
:func:`_pick_most_constrained` at every step (design decision #2)."""
|
|
846
|
+
ctx.tick()
|
|
847
|
+
if not remaining:
|
|
848
|
+
return _eval(matrix, structure, assignment, bound_existentials, ctx)
|
|
849
|
+
name, cands = _pick_most_constrained(
|
|
850
|
+
remaining, matrix, structure, assignment, bound_existentials, ctx)
|
|
851
|
+
# Remove exactly the ONE occurrence of `name` that was just picked, not
|
|
852
|
+
# every occurrence of that name — a chain can reuse a variable name
|
|
853
|
+
# across layers (`∃x∃x∃x φ`, each layer legitimately shadowing the last),
|
|
854
|
+
# and `[n for n in remaining if n != name]` used to drop all of them at
|
|
855
|
+
# once, collapsing an N-deep same-named chain into a single quantifier
|
|
856
|
+
# (see the module docstring's `all_different` bullet: this used to let
|
|
857
|
+
# `all_different=True` silently require only 1 witness instead of N).
|
|
858
|
+
# `_pick_most_constrained` always resolves ties by keeping the FIRST
|
|
859
|
+
# (leftmost) `remaining` entry with the smallest candidate set, and two
|
|
860
|
+
# occurrences of the same name always have IDENTICAL candidate sets at
|
|
861
|
+
# this point (same matrix, same assignment, same bound_existentials), so
|
|
862
|
+
# the leftmost remaining occurrence of `name` is always the one that was
|
|
863
|
+
# just picked — exactly what `list.remove` (first-occurrence) deletes.
|
|
864
|
+
rest = list(remaining)
|
|
865
|
+
rest.remove(name)
|
|
866
|
+
for c in cands:
|
|
867
|
+
if _search_exists(rest, matrix, structure, _extend(assignment, name, c),
|
|
868
|
+
bound_existentials | {c}, ctx):
|
|
869
|
+
return True
|
|
870
|
+
return False
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _search_exists_witness(
|
|
874
|
+
remaining: List[str], matrix: Node, structure: FiniteStructure,
|
|
875
|
+
assignment: Assignment, bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
|
|
876
|
+
) -> Optional[Dict[str, Individual]]:
|
|
877
|
+
"""Like :func:`_search_exists` but returns the satisfying binding (or
|
|
878
|
+
``None``) instead of just whether one exists — used by :func:`_find_witness`."""
|
|
879
|
+
ctx.tick()
|
|
880
|
+
if not remaining:
|
|
881
|
+
return {} if _eval(matrix, structure, assignment, bound_existentials, ctx) else None
|
|
882
|
+
name, cands = _pick_most_constrained(
|
|
883
|
+
remaining, matrix, structure, assignment, bound_existentials, ctx)
|
|
884
|
+
# See the identical `rest` line in _search_exists just above for why this
|
|
885
|
+
# must remove exactly one (the first-occurring, i.e. just-picked)
|
|
886
|
+
# occurrence of `name` rather than every occurrence of that name.
|
|
887
|
+
rest = list(remaining)
|
|
888
|
+
rest.remove(name)
|
|
889
|
+
for c in cands:
|
|
890
|
+
sub = _search_exists_witness(rest, matrix, structure, _extend(assignment, name, c),
|
|
891
|
+
bound_existentials | {c}, ctx)
|
|
892
|
+
if sub is not None:
|
|
893
|
+
merged = {name: c}
|
|
894
|
+
merged.update(sub)
|
|
895
|
+
return merged
|
|
896
|
+
return None
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
# ---------------------------------------------------------------------------
|
|
900
|
+
# Quantifier / Count evaluation
|
|
901
|
+
# ---------------------------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
def _eval_quantifier(
|
|
904
|
+
node: Quantifier, structure: FiniteStructure, assignment: Assignment,
|
|
905
|
+
bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
|
|
906
|
+
) -> bool:
|
|
907
|
+
if node.type in _FORALL:
|
|
908
|
+
# ∀ iterates the whole domain — see the module docstring: this
|
|
909
|
+
# evaluator's optimisations target ∃/Count (the bottlenecks that
|
|
910
|
+
# actually bite), not ∀, which has no analogous candidate-narrowing
|
|
911
|
+
# opportunity without also inspecting the (possibly absent) guard
|
|
912
|
+
# implicit in an implication body.
|
|
913
|
+
name = node.variable.name
|
|
914
|
+
for d in structure.domain:
|
|
915
|
+
if not _eval(node.formula, structure, _extend(assignment, name, d),
|
|
916
|
+
bound_existentials, ctx):
|
|
917
|
+
return False
|
|
918
|
+
return True
|
|
919
|
+
if node.type in _EXISTS:
|
|
920
|
+
names, matrix = _peel_exists_chain(node)
|
|
921
|
+
return _search_exists(names, matrix, structure, assignment, bound_existentials, ctx)
|
|
922
|
+
raise ValueError(f"model_eval: unknown quantifier type {node.type!r}")
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _eval_count(
|
|
926
|
+
node: Count, structure: FiniteStructure, assignment: Assignment,
|
|
927
|
+
bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
|
|
928
|
+
) -> bool:
|
|
929
|
+
"""∃≥n / ∃≤n / ∃=n, counted directly over the (index-narrowed) candidate
|
|
930
|
+
set for the bound variable — see design decision #3 in the module
|
|
931
|
+
docstring. ``all_different`` does not apply here: n distinct witnesses are
|
|
932
|
+
already what ``Count`` means, independent of that flag."""
|
|
933
|
+
name = node.variable.name
|
|
934
|
+
n = node.n.value
|
|
935
|
+
cands = _variable_candidates(name, node.formula, structure, assignment, ctx)
|
|
936
|
+
count = 0
|
|
937
|
+
for d in cands:
|
|
938
|
+
if _eval(node.formula, structure, _extend(assignment, name, d), bound_existentials, ctx):
|
|
939
|
+
count += 1
|
|
940
|
+
if node.op == "ge" and count >= n:
|
|
941
|
+
return True
|
|
942
|
+
if node.op in ("le", "eq") and count > n:
|
|
943
|
+
return False
|
|
944
|
+
if node.op == "ge":
|
|
945
|
+
return count >= n
|
|
946
|
+
if node.op == "le":
|
|
947
|
+
return count <= n
|
|
948
|
+
if node.op == "eq":
|
|
949
|
+
return count == n
|
|
950
|
+
raise ValueError(f"model_eval: unknown Count op {node.op!r}")
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
# ---------------------------------------------------------------------------
|
|
954
|
+
# Core recursive evaluator
|
|
955
|
+
# ---------------------------------------------------------------------------
|
|
956
|
+
|
|
957
|
+
def _eval(
|
|
958
|
+
node: Node, structure: FiniteStructure, assignment: Assignment,
|
|
959
|
+
bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
|
|
960
|
+
) -> bool:
|
|
961
|
+
"""Evaluate ``node`` directly against ``structure`` — the single
|
|
962
|
+
recursive engine behind :func:`evaluate`/:func:`evaluate_detailed`. One
|
|
963
|
+
step is charged per call (see ``_Ctx.tick``, which may raise the internal
|
|
964
|
+
``_BudgetHit`` signal). ``And``/``Or`` short-circuit via Python's own
|
|
965
|
+
``and``/``or`` on the two recursive calls; ``Implies`` short-circuits
|
|
966
|
+
explicitly on a false antecedent."""
|
|
967
|
+
ctx.tick()
|
|
968
|
+
if isinstance(node, Atom):
|
|
969
|
+
return _atom_value(node, structure, assignment, bound_existentials, ctx)
|
|
970
|
+
if isinstance(node, Not):
|
|
971
|
+
return not _eval(node.formula, structure, assignment, bound_existentials, ctx)
|
|
972
|
+
if isinstance(node, (And, Contrast)):
|
|
973
|
+
# Contrast (``P Ⓒ Q``, whereas/although/but) is truth-functionally
|
|
974
|
+
# conjunction — concession is a DISCOURSE relation, not a
|
|
975
|
+
# truth-functional one. The node exists so a front-end can keep the
|
|
976
|
+
# contrast instead of flattening it to ∧, and every export already
|
|
977
|
+
# treats it as ∧; evaluating it any other way here would invent a
|
|
978
|
+
# semantics the node's own contract denies.
|
|
979
|
+
return (_eval(node.left, structure, assignment, bound_existentials, ctx)
|
|
980
|
+
and _eval(node.right, structure, assignment, bound_existentials, ctx))
|
|
981
|
+
if isinstance(node, Or):
|
|
982
|
+
return (_eval(node.left, structure, assignment, bound_existentials, ctx)
|
|
983
|
+
or _eval(node.right, structure, assignment, bound_existentials, ctx))
|
|
984
|
+
if isinstance(node, Xor):
|
|
985
|
+
return (_eval(node.left, structure, assignment, bound_existentials, ctx)
|
|
986
|
+
!= _eval(node.right, structure, assignment, bound_existentials, ctx))
|
|
987
|
+
if isinstance(node, Implies):
|
|
988
|
+
if not _eval(node.left, structure, assignment, bound_existentials, ctx):
|
|
989
|
+
return True
|
|
990
|
+
return _eval(node.right, structure, assignment, bound_existentials, ctx)
|
|
991
|
+
if isinstance(node, Iff):
|
|
992
|
+
return (_eval(node.left, structure, assignment, bound_existentials, ctx)
|
|
993
|
+
== _eval(node.right, structure, assignment, bound_existentials, ctx))
|
|
994
|
+
if isinstance(node, Quantifier):
|
|
995
|
+
return _eval_quantifier(node, structure, assignment, bound_existentials, ctx)
|
|
996
|
+
if isinstance(node, Count):
|
|
997
|
+
return _eval_count(node, structure, assignment, bound_existentials, ctx)
|
|
998
|
+
raise UnsupportedNode(
|
|
999
|
+
f"model_eval: node type {type(node).__name__} is not supported by direct "
|
|
1000
|
+
"structural evaluation — only Atom/Not/And/Or/Xor/Implies/Iff/Quantifier/"
|
|
1001
|
+
"Count are (see the module docstring's 'Supported nodes'). Modal, "
|
|
1002
|
+
"lambda, fuzzy, second-order, sorted, and other MSFL/kit extensions are "
|
|
1003
|
+
"out of scope here; use the dedicated evaluator for that fragment."
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
# ---------------------------------------------------------------------------
|
|
1008
|
+
# Explanations (best-effort — see the module docstring)
|
|
1009
|
+
# ---------------------------------------------------------------------------
|
|
1010
|
+
|
|
1011
|
+
def _find_witness(
|
|
1012
|
+
node: Node, structure: FiniteStructure, assignment: Assignment,
|
|
1013
|
+
bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
|
|
1014
|
+
) -> Dict[str, Individual]:
|
|
1015
|
+
"""Best-effort variable→individual bindings that make a TRUE ``node``
|
|
1016
|
+
true — see the module docstring's "witness" bullet for exactly which
|
|
1017
|
+
node shapes contribute bindings."""
|
|
1018
|
+
ctx.tick()
|
|
1019
|
+
if isinstance(node, And):
|
|
1020
|
+
w = _find_witness(node.left, structure, assignment, bound_existentials, ctx)
|
|
1021
|
+
w2 = _find_witness(node.right, structure, assignment, bound_existentials, ctx)
|
|
1022
|
+
merged = dict(w)
|
|
1023
|
+
merged.update(w2)
|
|
1024
|
+
return merged
|
|
1025
|
+
if isinstance(node, Or):
|
|
1026
|
+
if _eval(node.left, structure, assignment, bound_existentials, ctx):
|
|
1027
|
+
return _find_witness(node.left, structure, assignment, bound_existentials, ctx)
|
|
1028
|
+
return _find_witness(node.right, structure, assignment, bound_existentials, ctx)
|
|
1029
|
+
if isinstance(node, Quantifier) and node.type in _EXISTS:
|
|
1030
|
+
names, matrix = _peel_exists_chain(node)
|
|
1031
|
+
binding = _search_exists_witness(names, matrix, structure, assignment,
|
|
1032
|
+
bound_existentials, ctx)
|
|
1033
|
+
if binding is None:
|
|
1034
|
+
return {} # defensive: should not happen if node evaluated True
|
|
1035
|
+
new_assignment = dict(assignment)
|
|
1036
|
+
new_assignment.update(binding)
|
|
1037
|
+
new_bound = bound_existentials | set(binding.values())
|
|
1038
|
+
w = dict(binding)
|
|
1039
|
+
w.update(_find_witness(matrix, structure, new_assignment, new_bound, ctx))
|
|
1040
|
+
return w
|
|
1041
|
+
return {} # Not / ∀ / Count / Implies / Iff / Xor / Atom: no natural witness
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _find_blame(
|
|
1045
|
+
node: Node, structure: FiniteStructure, assignment: Assignment,
|
|
1046
|
+
bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
|
|
1047
|
+
) -> Node:
|
|
1048
|
+
"""Drill through a spine of nested ``And`` (left-to-right, the same
|
|
1049
|
+
short-circuit order evaluation used) to the first conjunct that is
|
|
1050
|
+
actually false; any other node type is returned as itself (not
|
|
1051
|
+
decomposed — see the module docstring's "failing_conjunct" bullet)."""
|
|
1052
|
+
ctx.tick()
|
|
1053
|
+
if isinstance(node, And):
|
|
1054
|
+
if not _eval(node.left, structure, assignment, bound_existentials, ctx):
|
|
1055
|
+
return _find_blame(node.left, structure, assignment, bound_existentials, ctx)
|
|
1056
|
+
return _find_blame(node.right, structure, assignment, bound_existentials, ctx)
|
|
1057
|
+
return node
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
# ---------------------------------------------------------------------------
|
|
1061
|
+
# Public API
|
|
1062
|
+
# ---------------------------------------------------------------------------
|
|
1063
|
+
|
|
1064
|
+
def evaluate_detailed(
|
|
1065
|
+
formula: Node, structure: FiniteStructure, *,
|
|
1066
|
+
all_different: bool = False,
|
|
1067
|
+
assignment: Optional[Assignment] = None,
|
|
1068
|
+
budget: Optional[int] = None,
|
|
1069
|
+
) -> EvalResult:
|
|
1070
|
+
"""Evaluate ``formula`` against ``structure`` and return a full
|
|
1071
|
+
:class:`EvalResult` (three-valued: ``holds`` is ``None`` iff the budget
|
|
1072
|
+
was exhausted before a definitive answer). See the module docstring for
|
|
1073
|
+
the semantics of ``all_different``, the budget contract, and exactly what
|
|
1074
|
+
``witness``/``failing_conjunct`` can and cannot explain.
|
|
1075
|
+
|
|
1076
|
+
Raises:
|
|
1077
|
+
UninterpretedSymbol: ``formula`` mentions a predicate/constant
|
|
1078
|
+
``structure`` does not interpret.
|
|
1079
|
+
UnsupportedNode: ``formula`` contains a node type outside this
|
|
1080
|
+
evaluator's classical first-order fragment.
|
|
1081
|
+
ValueError: ``formula`` has a free variable not covered by
|
|
1082
|
+
``assignment``.
|
|
1083
|
+
"""
|
|
1084
|
+
env: Dict[str, Individual] = dict(assignment) if assignment else {}
|
|
1085
|
+
ctx = _Ctx(budget, all_different)
|
|
1086
|
+
try:
|
|
1087
|
+
holds = _eval(formula, structure, env, frozenset(), ctx)
|
|
1088
|
+
except _BudgetHit:
|
|
1089
|
+
return EvalResult(holds=None, witness=None, failing_conjunct=None,
|
|
1090
|
+
steps=ctx.steps, exhausted=True)
|
|
1091
|
+
|
|
1092
|
+
# The core answer is already decided at this point; witness/failing_conjunct
|
|
1093
|
+
# extraction is a secondary, best-effort pass that reuses ctx's step budget
|
|
1094
|
+
# but — deliberately — cannot retroactively turn a decided answer back into
|
|
1095
|
+
# "exhausted": running out here just means the explanation is dropped.
|
|
1096
|
+
try:
|
|
1097
|
+
if holds:
|
|
1098
|
+
witness = _find_witness(formula, structure, env, frozenset(), ctx)
|
|
1099
|
+
failing = None
|
|
1100
|
+
else:
|
|
1101
|
+
witness = None
|
|
1102
|
+
failing = _find_blame(formula, structure, env, frozenset(), ctx)
|
|
1103
|
+
except _BudgetHit:
|
|
1104
|
+
witness = None
|
|
1105
|
+
failing = None
|
|
1106
|
+
|
|
1107
|
+
return EvalResult(holds=holds, witness=witness, failing_conjunct=failing,
|
|
1108
|
+
steps=ctx.steps, exhausted=False)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def evaluate(
|
|
1112
|
+
formula: Node, structure: FiniteStructure, *,
|
|
1113
|
+
all_different: bool = False,
|
|
1114
|
+
assignment: Optional[Assignment] = None,
|
|
1115
|
+
budget: Optional[int] = None,
|
|
1116
|
+
) -> bool:
|
|
1117
|
+
"""Boolean-only convenience wrapper around :func:`evaluate_detailed`.
|
|
1118
|
+
|
|
1119
|
+
Raises :class:`BudgetExhausted` if ``budget`` runs out before a
|
|
1120
|
+
definitive answer — see the module docstring's budget contract for why
|
|
1121
|
+
this cannot simply return ``False``. Same other exceptions as
|
|
1122
|
+
:func:`evaluate_detailed`.
|
|
1123
|
+
"""
|
|
1124
|
+
result = evaluate_detailed(formula, structure, all_different=all_different,
|
|
1125
|
+
assignment=assignment, budget=budget)
|
|
1126
|
+
if result.exhausted:
|
|
1127
|
+
raise BudgetExhausted(
|
|
1128
|
+
f"model_eval.evaluate: budget of {budget} steps was exhausted after "
|
|
1129
|
+
f"{result.steps} steps before a definitive truth value could be "
|
|
1130
|
+
"established — the result is UNKNOWN, not False. Raise `budget`, or "
|
|
1131
|
+
"call evaluate_detailed() to receive the three-valued EvalResult "
|
|
1132
|
+
"instead of an exception.",
|
|
1133
|
+
steps=result.steps,
|
|
1134
|
+
)
|
|
1135
|
+
return result.holds
|