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,686 @@
|
|
|
1
|
+
"""Sato distribution semantics — the ProbLog core, exact, no sampling.
|
|
2
|
+
|
|
3
|
+
Sato (*A Statistical Learning Method for Logic Programs*, ICLP 1995) and
|
|
4
|
+
ProbLog (De Raedt, Kimmig & Toivonen, IJCAI 2007) attach an independent
|
|
5
|
+
probability to each of a finite set of GROUND probabilistic facts and read off
|
|
6
|
+
the probability of a query as a sum over "total choices". Concretely: a
|
|
7
|
+
:class:`ProbProgram` is a set of :class:`ProbFact`\\ s (each an independent
|
|
8
|
+
Bernoulli-distributed ground atom), a set of definite-clause ``rules``, and a
|
|
9
|
+
set of deterministic ``hard_facts``. A **total choice** ``C`` is a subset of
|
|
10
|
+
the probabilistic facts (the ones chosen "true"; the rest are "false"); it has
|
|
11
|
+
weight ``Π_{f∈C} p_f · Π_{f∉C} (1−p_f)`` — the probability, under mutual
|
|
12
|
+
independence, that exactly this subset comes out true. Every total choice,
|
|
13
|
+
together with ``hard_facts`` and ``rules``, is a plain (non-probabilistic)
|
|
14
|
+
DEFINITE logic program, which — being definite (no negation, no disjunctive
|
|
15
|
+
heads) — has a UNIQUE least Herbrand model, computed here by straightforward
|
|
16
|
+
forward-chaining (no stratification needed: definite programs are always
|
|
17
|
+
stratified trivially). :func:`query`'s result is the sum, over every total
|
|
18
|
+
choice whose least model satisfies the goal, of that choice's weight — exactly,
|
|
19
|
+
in :class:`~fractions.Fraction` arithmetic throughout (never sampled, never a
|
|
20
|
+
float): with ``k`` probabilistic facts this sums ``2^k`` exact weights, not an
|
|
21
|
+
approximation of them.
|
|
22
|
+
|
|
23
|
+
**Rule language.** A rule is Kit-AST shaped as either a bare ground
|
|
24
|
+
:class:`~unicode_logic_kit.fol.nodes.Atom` (a fact with an implicitly-true empty
|
|
25
|
+
body), or a (possibly nested) ``∀``-quantification wrapping
|
|
26
|
+
``Implies(Body, Head)`` where ``Head`` is a single positive atom and ``Body``
|
|
27
|
+
is a conjunction of positive atoms. Negation anywhere, a non-atomic or
|
|
28
|
+
disjunctive head, an existential quantifier, or a variable used in the clause
|
|
29
|
+
but not bound by an enclosing ``∀`` are all refused with ``ValueError`` — this
|
|
30
|
+
is exactly the DEFINITE-clause fragment distribution semantics is defined
|
|
31
|
+
over; anything outside it has no unique least model to speak of, so it is
|
|
32
|
+
rejected loudly rather than silently coerced. ``hard_facts`` accepts the same
|
|
33
|
+
two shapes (ground atom, or ∀-quantified definite clause) — they differ from
|
|
34
|
+
``rules`` only in being unconditionally available in EVERY total choice's
|
|
35
|
+
program, never gated by a probabilistic fact's inclusion.
|
|
36
|
+
|
|
37
|
+
**Grounding.** Rules are grounded over the FINITE set of constants occurring
|
|
38
|
+
anywhere in the program (facts, rules, hard_facts) and the goal — the
|
|
39
|
+
"Herbrand universe" is deliberately this finite, program-derived set, not an
|
|
40
|
+
open-ended one, so grounding always terminates.
|
|
41
|
+
|
|
42
|
+
**Goal language and CLOSED-WORLD NEGATION.** A goal is a ground literal, or a
|
|
43
|
+
conjunction/disjunction/negation of such — **NEGATION IS EVALUATED AGAINST THE
|
|
44
|
+
LEAST HERBRAND MODEL UNDER THE CLOSED-WORLD ASSUMPTION**: ``¬p`` is true in a
|
|
45
|
+
given total choice's model iff ``p`` is NOT a member of that model's (unique,
|
|
46
|
+
forward-chained) least fixpoint. This is the standard ProbLog convention, not
|
|
47
|
+
a general negation-as-failure over a possibly-non-stratified program (definite
|
|
48
|
+
programs need none of that machinery — the least model is unique regardless of
|
|
49
|
+
which atoms end up outside it). ``∀``/``∃`` in the goal are expanded, before
|
|
50
|
+
evaluation, into a finite ``∧``/``∨`` over the same finite constant domain
|
|
51
|
+
used for grounding rules.
|
|
52
|
+
|
|
53
|
+
**Truth constants.** ``⊤`` (``$true``) and ``⊥`` (``$false``) are the constants
|
|
54
|
+
true and false, not atoms that a rule has to derive: they have that value in
|
|
55
|
+
every total choice (:func:`~unicode_logic_kit.fol._truth_constants.truth_value`),
|
|
56
|
+
so ``P(⊤) = 1``, ``P(¬⊤) = 0`` and ``P(Rain ∧ ⊤) = P(Rain)``. In a rule body
|
|
57
|
+
``⊤`` is a conjunct that always holds and ``⊥`` makes the clause never fire. As
|
|
58
|
+
the head of a clause, as a hard fact or as a :class:`ProbFact` a constant is
|
|
59
|
+
refused by name: it derives nothing, or (``⊥``) is an integrity constraint that a
|
|
60
|
+
definite program has no least model for.
|
|
61
|
+
|
|
62
|
+
**Correctness-preserving pruning (``prune=True``, the default).** Only
|
|
63
|
+
probabilistic facts that lie in the goal's GROUND DEPENDENCY CONE — reachable
|
|
64
|
+
by walking grounded-rule body→head edges backward from a goal atom — can
|
|
65
|
+
possibly change whether that goal atom is derivable; every other fact
|
|
66
|
+
marginalises to a factor of 1 (its own probability mass sums away: ``p_f +
|
|
67
|
+
(1−p_f) = 1``) and is simply never enumerated, which is why omitting it from
|
|
68
|
+
both the choice enumeration AND the seed set of the least-model computation
|
|
69
|
+
never changes the result. This is the whole of the optimization: it narrows
|
|
70
|
+
which facts are enumerated as 2^k choices, never which RULES fire during
|
|
71
|
+
forward chaining. ``max_choice_facts`` (default 16, i.e. up to 65536 choices)
|
|
72
|
+
is a deliberate, overridable brake on the ``2^k`` enumeration over the
|
|
73
|
+
(pruned, if ``prune=True``) relevant fact set.
|
|
74
|
+
|
|
75
|
+
**A second evaluation route (``method="compile"``).** :func:`query` takes a
|
|
76
|
+
``method`` keyword, ``"enumerate"`` (the default; the ``2^k`` total-choice sum
|
|
77
|
+
described above, UNCHANGED) or ``"compile"`` — a second, algorithmically
|
|
78
|
+
DISTINCT but semantically IDENTICAL route that never materialises a total
|
|
79
|
+
choice at all. It reuses the exact same grounding, pruning and least-model
|
|
80
|
+
machinery, but replaces the Boolean least-fixpoint with a BDD-VALUED one over
|
|
81
|
+
:class:`~unicode_logic_kit.prob._bdd.BDDManager`: each derived ground atom gets
|
|
82
|
+
a canonical Boolean FUNCTION of the relevant facts (OR, over every grounded
|
|
83
|
+
rule deriving it, of the AND of that rule's body atoms' current functions),
|
|
84
|
+
iterated to a fixpoint by the same monotone-lattice argument as the
|
|
85
|
+
enumeration route's while-loop — now over canonical BDD node identity rather
|
|
86
|
+
than Python-set membership. The goal is composed over these atom-functions
|
|
87
|
+
with the identical ∧/∨/¬ structure :func:`_eval_goal` uses, and the single
|
|
88
|
+
resulting root is weighted-model-counted bottom-up (each SHARED node priced
|
|
89
|
+
once), which is why a chain/tree/diamond-shaped program collapses to
|
|
90
|
+
``O(#BDD nodes)`` under ``method="compile"`` instead of the ``O(2^k)`` weights
|
|
91
|
+
the enumeration route sums for the same answer. Both routes MUST agree, on
|
|
92
|
+
every input, exactly (never a float, never a tolerance) — that identity is
|
|
93
|
+
the specification this module holds itself to, and the test suite tests it
|
|
94
|
+
directly. ``method="compile"`` is bounded by its own brake, ``max_bdd_nodes``
|
|
95
|
+
(default 100 000, ``max_choice_facts`` does not apply to it): weighted model
|
|
96
|
+
counting is #P-hard, so worst-case Boolean functions still have exponential
|
|
97
|
+
ROBDDs under any fixed variable order — this path still refuses loudly rather
|
|
98
|
+
than silently degrading, preserving the kit-wide rule, and can in fact refuse
|
|
99
|
+
programs the enumeration route would ALSO refuse (just via a different brake,
|
|
100
|
+
since the two routes' costs scale with different things) as well as accept
|
|
101
|
+
programs (many shared sub-derivations) the enumeration route's
|
|
102
|
+
``max_choice_facts`` brake alone would never let through.
|
|
103
|
+
|
|
104
|
+
Public API: :class:`ProbFact`, :class:`ProbProgram`, :func:`query`.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
from dataclasses import dataclass
|
|
108
|
+
from fractions import Fraction
|
|
109
|
+
from itertools import product
|
|
110
|
+
from typing import Dict, List, Literal, Sequence, Set, Tuple
|
|
111
|
+
|
|
112
|
+
from ..fol.nodes import (
|
|
113
|
+
Node, Atom, Not, And, Or, Implies, Quantifier, Variable, Constant, substitute,
|
|
114
|
+
)
|
|
115
|
+
from ..fol._atom_keys import AtomKeys
|
|
116
|
+
from ..fol._msfl_nodes import key_text
|
|
117
|
+
from ..fol._truth_constants import truth_value
|
|
118
|
+
from ._bdd import BDDManager, weighted_model_count, FALSE as _BDD_FALSE, TRUE as _BDD_TRUE
|
|
119
|
+
|
|
120
|
+
__all__ = ["ProbFact", "ProbProgram", "query"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Exact-rational helper (mirrors nilsson._as_exact_prob; kept local so this
|
|
125
|
+
# module has no dependency on its sibling).
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _as_exact_prob(value, label: str) -> Fraction:
|
|
129
|
+
"""Coerce ``value`` to an exact Fraction in [0, 1]; reject float (never sampled/rounded)."""
|
|
130
|
+
if isinstance(value, bool) or not isinstance(value, (Fraction, int)):
|
|
131
|
+
raise TypeError(
|
|
132
|
+
f"ProbFact: {label} must be an exact Fraction (or int, coerced losslessly) "
|
|
133
|
+
f"— got {type(value).__name__} {value!r}. Pass Fraction({label}) explicitly; "
|
|
134
|
+
"a float is refused because this module never approximates a probability."
|
|
135
|
+
)
|
|
136
|
+
p = Fraction(value)
|
|
137
|
+
if not (Fraction(0) <= p <= Fraction(1)):
|
|
138
|
+
raise ValueError(f"ProbFact: {label} must satisfy 0 <= {label} <= 1; got {p}.")
|
|
139
|
+
return p
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# ProbFact / ProbProgram
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class ProbFact:
|
|
148
|
+
"""One independent Bernoulli-distributed GROUND atom: ``atom`` is true with probability ``prob``.
|
|
149
|
+
|
|
150
|
+
``atom`` must be a ground :class:`~unicode_logic_kit.fol.nodes.Atom` (no
|
|
151
|
+
:class:`~unicode_logic_kit.fol.nodes.Variable` anywhere in its arguments) —
|
|
152
|
+
distribution semantics assigns a probability to one concrete ground fact,
|
|
153
|
+
not a schema; a parametrised probability needs a family of ``ProbFact``\\ s
|
|
154
|
+
(one per grounding) or a deterministic ``∀``-rule deriving the pattern from
|
|
155
|
+
such a family. ``prob`` is coerced to an exact ``Fraction`` (float rejected;
|
|
156
|
+
see :func:`_as_exact_prob`).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
atom: Node
|
|
160
|
+
prob: Fraction
|
|
161
|
+
|
|
162
|
+
def __post_init__(self):
|
|
163
|
+
if not isinstance(self.atom, Atom):
|
|
164
|
+
raise ValueError(f"ProbFact: atom must be an Atom, got {type(self.atom).__name__}.")
|
|
165
|
+
if truth_value(self.atom) is not None:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"ProbFact: {self.atom.to_unicode_str()!r} is a truth constant, which is "
|
|
168
|
+
"true (or false) in every total choice and so has no probability of its own; "
|
|
169
|
+
"a probabilistic fact is a ground atom of the program."
|
|
170
|
+
)
|
|
171
|
+
if self.atom.variables():
|
|
172
|
+
names = sorted(v.name for v in self.atom.variables())
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"ProbFact: atom {self.atom.to_unicode_str()!r} is not ground (free "
|
|
175
|
+
f"variable(s) {names}) — a ProbFact assigns a probability to ONE ground "
|
|
176
|
+
"atom, not a schema; ground it, or express the pattern with a "
|
|
177
|
+
"deterministic ∀-quantified rule over a family of ground ProbFacts."
|
|
178
|
+
)
|
|
179
|
+
object.__setattr__(self, "prob", _as_exact_prob(self.prob, "prob"))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class ProbProgram:
|
|
184
|
+
"""A distribution-semantics program: probabilistic facts, definite rules, hard facts.
|
|
185
|
+
|
|
186
|
+
``facts`` are the independent :class:`ProbFact`\\ s whose total choices
|
|
187
|
+
define the distribution. ``rules`` and ``hard_facts`` both hold definite
|
|
188
|
+
clauses in the shape documented in the module docstring (ground atom, or
|
|
189
|
+
∀-quantified ``Implies(Body, Head)``); ``hard_facts`` are simply always
|
|
190
|
+
available (in every total choice), while ``rules`` are as well — the two
|
|
191
|
+
fields are one combined rule set for forward chaining, kept separate only
|
|
192
|
+
so callers can organise "background theory" apart from the rules that
|
|
193
|
+
mediate between probabilistic facts and derived predicates. Malformed
|
|
194
|
+
rules/hard_facts raise ``ValueError`` eagerly, at construction time.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
facts: Sequence[ProbFact]
|
|
198
|
+
rules: Sequence[Node]
|
|
199
|
+
hard_facts: Sequence[Node] = ()
|
|
200
|
+
|
|
201
|
+
def __post_init__(self):
|
|
202
|
+
object.__setattr__(self, "facts", tuple(self.facts))
|
|
203
|
+
object.__setattr__(self, "rules", tuple(self.rules))
|
|
204
|
+
object.__setattr__(self, "hard_facts", tuple(self.hard_facts))
|
|
205
|
+
for f in self.facts:
|
|
206
|
+
if not isinstance(f, ProbFact):
|
|
207
|
+
raise ValueError(f"ProbProgram: facts must be ProbFact, got {type(f).__name__}.")
|
|
208
|
+
for r in self.rules:
|
|
209
|
+
_decompose_rule(r, "rule")
|
|
210
|
+
for hf in self.hard_facts:
|
|
211
|
+
_decompose_rule(hf, "hard fact")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Definite-clause validation
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def _refuse_truth_constant_head(atom: Atom, kind: str) -> None:
|
|
219
|
+
"""Refuse a clause whose head (or whose bare fact) is a truth constant, by name.
|
|
220
|
+
|
|
221
|
+
A definite clause derives an atom. ``⊤`` is true in every total choice, so
|
|
222
|
+
deriving it adds nothing, and ``⊥`` as a head is an integrity constraint
|
|
223
|
+
(``Body → ⊥`` says the body never holds), which a definite program has no
|
|
224
|
+
least model for: this module has no conditioning on evidence, so it refuses
|
|
225
|
+
both rather than read one as an atom named ``⊥``. A truth constant in a
|
|
226
|
+
BODY is fine: it is read as the value it is (see :func:`_ground_definite_clauses`).
|
|
227
|
+
"""
|
|
228
|
+
if truth_value(atom) is not None:
|
|
229
|
+
raise ValueError(
|
|
230
|
+
f"ProbProgram: {kind} has the truth constant {atom.to_unicode_str()!r} as its "
|
|
231
|
+
"head (or is that constant): a definite clause derives an atom, and a truth "
|
|
232
|
+
"constant is true (or false) in every total choice -- deriving it adds nothing, "
|
|
233
|
+
"and a clause that derives falsity is an integrity constraint this module does "
|
|
234
|
+
"not have. Leave the clause out, or use the constant only in a body."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _decompose_rule(node: Node, kind: str) -> Tuple[Tuple[Atom, ...], Atom, Tuple[str, ...]]:
|
|
239
|
+
"""Validate ``node`` as a definite clause; return ``(body_atoms, head, bound_var_names)``.
|
|
240
|
+
|
|
241
|
+
Accepts exactly two shapes: a bare ground-or-not Atom (empty body,
|
|
242
|
+
``bound_var_names`` from any variables it uses is checked to be empty —
|
|
243
|
+
plain atoms are never quantifier-wrapped in this grammar), or a chain of
|
|
244
|
+
one-or-more ``∀`` quantifiers directly wrapping ``Implies(Body, Head)``
|
|
245
|
+
with ``Head`` a single positive Atom and ``Body`` a conjunction of
|
|
246
|
+
positive atoms. Anything else — negation anywhere, a disjunctive/negated
|
|
247
|
+
head, an existential in the chain, or a variable used but not bound by the
|
|
248
|
+
chain — raises ``ValueError`` naming ``kind`` (``"rule"`` / ``"hard fact"``).
|
|
249
|
+
"""
|
|
250
|
+
if isinstance(node, Atom):
|
|
251
|
+
_refuse_truth_constant_head(node, kind)
|
|
252
|
+
if node.variables():
|
|
253
|
+
names = sorted(v.name for v in node.variables())
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"ProbProgram: {kind} {node.to_unicode_str()!r} is a bare atom with free "
|
|
256
|
+
f"variable(s) {names}; a variable must be bound by an enclosing ∀ over an "
|
|
257
|
+
"Implies(Body, Head) clause, or the atom must be ground."
|
|
258
|
+
)
|
|
259
|
+
return (), node, ()
|
|
260
|
+
|
|
261
|
+
bound: Set[str] = set()
|
|
262
|
+
cur = node
|
|
263
|
+
while isinstance(cur, Quantifier):
|
|
264
|
+
if cur.type not in ("forall", "∀"):
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"ProbProgram: {kind} uses a non-universal quantifier {cur.type!r}; only "
|
|
267
|
+
"ground atoms or ∀-quantified definite clauses are accepted (no ∃ — "
|
|
268
|
+
"distribution semantics is defined over DEFINITE clauses only)."
|
|
269
|
+
)
|
|
270
|
+
bound.add(cur.variable.name)
|
|
271
|
+
cur = cur.formula
|
|
272
|
+
|
|
273
|
+
if not isinstance(cur, Implies):
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f"ProbProgram: {kind} must be a ground Atom or a (possibly ∀-quantified) "
|
|
276
|
+
f"Implies(Body, Head) definite clause; found {type(cur).__name__} under the "
|
|
277
|
+
"quantifier chain."
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
head = cur.right
|
|
281
|
+
if not isinstance(head, Atom):
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"ProbProgram: {kind} head must be a single positive Atom; found "
|
|
284
|
+
f"{type(head).__name__} — negation or disjunction in the head is not a "
|
|
285
|
+
"definite clause."
|
|
286
|
+
)
|
|
287
|
+
_refuse_truth_constant_head(head, kind)
|
|
288
|
+
|
|
289
|
+
body_atoms: List[Atom] = []
|
|
290
|
+
|
|
291
|
+
def _flatten_body(n: Node) -> None:
|
|
292
|
+
if isinstance(n, Atom):
|
|
293
|
+
body_atoms.append(n)
|
|
294
|
+
return
|
|
295
|
+
if isinstance(n, And):
|
|
296
|
+
_flatten_body(n.left)
|
|
297
|
+
_flatten_body(n.right)
|
|
298
|
+
return
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f"ProbProgram: {kind} body must be a conjunction of positive atoms; found "
|
|
301
|
+
f"{type(n).__name__} — negation or disjunction in the body is not a definite "
|
|
302
|
+
"clause."
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
_flatten_body(cur.left)
|
|
306
|
+
|
|
307
|
+
used: Set[str] = set()
|
|
308
|
+
for a in body_atoms + [head]:
|
|
309
|
+
used |= {v.name for v in a.variables()}
|
|
310
|
+
free = used - bound
|
|
311
|
+
if free:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"ProbProgram: {kind} has free variable(s) {sorted(free)} not bound by an "
|
|
314
|
+
"enclosing ∀."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return tuple(body_atoms), head, tuple(sorted(bound))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Constants, grounding, goal expansion
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def _constants_in(node: Node, out: Set[str]) -> None:
|
|
325
|
+
"""Collect every Constant name occurring anywhere in ``node`` into ``out``."""
|
|
326
|
+
for n in node.walk():
|
|
327
|
+
if isinstance(n, Constant):
|
|
328
|
+
out.add(n.name)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _ground_clause(body_atoms: Tuple[Atom, ...], head: Atom, bound_vars: Tuple[str, ...],
|
|
332
|
+
constants: Tuple[str, ...]):
|
|
333
|
+
"""Yield every ``(ground_body, ground_head)`` instance of a clause over ``constants``.
|
|
334
|
+
|
|
335
|
+
With no bound variables the clause is already ground and yields once
|
|
336
|
+
unchanged. Otherwise every combination of constants for ``bound_vars`` is
|
|
337
|
+
substituted in (capture-avoiding, via :func:`~unicode_logic_kit.fol.nodes.substitute`);
|
|
338
|
+
with an empty ``constants`` domain and at least one bound variable this
|
|
339
|
+
yields NOTHING (an empty-domain clause has no ground instances).
|
|
340
|
+
"""
|
|
341
|
+
if not bound_vars:
|
|
342
|
+
yield body_atoms, head
|
|
343
|
+
return
|
|
344
|
+
for combo in product(constants, repeat=len(bound_vars)):
|
|
345
|
+
mapping = dict(zip(bound_vars, combo))
|
|
346
|
+
|
|
347
|
+
def sub(term: Node) -> Node:
|
|
348
|
+
for vname, cname in mapping.items():
|
|
349
|
+
term = substitute(term, Variable(vname), Constant(cname))
|
|
350
|
+
return term
|
|
351
|
+
|
|
352
|
+
yield tuple(sub(a) for a in body_atoms), sub(head)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _ground_definite_clauses(nodes: Sequence[Node], kind: str, constants: Tuple[str, ...]):
|
|
356
|
+
"""Validate+ground every clause in ``nodes``; split into always-true seeds and body-gated rules.
|
|
357
|
+
|
|
358
|
+
A grounding whose body comes out empty (a bare fact, or a clause whose
|
|
359
|
+
body atoms were all the truth constant ``⊤``) is a SEED — always true,
|
|
360
|
+
independent of any total choice. Everything else is a grounded rule
|
|
361
|
+
``(ground_body, ground_head)`` for forward chaining.
|
|
362
|
+
|
|
363
|
+
A truth constant in a body is read as the value it is, not as an atom that
|
|
364
|
+
some rule has to derive: ``⊤`` holds in every total choice, so it is dropped
|
|
365
|
+
from the body, and a body that holds ``⊥`` holds in none, so the grounded
|
|
366
|
+
clause is dropped.
|
|
367
|
+
"""
|
|
368
|
+
seeds: List[Atom] = []
|
|
369
|
+
grounded: List[Tuple[Tuple[Atom, ...], Atom]] = []
|
|
370
|
+
for node in nodes:
|
|
371
|
+
body_atoms, head, bound_vars = _decompose_rule(node, kind)
|
|
372
|
+
for g_body, g_head in _ground_clause(body_atoms, head, bound_vars, constants):
|
|
373
|
+
if any(truth_value(a) is False for a in g_body):
|
|
374
|
+
continue
|
|
375
|
+
g_body = tuple(a for a in g_body if truth_value(a) is None)
|
|
376
|
+
if g_body:
|
|
377
|
+
grounded.append((g_body, g_head))
|
|
378
|
+
else:
|
|
379
|
+
seeds.append(g_head)
|
|
380
|
+
return seeds, grounded
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
_GOAL_LANGUAGE = (
|
|
384
|
+
"query: unsupported node type {cls} in a goal; expected a ground literal, a "
|
|
385
|
+
"∧/∨/¬ combination of such, or a ∀/∃ quantifier over the program's finite "
|
|
386
|
+
"constant domain (→/↔ are not part of the goal language — encode them via "
|
|
387
|
+
"¬/∧/∨ if needed)."
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _expand_goal(node: Node, constants: Tuple[str, ...]) -> Node:
|
|
392
|
+
"""Expand every ∀/∃ in ``node`` into a finite ∧/∨ over ``constants``; validate the rest.
|
|
393
|
+
|
|
394
|
+
Recurses through Atom/Not/And/Or unchanged (validating shape); a
|
|
395
|
+
Quantifier is expanded via :func:`~unicode_logic_kit.fol.nodes.substitute`
|
|
396
|
+
into the conjunction (∀) or disjunction (∃) of its body over every
|
|
397
|
+
constant, each branch recursively expanded (so nested quantifiers work).
|
|
398
|
+
Raises ``ValueError`` on an empty constant domain under a quantifier, or
|
|
399
|
+
on any node type outside the goal language.
|
|
400
|
+
"""
|
|
401
|
+
if isinstance(node, Atom):
|
|
402
|
+
return node
|
|
403
|
+
if isinstance(node, Not):
|
|
404
|
+
return Not(_expand_goal(node.formula, constants))
|
|
405
|
+
if isinstance(node, And):
|
|
406
|
+
return And(_expand_goal(node.left, constants), _expand_goal(node.right, constants))
|
|
407
|
+
if isinstance(node, Or):
|
|
408
|
+
return Or(_expand_goal(node.left, constants), _expand_goal(node.right, constants))
|
|
409
|
+
if isinstance(node, Quantifier):
|
|
410
|
+
if not constants:
|
|
411
|
+
raise ValueError(
|
|
412
|
+
"query: cannot expand a ∀/∃ in the goal over an empty constant domain "
|
|
413
|
+
"(the program and goal mention no constants at all)."
|
|
414
|
+
)
|
|
415
|
+
branches = [_expand_goal(substitute(node.formula, node.variable, Constant(c)), constants)
|
|
416
|
+
for c in constants]
|
|
417
|
+
if node.type in ("forall", "∀"):
|
|
418
|
+
result = branches[0]
|
|
419
|
+
for b in branches[1:]:
|
|
420
|
+
result = And(result, b)
|
|
421
|
+
return result
|
|
422
|
+
if node.type in ("exists", "∃"):
|
|
423
|
+
result = branches[0]
|
|
424
|
+
for b in branches[1:]:
|
|
425
|
+
result = Or(result, b)
|
|
426
|
+
return result
|
|
427
|
+
raise ValueError(f"query: unknown quantifier type {node.type!r} in goal.")
|
|
428
|
+
raise ValueError(_GOAL_LANGUAGE.format(cls=type(node).__name__))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _goal_atom_keys(node: Node, out: Set[str]) -> None:
|
|
432
|
+
"""Collect the keys of every Atom leaf in a (already-expanded) goal.
|
|
433
|
+
|
|
434
|
+
A key is the text of the atom with every constant written by its name (the same
|
|
435
|
+
text for the facts, the rules and the goal, whatever the constants are called).
|
|
436
|
+
A truth constant is not an atom of the program and has no key: it is true or
|
|
437
|
+
false in every total choice, so it depends on no probabilistic fact.
|
|
438
|
+
"""
|
|
439
|
+
if isinstance(node, Atom):
|
|
440
|
+
if truth_value(node) is None:
|
|
441
|
+
out.add(key_text(node))
|
|
442
|
+
return
|
|
443
|
+
if isinstance(node, Not):
|
|
444
|
+
_goal_atom_keys(node.formula, out)
|
|
445
|
+
return
|
|
446
|
+
if isinstance(node, (And, Or)):
|
|
447
|
+
_goal_atom_keys(node.left, out)
|
|
448
|
+
_goal_atom_keys(node.right, out)
|
|
449
|
+
return
|
|
450
|
+
raise ValueError(_GOAL_LANGUAGE.format(cls=type(node).__name__)) # pragma: no cover — defensive
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _eval_goal(node: Node, known_true: Set[str]) -> bool:
|
|
454
|
+
"""Evaluate an expanded ground goal against a least-model's atom-key set.
|
|
455
|
+
|
|
456
|
+
A truth constant is the value it is in every total choice; it is never looked
|
|
457
|
+
up in the model.
|
|
458
|
+
"""
|
|
459
|
+
if isinstance(node, Atom):
|
|
460
|
+
constant = truth_value(node)
|
|
461
|
+
if constant is not None:
|
|
462
|
+
return constant
|
|
463
|
+
return key_text(node) in known_true
|
|
464
|
+
if isinstance(node, Not):
|
|
465
|
+
return not _eval_goal(node.formula, known_true)
|
|
466
|
+
if isinstance(node, And):
|
|
467
|
+
return _eval_goal(node.left, known_true) and _eval_goal(node.right, known_true)
|
|
468
|
+
if isinstance(node, Or):
|
|
469
|
+
return _eval_goal(node.left, known_true) or _eval_goal(node.right, known_true)
|
|
470
|
+
raise ValueError(_GOAL_LANGUAGE.format(cls=type(node).__name__)) # pragma: no cover — defensive
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _goal_bdd(node: Node, atom_bdd: Dict[str, int], manager: BDDManager) -> int:
|
|
474
|
+
"""Compose an expanded ground goal over per-atom BDDs — the ``method="compile"`` analog of :func:`_eval_goal`.
|
|
475
|
+
|
|
476
|
+
Same ∧/∨/¬ recursive structure, same default-``False`` (here: the BDD
|
|
477
|
+
``FALSE`` terminal) treatment of an atom key absent from ``atom_bdd`` —
|
|
478
|
+
mirroring ``key_text(node) in known_true`` being ``False`` when the
|
|
479
|
+
key was never derived. A truth constant is the BDD terminal of its value.
|
|
480
|
+
"""
|
|
481
|
+
if isinstance(node, Atom):
|
|
482
|
+
constant = truth_value(node)
|
|
483
|
+
if constant is not None:
|
|
484
|
+
return _BDD_TRUE if constant else _BDD_FALSE
|
|
485
|
+
return atom_bdd.get(key_text(node), _BDD_FALSE)
|
|
486
|
+
if isinstance(node, Not):
|
|
487
|
+
return manager.NOT(_goal_bdd(node.formula, atom_bdd, manager))
|
|
488
|
+
if isinstance(node, And):
|
|
489
|
+
return manager.AND(_goal_bdd(node.left, atom_bdd, manager), _goal_bdd(node.right, atom_bdd, manager))
|
|
490
|
+
if isinstance(node, Or):
|
|
491
|
+
return manager.OR(_goal_bdd(node.left, atom_bdd, manager), _goal_bdd(node.right, atom_bdd, manager))
|
|
492
|
+
raise ValueError(_GOAL_LANGUAGE.format(cls=type(node).__name__)) # pragma: no cover — defensive, unreachable: _expand_goal already validated node's shape
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
# Dependency cone (pruning) and least Herbrand model (forward chaining)
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
def _dependency_cone(goal_keys: Set[str],
|
|
500
|
+
grounded_rules: List[Tuple[Tuple[Atom, ...], Atom]]) -> Set[str]:
|
|
501
|
+
"""Backward-reachable atom keys from ``goal_keys`` over grounded body→head edges.
|
|
502
|
+
|
|
503
|
+
A conservative (safe-to-over-include, never under-include) approximation
|
|
504
|
+
of "could possibly affect whether some goal atom is derivable": walks
|
|
505
|
+
grounded-rule edges in REVERSE (head -> its body atoms) from every goal
|
|
506
|
+
atom, transitively. See the module docstring's pruning-correctness note.
|
|
507
|
+
"""
|
|
508
|
+
predecessors: Dict[str, Set[str]] = {}
|
|
509
|
+
for body_atoms, head in grounded_rules:
|
|
510
|
+
hk = key_text(head)
|
|
511
|
+
predecessors.setdefault(hk, set()).update(key_text(a) for a in body_atoms)
|
|
512
|
+
|
|
513
|
+
seen = set(goal_keys)
|
|
514
|
+
frontier = list(goal_keys)
|
|
515
|
+
while frontier:
|
|
516
|
+
cur = frontier.pop()
|
|
517
|
+
for pred in predecessors.get(cur, ()):
|
|
518
|
+
if pred not in seen:
|
|
519
|
+
seen.add(pred)
|
|
520
|
+
frontier.append(pred)
|
|
521
|
+
return seen
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _least_model(seed_keys: Set[str],
|
|
525
|
+
grounded_rules: List[Tuple[Tuple[Atom, ...], Atom]]) -> Set[str]:
|
|
526
|
+
"""Forward-chain ``grounded_rules`` from ``seed_keys`` to the (unique) least Herbrand model."""
|
|
527
|
+
known = set(seed_keys)
|
|
528
|
+
changed = True
|
|
529
|
+
while changed:
|
|
530
|
+
changed = False
|
|
531
|
+
for body_atoms, head in grounded_rules:
|
|
532
|
+
hk = key_text(head)
|
|
533
|
+
if hk in known:
|
|
534
|
+
continue
|
|
535
|
+
if all(key_text(a) in known for a in body_atoms):
|
|
536
|
+
known.add(hk)
|
|
537
|
+
changed = True
|
|
538
|
+
return known
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _least_model_bdd(always_true: Set[str], relevant: Sequence["ProbFact"],
|
|
542
|
+
grounded_rules: List[Tuple[Tuple[Atom, ...], Atom]],
|
|
543
|
+
manager: BDDManager) -> Dict[str, int]:
|
|
544
|
+
"""The ``method="compile"`` analog of :func:`_least_model`: a BDD-valued least fixpoint.
|
|
545
|
+
|
|
546
|
+
``relevant[i]``'s own truth is exactly BDD variable ``i`` (its "chosen"
|
|
547
|
+
status); every other atom key's function starts at ``FALSE`` and is
|
|
548
|
+
repeatedly OR'd with (AND over a deriving rule's body atoms' current
|
|
549
|
+
functions), one grounded rule at a time, until nothing changes — same
|
|
550
|
+
flat while-loop shape as :func:`_least_model`, same termination argument
|
|
551
|
+
(a monotone, bounded-above lattice: here "bounded above" by the BDD
|
|
552
|
+
``TRUE`` terminal under Boolean implication, each successful update
|
|
553
|
+
strictly increasing an atom's function in that order), but comparing
|
|
554
|
+
canonical BDD node ids instead of Python-set membership.
|
|
555
|
+
"""
|
|
556
|
+
known: Dict[str, int] = {key: _BDD_TRUE for key in always_true}
|
|
557
|
+
for i, fact in enumerate(relevant):
|
|
558
|
+
key = key_text(fact.atom)
|
|
559
|
+
known[key] = manager.OR(known.get(key, _BDD_FALSE), manager.variable(i))
|
|
560
|
+
|
|
561
|
+
changed = True
|
|
562
|
+
while changed:
|
|
563
|
+
changed = False
|
|
564
|
+
for body_atoms, head in grounded_rules:
|
|
565
|
+
hk = key_text(head)
|
|
566
|
+
if known.get(hk) == _BDD_TRUE:
|
|
567
|
+
continue # already the constant-true function; cannot improve further
|
|
568
|
+
body_bdd = _BDD_TRUE
|
|
569
|
+
for a in body_atoms:
|
|
570
|
+
body_bdd = manager.AND(body_bdd, known.get(key_text(a), _BDD_FALSE))
|
|
571
|
+
if body_bdd == _BDD_FALSE:
|
|
572
|
+
break
|
|
573
|
+
new_value = manager.OR(known.get(hk, _BDD_FALSE), body_bdd)
|
|
574
|
+
if new_value != known.get(hk, _BDD_FALSE):
|
|
575
|
+
known[hk] = new_value
|
|
576
|
+
changed = True
|
|
577
|
+
return known
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# Public query
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def query(program: ProbProgram, goal: Node, *, max_choice_facts: int = 16,
|
|
585
|
+
prune: bool = True, method: Literal["enumerate", "compile"] = "enumerate",
|
|
586
|
+
max_bdd_nodes: int = 100_000) -> Fraction:
|
|
587
|
+
"""Return the exact distribution-semantics probability of ``goal`` under ``program``.
|
|
588
|
+
|
|
589
|
+
Sums the weight of every total choice of the (by default, pruned to the
|
|
590
|
+
goal's dependency cone — see the module docstring) relevant probabilistic
|
|
591
|
+
facts whose resulting least Herbrand model satisfies ``goal``. The result
|
|
592
|
+
is an exact :class:`~fractions.Fraction` — Z3 is not used anywhere in this
|
|
593
|
+
module; the arithmetic is plain Python ``Fraction`` products and sums over
|
|
594
|
+
a finite, explicitly enumerated set of choices.
|
|
595
|
+
|
|
596
|
+
``method`` selects the evaluation ALGORITHM, never the semantics — see the
|
|
597
|
+
module docstring's "A second evaluation route" section. ``"enumerate"``
|
|
598
|
+
(the default) is the ``2^k`` total-choice sum just described, unchanged.
|
|
599
|
+
``"compile"`` instead builds one shared Boolean-decision-diagram function
|
|
600
|
+
per derivable atom (:mod:`unicode_logic_kit.prob._bdd`) and weighted-model-
|
|
601
|
+
counts a single composed root; it is bounded by ``max_bdd_nodes`` rather
|
|
602
|
+
than ``max_choice_facts`` (the latter is ignored under ``method="compile"``),
|
|
603
|
+
and can therefore answer some programs ``"enumerate"`` cannot (many shared
|
|
604
|
+
sub-derivations, few actual BDD nodes) — while, on every program either
|
|
605
|
+
route CAN answer, returning bit-for-bit the same ``Fraction``.
|
|
606
|
+
|
|
607
|
+
Raises:
|
|
608
|
+
ValueError: on a malformed ``goal`` (outside ground-literal /
|
|
609
|
+
∧/∨/¬ / ∀/∃-over-constants); under ``method="enumerate"``, if the
|
|
610
|
+
number of relevant probabilistic facts exceeds ``max_choice_facts``
|
|
611
|
+
(the choice enumeration is ``2^k`` — an explicit, overridable
|
|
612
|
+
brake); under ``method="compile"``, if the compiled BDD would grow
|
|
613
|
+
past ``max_bdd_nodes`` (weighted model counting is #P-hard — an
|
|
614
|
+
explicit, overridable brake on a different failure mode); an
|
|
615
|
+
unrecognised ``method``; or if ``program`` itself is malformed
|
|
616
|
+
(raised eagerly by :class:`ProbProgram` / :class:`ProbFact` at
|
|
617
|
+
construction time, before ``query`` is ever called); or if two different
|
|
618
|
+
ground atoms of the program and goal print alike (the numeral ``1`` and a
|
|
619
|
+
constant named ``1``, which would be ONE probabilistic variable), or an atom
|
|
620
|
+
holds a sorted constant.
|
|
621
|
+
"""
|
|
622
|
+
constant_names: Set[str] = set()
|
|
623
|
+
for f in program.facts:
|
|
624
|
+
_constants_in(f.atom, constant_names)
|
|
625
|
+
for r in program.rules:
|
|
626
|
+
_constants_in(r, constant_names)
|
|
627
|
+
for hf in program.hard_facts:
|
|
628
|
+
_constants_in(hf, constant_names)
|
|
629
|
+
_constants_in(goal, constant_names)
|
|
630
|
+
constants = tuple(sorted(constant_names))
|
|
631
|
+
|
|
632
|
+
ground_goal = _expand_goal(goal, constants)
|
|
633
|
+
|
|
634
|
+
rule_seeds, grounded_rules = _ground_definite_clauses(program.rules, "rule", constants)
|
|
635
|
+
hard_seeds, hard_grounded = _ground_definite_clauses(program.hard_facts, "hard fact", constants)
|
|
636
|
+
always_true = {key_text(a) for a in rule_seeds + hard_seeds}
|
|
637
|
+
all_grounded_rules = grounded_rules + hard_grounded
|
|
638
|
+
|
|
639
|
+
# An atom is named by the text it prints as: two different ground atoms that print alike
|
|
640
|
+
# (the numeral 1 and a constant named 1) would be one probabilistic variable.
|
|
641
|
+
AtomKeys("query", "refuse", ValueError).letters(
|
|
642
|
+
[*(f.atom for f in program.facts), *rule_seeds, *hard_seeds,
|
|
643
|
+
*(a for body, head in all_grounded_rules for a in (*body, head)), ground_goal])
|
|
644
|
+
|
|
645
|
+
if prune:
|
|
646
|
+
goal_keys: Set[str] = set()
|
|
647
|
+
_goal_atom_keys(ground_goal, goal_keys)
|
|
648
|
+
cone = _dependency_cone(goal_keys, all_grounded_rules)
|
|
649
|
+
relevant = [f for f in program.facts if key_text(f.atom) in cone]
|
|
650
|
+
else:
|
|
651
|
+
relevant = list(program.facts)
|
|
652
|
+
|
|
653
|
+
if method == "enumerate":
|
|
654
|
+
if len(relevant) > max_choice_facts:
|
|
655
|
+
raise ValueError(
|
|
656
|
+
f"query: {len(relevant)} relevant probabilistic facts exceeds "
|
|
657
|
+
f"max_choice_facts={max_choice_facts}. The total-choice enumeration is "
|
|
658
|
+
f"O(2^k) — {len(relevant)} facts means 2**{len(relevant)} = "
|
|
659
|
+
f"{2 ** len(relevant)} choices. Reduce the program (or tighten the goal, "
|
|
660
|
+
"which tightens the dependency-cone pruning), or pass a larger "
|
|
661
|
+
"max_choice_facts explicitly if that blow-up is intended."
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
total = Fraction(0)
|
|
665
|
+
for combo in product((False, True), repeat=len(relevant)):
|
|
666
|
+
weight = Fraction(1)
|
|
667
|
+
chosen_keys = set(always_true)
|
|
668
|
+
for fact, is_chosen in zip(relevant, combo):
|
|
669
|
+
weight *= fact.prob if is_chosen else (Fraction(1) - fact.prob)
|
|
670
|
+
if is_chosen:
|
|
671
|
+
chosen_keys.add(key_text(fact.atom))
|
|
672
|
+
if weight == 0:
|
|
673
|
+
continue
|
|
674
|
+
known_true = _least_model(chosen_keys, all_grounded_rules)
|
|
675
|
+
if _eval_goal(ground_goal, known_true):
|
|
676
|
+
total += weight
|
|
677
|
+
return total
|
|
678
|
+
|
|
679
|
+
if method == "compile":
|
|
680
|
+
manager = BDDManager(len(relevant), max_nodes=max_bdd_nodes)
|
|
681
|
+
atom_bdd = _least_model_bdd(always_true, relevant, all_grounded_rules, manager)
|
|
682
|
+
root = _goal_bdd(ground_goal, atom_bdd, manager)
|
|
683
|
+
weights = [fact.prob for fact in relevant]
|
|
684
|
+
return weighted_model_count(manager, root, weights)
|
|
685
|
+
|
|
686
|
+
raise ValueError(f"query: unknown method {method!r}; expected 'enumerate' or 'compile'.")
|