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,4048 @@
|
|
|
1
|
+
"""An ALCHQ tableau reasoner: concept satisfiability, subsumption, ABox consistency.
|
|
2
|
+
|
|
3
|
+
Decides the core description-logic reasoning tasks for **ALC**, extended with role
|
|
4
|
+
hierarchies/transitive roles (**H**, **S** — see "Role hierarchies and transitive
|
|
5
|
+
roles (RBox)" below) and qualified number restrictions (**Q** — see "Qualified
|
|
6
|
+
number restrictions" below), with *general* TBoxes, by the standard tableau
|
|
7
|
+
algorithm. A tableau builds a tree (more precisely, once **Q** is in play, a DAG —
|
|
8
|
+
see "Qualified number restrictions") of individuals, each carrying a *label* (a set
|
|
9
|
+
of concepts it must satisfy), and applies completion rules:
|
|
10
|
+
|
|
11
|
+
- ``⊓``: ``x : C ⊓ D`` ⇒ add ``x : C`` and ``x : D`` (deterministic);
|
|
12
|
+
- ``⊔``: ``x : C ⊔ D`` ⇒ branch on ``x : C`` | ``x : D`` (nondeterministic);
|
|
13
|
+
- ``∃``: ``x : ∃r.C`` ⇒ create a fresh ``r``-successor ``y`` with ``y : C``;
|
|
14
|
+
- ``∀``: ``x : ∀r.C`` and ``x —r→ y`` ⇒ add ``y : C``;
|
|
15
|
+
- ``≥``, ``≤``, choose: the **Q** number-restriction rules — see "Qualified number
|
|
16
|
+
restrictions" below.
|
|
17
|
+
|
|
18
|
+
A **clash** is ``x : ⊥``, ``{x : A, x : ¬A}``, or (with **Q**) ``x : ≤n r.C`` together
|
|
19
|
+
with ``n+1`` PAIRWISE-DISTINCT ``r``-neighbours of ``x`` all in ``C`` — see below. A
|
|
20
|
+
concept is satisfiable iff some branch saturates without a clash. **General TBoxes**
|
|
21
|
+
``C ⊑ D`` are *internalised*: the concept ``¬C ⊔ D`` is forced on every individual.
|
|
22
|
+
Termination with such axioms relies on **subset blocking** — a generated individual
|
|
23
|
+
whose label is contained in that of an earlier individual is not expanded (its
|
|
24
|
+
successors are reused) — sound and complete for plain ALC(H+S) (no inverse roles or
|
|
25
|
+
number restrictions); see "Qualified number restrictions" below for why it remains
|
|
26
|
+
sound and complete once **Q** (still without inverse roles) is added on top.
|
|
27
|
+
|
|
28
|
+
The instance/realization family are reductions to :func:`abox_consistent`
|
|
29
|
+
(``instance_check``) and to that in turn (``instance_retrieval``, ``realize``,
|
|
30
|
+
``realize_all``) — they add no tableau completion rule, so they inherit the same
|
|
31
|
+
soundness/completeness as the rest of the reasoner without adding to its burden (the
|
|
32
|
+
guard they run first is the one thing they do add: see "The axiom-kind table"
|
|
33
|
+
below). The same is true of :func:`unicode_logic_kit.dl.classification.classify`, which
|
|
34
|
+
reduces to
|
|
35
|
+
:func:`subsumes` alone; consequently every RBox extension below (role hierarchies,
|
|
36
|
+
transitive roles) is automatically respected by ``instance_check``, ``classify``, and
|
|
37
|
+
the rest of that family with no change to their own code — they only ever call back
|
|
38
|
+
into :func:`concept_satisfiable`/:func:`abox_consistent`, which are where the RBox
|
|
39
|
+
lives.
|
|
40
|
+
|
|
41
|
+
Role hierarchies and transitive roles (RBox)
|
|
42
|
+
---------------------------------------------
|
|
43
|
+
On top of the TBox, :class:`TBox` also carries an **RBox**: role inclusions
|
|
44
|
+
``r ⊑ s`` (:meth:`TBox.add_role_inclusion`) and transitivity declarations
|
|
45
|
+
``Trans(r)`` (:meth:`TBox.add_transitive_role`). Together with the ALC concept
|
|
46
|
+
language this gives **ALCH** (role hierarchies) plus transitive roles, i.e. the
|
|
47
|
+
DL usually written **SH** once inverse roles are added on top — this kit
|
|
48
|
+
deliberately stops short of inverses and of qualified number restrictions,
|
|
49
|
+
which is what keeps the algorithm below (and its blocking argument) simple.
|
|
50
|
+
|
|
51
|
+
Both axiom kinds are precomputed once per :func:`_solve` call into an
|
|
52
|
+
``_RBox`` helper (see ``_new_branch``): ``ancestors(r)`` is the
|
|
53
|
+
reflexive-transitive closure of ``⊑`` over role names (``s ∈ ancestors(r)``
|
|
54
|
+
iff ``r ⊑* s``), and ``transitive`` is the declared set of transitive role
|
|
55
|
+
names. A cycle in the inclusions (``r ⊑ s ⊑ r``) is not rejected — it simply
|
|
56
|
+
makes ``ancestors(r) == ancestors(s)``, i.e. the two roles become semantically
|
|
57
|
+
synonymous, which is sound (every model where ``r ⊑ s`` and ``s ⊑ r`` both
|
|
58
|
+
hold has ``r`` and ``s`` denote the same relation anyway).
|
|
59
|
+
|
|
60
|
+
Two changes to :func:`_saturate`'s ∀-rule realise the RBox semantics, confined
|
|
61
|
+
entirely to that one branch:
|
|
62
|
+
|
|
63
|
+
1. **H (role hierarchy).** The exact-match edge/restriction test
|
|
64
|
+
``role == c.role`` is generalised to ``c.role ∈ ancestors(role)`` (i.e.
|
|
65
|
+
``role ⊑* c.role``): an ``r``-edge is also an ``s``-edge for every
|
|
66
|
+
declared ``r ⊑ s``, so ``x : ∀s.C`` together with an ``r``-successor
|
|
67
|
+
still forces that successor into ``C``. This is exactly Horrocks &
|
|
68
|
+
Sattler's treatment of role hierarchies for **ALCH** ("A Description
|
|
69
|
+
Logic with Transitive and Inverse Roles and Role Hierarchies", *DL'99*).
|
|
70
|
+
|
|
71
|
+
2. **S (transitive roles, the "∀+"-rule).** Whenever rule 1 fires across an
|
|
72
|
+
edge labelled ``role`` and there exists SOME transitive role ``R`` with
|
|
73
|
+
``role ⊑* R ⊑* c.role`` (``R`` may equal ``role`` itself, ``c.role``
|
|
74
|
+
itself, or an intermediate declared-transitive role strictly between the
|
|
75
|
+
two), the *whole* restriction ``∀R.C`` — not just ``C`` — is also copied
|
|
76
|
+
onto the successor's label, so the restriction is still active for that
|
|
77
|
+
successor's own ``R``-neighbours, and so on along the entire chain. This
|
|
78
|
+
is the standard ∀+-rule from the same paper (their "R-neighbour" notion,
|
|
79
|
+
restricted here to the non-inverse fragment). Concretely this is what
|
|
80
|
+
makes ``hasChild ⊑ hasDescendant`` plus ``Trans(hasDescendant)`` propagate
|
|
81
|
+
``∀hasDescendant.C`` down an arbitrarily long chain of ``hasChild`` edges
|
|
82
|
+
(``R = hasDescendant``, transitive, reachable from every ``hasChild``
|
|
83
|
+
edge and equal to the restriction's own role), and equally what makes
|
|
84
|
+
``Trans(hasChild)`` alone (no hierarchy at all, ``R = role = c.role``)
|
|
85
|
+
propagate along a ``hasChild``-only chain — both are the same rule, just
|
|
86
|
+
different choices of the witnessing ``R``.
|
|
87
|
+
|
|
88
|
+
**Soundness.** Neither rule ever adds a concept that every model of the role
|
|
89
|
+
axioms does not already force onto the successor. Rule 1: ``r ⊑ s`` is
|
|
90
|
+
exactly ``∀x,y (r(x,y) → s(x,y))`` (see :func:`unicode_logic_kit.dl.translate.rbox_to_fol`),
|
|
91
|
+
so an ``r``-edge already witnesses an ``s``-edge in any model — copying
|
|
92
|
+
``C`` across it is just using that fact. Rule 2: chaining rule-1 facts along
|
|
93
|
+
``role ⊑* R`` shows every ``role``-edge is also an ``R``-edge, and
|
|
94
|
+
``Trans(R)`` (``∀x,y,z (R(x,y) ∧ R(y,z) → R(x,z))``) then makes any further
|
|
95
|
+
``R``-neighbour of the successor an ``R``-neighbour of the *original* node
|
|
96
|
+
too — exactly the FOL image :func:`rbox_to_fol` renders and the differential
|
|
97
|
+
test battery checks the tableau against directly. Everything else (⊓, ⊔, ∃,
|
|
98
|
+
the clash condition) is untouched, so soundness elsewhere in the reasoner
|
|
99
|
+
carries over unchanged.
|
|
100
|
+
|
|
101
|
+
**Termination.** Only the ∀-rule changed, and both new cases remain purely
|
|
102
|
+
LABEL-GROWING: they add elements to ``branch.label[successor]`` and never
|
|
103
|
+
touch an edge or look at (let alone modify) a predecessor's label — exactly
|
|
104
|
+
like the pre-existing plain ∀-rule. The role language has no inverse roles,
|
|
105
|
+
so a node's label still depends only on what was pushed forward from its
|
|
106
|
+
ancestors along the (fixed, ∃-rule-built) tree structure; nothing added by
|
|
107
|
+
rule 1 or 2 ever needs to look backward across an edge. That is precisely
|
|
108
|
+
the property subset blocking's soundness/completeness proof relies on
|
|
109
|
+
(Horrocks & Sattler 1999): a blocked node's label is a SUBSET of its
|
|
110
|
+
blocker's, so reusing the blocker's already-expanded subtree instead of
|
|
111
|
+
re-expanding remains model-correct regardless of how that label came to
|
|
112
|
+
contain what it does. Since rule 2 only ever adds ``ForAll(R, C)`` for an
|
|
113
|
+
``R`` drawn from the FINITE set of role names mentioned in the RBox, paired
|
|
114
|
+
with a ``C`` that already occurs somewhere in the branch (never a fresh
|
|
115
|
+
one), the set of concepts able to appear in any one label stays finite —
|
|
116
|
+
bounded by the same subconcept/RBox-role closure the ALC case already
|
|
117
|
+
blocks on — so blocking is still guaranteed to trigger within a bounded
|
|
118
|
+
number of successor generations, and termination follows exactly as before;
|
|
119
|
+
``_blocked`` itself is untouched.
|
|
120
|
+
|
|
121
|
+
The rest of the OWL 2 role box: characteristics, disjointness, chains
|
|
122
|
+
----------------------------------------------------------------------
|
|
123
|
+
OWL 2 has nine more object-property axiom kinds, and a :class:`TBox` carries
|
|
124
|
+
every one of them (builders never refuse — see "The axiom-kind table" below).
|
|
125
|
+
Three of them this tableau DECIDES, six it REFUSES BY NAME, and the split is
|
|
126
|
+
not a matter of effort: it follows from one property of the completion graph.
|
|
127
|
+
|
|
128
|
+
**The three decided: irreflexivity, asymmetry, role disjointness.** Each
|
|
129
|
+
forbids an edge PATTERN and forces no concept on anybody, so each is ONE new
|
|
130
|
+
clash condition in :func:`_clash` and nothing else:
|
|
131
|
+
|
|
132
|
+
* ``IrreflexiveObjectProperty(P)`` (:meth:`TBox.add_irreflexive_role`) — clash
|
|
133
|
+
if some edge ``x —R→ x`` has ``R ⊑* P``.
|
|
134
|
+
* ``AsymmetricObjectProperty(P)`` (:meth:`TBox.add_asymmetric_role`) — clash if
|
|
135
|
+
edges ``x —R→ y`` and ``y —R'→ x`` both have ``R ⊑* P`` and ``R' ⊑* P``. The
|
|
136
|
+
degenerate ``x = y`` is included, which is right: asymmetry ENTAILS
|
|
137
|
+
irreflexivity (instantiate ``y := x`` in ``P(x,y) → ¬P(y,x)``).
|
|
138
|
+
* ``DisjointObjectProperties(P, Q)`` (:meth:`TBox.add_disjoint_roles`) — clash
|
|
139
|
+
if edges ``x —R→ y`` and ``x —R'→ y`` with the SAME source and the SAME
|
|
140
|
+
destination have ``R ⊑* P`` and ``R' ⊑* Q``. The degenerate ``P = Q`` falls
|
|
141
|
+
out of this with ``R' = R``, correctly making ``P`` empty.
|
|
142
|
+
|
|
143
|
+
All three are read off one per-``_solve``-iteration index of the branch's edges
|
|
144
|
+
(:func:`_edge_closure`: ``(source, destination) -> every role the edges between
|
|
145
|
+
them entail via ⊑*``), and all three are skipped outright unless
|
|
146
|
+
``_RBox.has_edge_constraints`` says the role box declares one — ``_clash`` is
|
|
147
|
+
the innermost loop of the whole reasoner and a plain ALC/ALCQ TBox must not pay
|
|
148
|
+
for a feature it does not use.
|
|
149
|
+
|
|
150
|
+
**Soundness** of the three is the same one-line argument: each condition is
|
|
151
|
+
strictly SUBTRACTIVE. It adds no concept, adds no edge, changes no rule, and
|
|
152
|
+
closes a branch only where two edges the branch itself entails contradict the
|
|
153
|
+
axiom in EVERY model — so ``_saturate``, ``_blocked``, ``_merge``,
|
|
154
|
+
``_find_*`` and the termination argument above are untouched, and the only
|
|
155
|
+
branches lost were unsatisfiable anyway.
|
|
156
|
+
|
|
157
|
+
**Completeness** rests on two things, and the second is the one an implementer
|
|
158
|
+
must not get wrong.
|
|
159
|
+
|
|
160
|
+
1. *The roles are SIMPLE.* OWL 2 (Structural Specification §11) restricts these
|
|
161
|
+
three axioms — and ``FunctionalObjectProperty``/
|
|
162
|
+
``InverseFunctionalObjectProperty``, and every number restriction — to
|
|
163
|
+
SIMPLE roles, and :func:`_check_simple_role_box` enforces exactly that
|
|
164
|
+
condition, by name, before the tableau starts. A role is COMPOSITE iff it is
|
|
165
|
+
declared transitive or is the super-role of a property chain, and NON-SIMPLE
|
|
166
|
+
iff some composite role entails it via ``⊑*`` (``r' ⊑* r`` for composite
|
|
167
|
+
``r'``, ``r'`` possibly ``= r``). That direction matters: a composite
|
|
168
|
+
SUB-role is what makes the super-role non-simple, and reading it the other
|
|
169
|
+
way round silently readmits an undecidable combination (Horrocks, Sattler &
|
|
170
|
+
Tobies 1999). With simplicity in hand, the ``P``-relation a saturated branch
|
|
171
|
+
entails is EXACTLY the one-step ``⊑*``-closure of ``branch.edges`` — no
|
|
172
|
+
transitive closure and no chain closure can manufacture a pair the one-step
|
|
173
|
+
check missed — so the edge-pattern tests above are exact rather than merely
|
|
174
|
+
sound.
|
|
175
|
+
2. *The model is read off the UNRAVELLED tree*, not the looped finite model the
|
|
176
|
+
usual subset-blocking presentation builds. Concretely: with ``⊤ ⊑ ∃r.⊤`` and
|
|
177
|
+
``Asym(r)``, the looped model (a blocked node's edge bent back to its
|
|
178
|
+
blocker) contains both ``r(a, _x1)`` and ``r(_x1, a)`` and so violates
|
|
179
|
+
asymmetry, while the INFINITE unravelled tree model does not. The tableau's
|
|
180
|
+
answer (satisfiable) is correct and only the completeness proof's model
|
|
181
|
+
CONSTRUCTION needs the unravelling; an implementer who checks the rule
|
|
182
|
+
against the looped model will wrongly conclude it is unsound and weaken it.
|
|
183
|
+
The generated part of a branch is a tree, so it never contains a converse
|
|
184
|
+
pair or a self-loop at all; the only sources are ABox role assertions
|
|
185
|
+
(finite, possibly cyclic, checked literally) and the ≤-rule's merges — and
|
|
186
|
+
``_clash`` runs at the TOP of every :func:`_solve` iteration, hence after
|
|
187
|
+
every merge, with ``_solve`` backtracking over the other candidate pairs, so
|
|
188
|
+
a merge choice that violates one of the three is rejected and another is
|
|
189
|
+
tried. ``tests/test_dl_rbox.py``'s unbounded-chain case is this argument's
|
|
190
|
+
regression, and its at-most-one merge case proves the condition is consulted
|
|
191
|
+
after a merge rather than only at branch setup.
|
|
192
|
+
|
|
193
|
+
**Functionality is not a new rule at all.** ``FunctionalObjectProperty(P)``
|
|
194
|
+
(:meth:`TBox.add_functional_role`) IS the GCI ``⊤ ⊑ ≤1 P.⊤``, and
|
|
195
|
+
``AtMost(1, P, Top())`` on a simple role is already decided by the **Q**
|
|
196
|
+
machinery below. So it is INTERNALISED, in :func:`_new_branch` rather than in
|
|
197
|
+
:meth:`TBox.internalized` (which stays exactly the GCI list — a guard worth
|
|
198
|
+
keeping, see ``tests/test_dl_rbox.py::test_internalized_unaffected_by_rbox``).
|
|
199
|
+
``_new_branch`` is the single place both :func:`concept_satisfiable` and
|
|
200
|
+
:func:`abox_consistent` take ``tbox_concepts`` from, and the ∃- and ≥-rules
|
|
201
|
+
already copy that list onto every freshly generated node, so functionality
|
|
202
|
+
applies to generated individuals too, for free. Soundness, completeness and
|
|
203
|
+
termination are the existing ALCQ argument, unchanged.
|
|
204
|
+
|
|
205
|
+
**The six refused**, each by name from :func:`_reject_unsupported` (the shared
|
|
206
|
+
axiom-level guard) with its own remedy in the message:
|
|
207
|
+
|
|
208
|
+
* ``InverseObjectProperties(P Q)``, and an :class:`~unicode_logic_kit.dl.concepts.InverseRole`
|
|
209
|
+
on either side of a role inclusion (``r ⊑ s⁻``) — the **I** of SHIQ, refused
|
|
210
|
+
for exactly the reason given under "Inverse roles and nominals (I, O)" below:
|
|
211
|
+
a back-edge makes a node's label depend on what lies BACKWARD across an edge,
|
|
212
|
+
which subset blocking's soundness/completeness argument does not cover.
|
|
213
|
+
* ``SymmetricObjectProperty(P)`` — this IS the inverse-role inclusion
|
|
214
|
+
``P ⊑ P⁻``. The tempting shortcut, materialising the converse edge whenever
|
|
215
|
+
``(x, P, y)`` is added, puts a CYCLE into the completion graph, so a blocked
|
|
216
|
+
node's blocker can become its own descendant; same failure, same refusal.
|
|
217
|
+
* ``InverseFunctionalObjectProperty(P)`` — the number restriction
|
|
218
|
+
``≤1 P⁻.⊤``, which counts P-PREDECESSORS. The **I** again; the message points
|
|
219
|
+
at :meth:`TBox.add_functional_role` in case functionality on ``P`` itself was
|
|
220
|
+
meant.
|
|
221
|
+
* ``ReflexiveObjectProperty(P)`` — the only one refused for a COUNTING reason
|
|
222
|
+
rather than a blocking one. The ∀ half would be cheap (a reflexive ``P``
|
|
223
|
+
makes every node its own ``P``-neighbour, so ``x : ∀Q.C`` with ``P ⊑* Q``
|
|
224
|
+
forces ``C`` onto ``x`` itself: a purely local label rule from the existing
|
|
225
|
+
finite closure). What kills it is that OWL 2 does NOT restrict Reflexive to
|
|
226
|
+
simple roles and does allow ``≤n P.C`` alongside it, and a correct ``≤``/``≥``
|
|
227
|
+
answer then needs :func:`_role_neighbours` to include ``x`` itself — at which
|
|
228
|
+
point :func:`_find_mergeable` can hand :func:`_merge` the pair ``(x, one of
|
|
229
|
+
x's own successors)``, the back-edge case the ≤-rule's termination argument
|
|
230
|
+
explicitly excludes. One unconditional refusal is a smaller and more
|
|
231
|
+
auditable surface than a second, conditional refusal mechanism, and matches
|
|
232
|
+
this module's stated preference for the simplest sufficient condition. (A
|
|
233
|
+
consequence worth recording so it is not re-derived: a role that is both
|
|
234
|
+
reflexive and irreflexive — or both reflexive and asymmetric, which entails
|
|
235
|
+
irreflexivity — is an inconsistent ROLE BOX, since the domain is never empty.
|
|
236
|
+
With Reflexive refused, that combination can never reach the tableau, so no
|
|
237
|
+
RBox-level clash condition is needed for it.)
|
|
238
|
+
* ``SubObjectPropertyOf(ObjectPropertyChain(P1 … Pn) Q)`` — complex role
|
|
239
|
+
inclusions, the **R** of SROIQ. Deferred, not impossible, and the rule is
|
|
240
|
+
recorded here so a future session does not have to re-derive it: it would be
|
|
241
|
+
a purely LOCAL label rule — whenever ``∀Q.C`` is in a label and a chain
|
|
242
|
+
``P1…Pn ⊑ Q'`` exists with ``Q' ⊑* Q``, add ``∀P1.∀P2.…∀Pn.C`` to the SAME
|
|
243
|
+
label, plus ``∀P1.…∀Pn.(C ⊓ ∀Q.C)`` when ``Q`` is transitive — which adds no
|
|
244
|
+
edge and no backward dependency, and whose concept closure is finite exactly
|
|
245
|
+
when the chain dependency graph is ACYCLIC. What that does not give for free
|
|
246
|
+
is (a) the counting side, since a chain-derived ``Q``-edge must count as a
|
|
247
|
+
``Q``-neighbour — disposed of by the simple-role restriction above, because a
|
|
248
|
+
chain super-role is COMPOSITE and so may carry no number restriction,
|
|
249
|
+
asymmetry, irreflexivity or role disjointness — and (b) the REGULARITY
|
|
250
|
+
restriction a general chain set needs: an irregular set such as
|
|
251
|
+
``{R∘S ⊑ S, S∘R ⊑ S}`` makes satisfiability undecidable, and deciding
|
|
252
|
+
regularity needs the role-automaton construction of Horrocks, Kutz & Sattler
|
|
253
|
+
2006 ("The Even More Irresistible SROIQ"). (b) is a second piece of work, so
|
|
254
|
+
the honest answer today is a refusal that says so. Ignoring the chain instead
|
|
255
|
+
would report "not subsumed" for a subsumption the FOL image PROVES, which is
|
|
256
|
+
precisely the two-routes-disagree failure this module exists to prevent.
|
|
257
|
+
|
|
258
|
+
The OWL 2 built-in roles (``owl:topObjectProperty`` and friends)
|
|
259
|
+
-----------------------------------------------------------------
|
|
260
|
+
``(owl:topObjectProperty)^OP`` is the whole of ``Δ^I × Δ^I`` and
|
|
261
|
+
``(owl:bottomObjectProperty)^OP`` is ``∅``. ALCHQ has neither the universal nor
|
|
262
|
+
the empty role, and carrying such a name as an ORDINARY role would ship a
|
|
263
|
+
weaker theory under a name that looks like a built-in — so no role-box field of
|
|
264
|
+
any :class:`TBox` may contain one. Every ``add_*`` method refuses the four
|
|
265
|
+
built-ins, in both the abbreviated and the full-IRI spelling
|
|
266
|
+
(:data:`RESERVED_ROLE_SPELLINGS`), by name and with the rewrite to use instead.
|
|
267
|
+
The ONE way such an axiom may enter is
|
|
268
|
+
:func:`~unicode_logic_kit.dl.owl_functional.parse_owl_functional`, which consumes
|
|
269
|
+
a TAUTOLOGICAL inclusion (a reserved TOP name as the super-role, or a reserved
|
|
270
|
+
BOTTOM name as the sub-role) as a documented no-op — a tautology carries no
|
|
271
|
+
truth to lose, which is the same precedent that module already sets and argues
|
|
272
|
+
for ``Declaration(...)``/``Annotation(...)``. Consequence to state plainly: the
|
|
273
|
+
kit does not gain the universal role, so ``∃owl:topObjectProperty.C`` ("C is
|
|
274
|
+
non-empty") remains inexpressible, and ``P ⊑ owl:bottomObjectProperty`` ("P is
|
|
275
|
+
empty") must be written as the concept inclusion ``⊤ ⊑ ∀P.⊥``.
|
|
276
|
+
|
|
277
|
+
Qualified number restrictions (ALCQ)
|
|
278
|
+
-------------------------------------
|
|
279
|
+
On top of ALC(H+S), :mod:`unicode_logic_kit.dl.concepts` also has
|
|
280
|
+
:class:`~unicode_logic_kit.dl.concepts.AtLeast` (≥n r.C) and
|
|
281
|
+
:class:`~unicode_logic_kit.dl.concepts.AtMost` (≤n r.C) — *qualified number
|
|
282
|
+
restrictions*. This is the **Q** in ALCHQ (Hollunder & Baader 1991 for the
|
|
283
|
+
core ALCQ algorithm; Horrocks, Sattler & Tobies 1999/2000 for the SHQ/SHIQ
|
|
284
|
+
extension this kit's role-hierarchy/transitivity combination follows).
|
|
285
|
+
|
|
286
|
+
**No unique name assumption.** DL semantics never assumes two individuals
|
|
287
|
+
(named or generated) denote different domain elements unless something
|
|
288
|
+
forces it, so counting "n distinct r-successors" needs an explicit notion of
|
|
289
|
+
FORCED distinctness, tracked in ``_Branch.distinct``: a set of node-name
|
|
290
|
+
pairs. Two nodes start distinct only when the ≥-rule (below) generates them
|
|
291
|
+
TOGETHER as witnesses of the same restriction, or when :meth:`ABox.assert_distinct`
|
|
292
|
+
records it explicitly; every other pair (two ABox individuals in particular)
|
|
293
|
+
is *not* assumed distinct, exactly per the mandate above — so an ABox with
|
|
294
|
+
two named ``r``-successors that are never asserted distinct can always be
|
|
295
|
+
read as ONE individual wearing two names, and a ``≤1 r.⊤`` restriction over
|
|
296
|
+
them is satisfiable (see ``tests/test_dl_alcq.py``'s ABox-merge cases).
|
|
297
|
+
|
|
298
|
+
**Simple roles only.** A qualified number restriction may not target a
|
|
299
|
+
NON-SIMPLE role — one that is itself transitive, or has a transitive
|
|
300
|
+
sub-role reachable via the RBox (``r' ⊑* r`` for some ``Trans(r')``, ``r'``
|
|
301
|
+
possibly ``= r``). Combining unrestricted transitivity with counting makes
|
|
302
|
+
satisfiability UNDECIDABLE (Horrocks, Sattler & Tobies 1999, "A Description
|
|
303
|
+
Logic with Transitive and Inverse Roles and Role Hierarchies", *DL'99*; the
|
|
304
|
+
"simple roles" restriction is standard in every DL from SHQ onward). This
|
|
305
|
+
kit refuses the combination outright rather than silently mistranslating or
|
|
306
|
+
looping forever: :func:`_check_simple_roles` walks every ``AtLeast``/
|
|
307
|
+
``AtMost`` reachable from the query concept, the TBox, and the ABox once per
|
|
308
|
+
:func:`concept_satisfiable`/:func:`abox_consistent` call and raises
|
|
309
|
+
:class:`NonSimpleRoleError`, naming the offending role, before the tableau
|
|
310
|
+
ever starts.
|
|
311
|
+
|
|
312
|
+
**Role-hierarchy-aware neighbour counting.** Exactly like the ∀-rule's rule H
|
|
313
|
+
(see above), an ``r``-edge counts as an ``s``-neighbour of its source for
|
|
314
|
+
EVERY ``s`` with ``r ⊑* s`` — so ``≥n s.C``/``≤n s.C`` at ``x`` are decided
|
|
315
|
+
over ``_role_neighbours(x, s)`` = every DISTINCT ``y`` with an edge ``x —r→ y``
|
|
316
|
+
and ``s ∈ ancestors(r)``, not just literal ``s``-edges. "Distinct" matters: a
|
|
317
|
+
number restriction counts NEIGHBOURS, so a ``y`` reached by more than one
|
|
318
|
+
entailing edge out of ``x`` (e.g. both a literal ``s``-edge and an ``r``-edge
|
|
319
|
+
with ``r ⊑ s``, or two sibling sub-roles of ``s``) must be counted exactly
|
|
320
|
+
ONCE, not once per edge — ``_role_neighbours`` deduplicates by destination for
|
|
321
|
+
exactly this reason (see its own docstring for the branch-corrupting crash an
|
|
322
|
+
undeduplicated count used to cause once two entailing edges landed on the same
|
|
323
|
+
node, since a self-pair then looks "mergeable" to the ≤-rule below). ``⊤`` as
|
|
324
|
+
the qualifying concept is handled specially (``_in_concept``): every
|
|
325
|
+
individual is trivially "in ⊤" whether or not the literal concept ``Top()``
|
|
326
|
+
was ever added to its label, matching how the rest of the tableau treats ⊤ as
|
|
327
|
+
inert.
|
|
328
|
+
|
|
329
|
+
**The three new completion rules**, all confined to their own functions and
|
|
330
|
+
consulted from :func:`_solve` in the same nondeterministic-then-generating
|
|
331
|
+
order the ⊔-rule and ∃-rule already use:
|
|
332
|
+
|
|
333
|
+
1. **≥-rule** (:func:`_find_atleast`, deterministic *generation*, mirrors the
|
|
334
|
+
∃-rule): if ``x : ≥n r.C`` and ``x`` does not already have ``n``
|
|
335
|
+
pairwise-distinct ``r``-neighbours in ``C``, generate ``n`` FRESH nodes,
|
|
336
|
+
each an ``r``-edge from ``x`` labelled ``C`` (plus the internalised TBox
|
|
337
|
+
concepts, like the ∃-rule), and mark them PAIRWISE distinct from each
|
|
338
|
+
other (never from anyone else). Marking them distinct is what protects an
|
|
339
|
+
already-satisfied ``≥``-restriction from ever being undone by a later
|
|
340
|
+
merge: the ≤-rule (below) only merges NON-distinct pairs, so two
|
|
341
|
+
witnesses generated together for the same restriction can never be
|
|
342
|
+
collapsed back into one.
|
|
343
|
+
2. **≤-rule** (:func:`_find_mergeable` + :func:`_merge`, NONDETERMINISTIC —
|
|
344
|
+
this is the mandatory nondeterminism the ≤-rule needs, see below): if
|
|
345
|
+
``x : ≤n r.C`` and ``x`` has more than ``n`` ``r``-neighbours in ``C``,
|
|
346
|
+
then — since :func:`_clash` has already ruled out ``n+1`` of them being
|
|
347
|
+
pairwise distinct (that is the clash condition itself) — pigeonhole
|
|
348
|
+
guarantees some pair among them is NOT marked distinct; merging THAT pair
|
|
349
|
+
is sound (nothing forces them apart) but WHICH non-distinct pair to merge
|
|
350
|
+
is not determined by the branch alone, so every candidate pair is tried
|
|
351
|
+
as a separate branch, backtracking on failure (this is the "which pair to
|
|
352
|
+
merge" nondeterminism the ≤-rule is known for). A merge redirects every
|
|
353
|
+
edge and re-parents every distinctness pair from the discarded node onto
|
|
354
|
+
the survivor, and unions their labels; the survivor is chosen to be the
|
|
355
|
+
NON-blockable (named/root) node when exactly one of the pair is blockable
|
|
356
|
+
(a generated node can always be discarded — see "Termination" below —
|
|
357
|
+
but a named individual should not be, since :func:`abox_consistent` and
|
|
358
|
+
friends only ever report a boolean, never inspect *which* name survived,
|
|
359
|
+
so the choice is safe either way, but keeping named nodes stable matches
|
|
360
|
+
the standard presentation and avoids surprising a caller who traces the
|
|
361
|
+
branch by hand).
|
|
362
|
+
3. **choose-rule** (:func:`_find_choose`, NONDETERMINISTIC, needed for the
|
|
363
|
+
≤-rule's COMPLETENESS): for ``x : ≥n r.C`` or ``x : ≤n r.C`` and an
|
|
364
|
+
``r``-neighbour ``y`` of ``x`` with NEITHER ``C`` nor ``¬C`` decided in
|
|
365
|
+
its label, branch on adding ``C`` or ``¬C`` to ``y``. Without this rule
|
|
366
|
+
the ≥-rule's "already satisfied?" check can only see neighbours with ``C``
|
|
367
|
+
LITERALLY in their label, so an undetermined pre-existing neighbour that
|
|
368
|
+
*could* have been reused as a witness gets ignored and a brand-new one
|
|
369
|
+
generated instead — inflating the neighbour count and risking a spurious
|
|
370
|
+
clash against an unrelated ``≤`` restriction that a complete algorithm
|
|
371
|
+
would have avoided by reusing the undetermined neighbour. This is exactly
|
|
372
|
+
the standard DL-handbook justification for the choose-rule (Baader &
|
|
373
|
+
Sattler, "An Overview of Tableau Algorithms for Description Logics",
|
|
374
|
+
*Studia Logica* 2001).
|
|
375
|
+
|
|
376
|
+
**Termination.** ``_find_atleast``/``_find_choose``/``_find_mergeable`` all
|
|
377
|
+
skip blocked nodes exactly like ``_find_exists`` (a blocked node's
|
|
378
|
+
consistency is guaranteed by its blocker's, so no rule — old or new — is
|
|
379
|
+
ever applied to one). The ≥-rule's generated labels are drawn from the same
|
|
380
|
+
finite subconcept/RBox-role closure ``AtLeast``/``AtMost`` are themselves
|
|
381
|
+
members of, so blocking triggers within a bounded number of generations
|
|
382
|
+
exactly as in the ALC(H+S) case. Merging never threatens this: the ≤-rule
|
|
383
|
+
only ever merges two ``r``-NEIGHBOURS OF THE SAME NODE ``x`` (both reached
|
|
384
|
+
by a forward edge out of ``x``), so it can only fold sibling subtrees
|
|
385
|
+
together, never create a back-edge toward an ancestor of ``x`` — the graph
|
|
386
|
+
built by ∃/≥-generation plus ≤-merging stays acyclic with strictly
|
|
387
|
+
non-decreasing "distance from a root", which is exactly the structural
|
|
388
|
+
property subset blocking's soundness/completeness proof needs (Horrocks &
|
|
389
|
+
Sattler 1999; Hollunder & Baader 1991 for the ALCQ-specific merge case):
|
|
390
|
+
merging never turns a blocked node's blocker into its own descendant, so
|
|
391
|
+
the blocker relationship a blocked node relies on stays valid. (A role
|
|
392
|
+
CYCLE among purely NAMED ABox individuals, e.g. ``a —r→ b —r→ a``, needs no
|
|
393
|
+
blocking argument at all: named individuals are never blocked in the first
|
|
394
|
+
place — see ``_blocked`` — and the branch they live on is finite by
|
|
395
|
+
construction, so saturation over it is a plain finite fixed-point
|
|
396
|
+
computation, exactly as in pre-Q ALC ABox reasoning. Since 0.30.0 a named
|
|
397
|
+
individual may not BLOCK either, which is a strictly smaller set of eligible
|
|
398
|
+
blockers and so leaves this argument untouched; ``_blocked``'s own docstring
|
|
399
|
+
says what that restriction protects.) This is deliberately
|
|
400
|
+
the *simplest* sufficient condition, not the most permissive one this
|
|
401
|
+
fragment could support (a tighter analysis could shrink the search space by
|
|
402
|
+
recognising DAG-equal nodes sooner), which is an appropriate trade for a
|
|
403
|
+
tool that must stay auditable, per this kit's own design principles.
|
|
404
|
+
|
|
405
|
+
Value restrictions (ObjectHasValue) — refused, and why no value-rule is sound here
|
|
406
|
+
-----------------------------------------------------------------------------------
|
|
407
|
+
:class:`~unicode_logic_kit.dl.concepts.HasValue` (``∃r.{a}``, OWL's
|
|
408
|
+
``ObjectHasValue(r a)``) names an INDIVIDUAL inside a concept: it is a
|
|
409
|
+
NOMINAL, the **O** of SHOIQ, in the one shape that looks harmless. This
|
|
410
|
+
tableau does NOT decide it. :func:`_reject_beyond_alc` refuses it, by name,
|
|
411
|
+
with :class:`UnsupportedConceptError`, through the same code path that refuses
|
|
412
|
+
a bare :class:`~unicode_logic_kit.dl.concepts.Nominal` — wherever it occurs: in
|
|
413
|
+
the queried concept, in a TBox inclusion, in a domain or range filler, in an
|
|
414
|
+
ABox concept assertion, positive or negated. Everything that reduces to
|
|
415
|
+
:func:`concept_satisfiable`/:func:`abox_consistent` refuses with it. The
|
|
416
|
+
construct itself is kept as a concept (its FOL image, the Manchester and
|
|
417
|
+
Functional-Syntax readers and writers and the external route all handle it);
|
|
418
|
+
only the in-house DECISION is gone.
|
|
419
|
+
|
|
420
|
+
An in-house **value-rule** was built for 0.30.0 and removed again, for the
|
|
421
|
+
reason below. It is recorded here so that a later session neither rebuilds it
|
|
422
|
+
nor has to rediscover the counterexample.
|
|
423
|
+
|
|
424
|
+
**The value-rule.** For an unblocked ``x`` with ``∃r.{a}`` in its label and no
|
|
425
|
+
entailed ``r``-edge to ``a``: add the NAMED node ``a`` (seeded with the
|
|
426
|
+
internalised TBox) and the edge ``x —r→ a``; ``¬∃r.{a}`` clashes with an
|
|
427
|
+
entailed ``r``-edge from ``x`` to ``a``. Two defects were found by a
|
|
428
|
+
differential fuzz of the tableau against the FOL image, and only the first can
|
|
429
|
+
be patched:
|
|
430
|
+
|
|
431
|
+
1. After a merge (``SameIndividual``, or the ≤-rule identifying two named
|
|
432
|
+
individuals) a concept still mentions the merged-away node by name, and the
|
|
433
|
+
rule re-created that node: ``a : ∃r.{c}``, ``a = c``, ``¬r(c, c)`` was
|
|
434
|
+
reported consistent. An alias map repaired it.
|
|
435
|
+
2. **A hole in the method.** Two axioms::
|
|
436
|
+
|
|
437
|
+
Asym(s) s is asymmetric
|
|
438
|
+
range(s) = ∃s.∃s.{b} ⊤ ⊑ ∀s.∃s.∃s.{b}
|
|
439
|
+
|
|
440
|
+
Hand-derived, in ANY model: take an ``s``-edge ``x → y``. The range axiom
|
|
441
|
+
puts ``y`` in ``∃s.∃s.{b}``, so there are ``y → z`` and ``z → b`` (``z`` is
|
|
442
|
+
in ``∃s.{b}``). The edge ``z → b`` is an ``s``-edge too, so the range axiom
|
|
443
|
+
puts its target ``b`` in ``∃s.∃s.{b}`` as well: ``b → w`` and ``w → b``.
|
|
444
|
+
That is ``s(b, w)`` together with ``s(w, b)``, which asymmetry forbids
|
|
445
|
+
(``w = b`` would be the loop ``s(b, b)``, which asymmetry entails to be
|
|
446
|
+
absent). So NO ``s``-edge can exist, and ``∃s.⊤`` is UNSATISFIABLE with
|
|
447
|
+
respect to the two axioms. The FOL image proves it (``api.prove`` over
|
|
448
|
+
:func:`~unicode_logic_kit.dl.translate.kb_to_fol`) and so does HermiT; the
|
|
449
|
+
tableau with the value-rule answered *satisfiable*. Among what it built
|
|
450
|
+
(traced on the 0.30.0 code) was the chain
|
|
451
|
+
``_root —s→ _x1 —s→ _x2 —s→ b —s→ _x3``: ``_x2 : ∃s.{b}`` got its edge to
|
|
452
|
+
``b``; the range axiom put ``∃s.∃s.{b}`` on ``b`` over that edge, which
|
|
453
|
+
generated ``_x3 : ∃s.{b}`` — and ``_x3``'s label equals ``_x2``'s, so
|
|
454
|
+
``_x3`` is BLOCKED. The value-rule never fires on a blocked node, so the
|
|
455
|
+
edge ``_x3 —s→ b`` was never added and the asymmetry clash
|
|
456
|
+
``s(b, _x3), s(_x3, b)`` was never seen.
|
|
457
|
+
|
|
458
|
+
This is not a bug in one function. A value restriction is the one construct
|
|
459
|
+
that gives a GENERATED node an edge BACK to a NAMED one, and two things in
|
|
460
|
+
this module assume that never happens: subset blocking (a blocked node is
|
|
461
|
+
interpreted by its blocker, so it needs no edges of its own — which is only
|
|
462
|
+
true while no rule has to add one), and the argument above that the role
|
|
463
|
+
box's clash conditions are EXACT because the model is read off the
|
|
464
|
+
unravelled tree (whose generated part has no edge back to anything already
|
|
465
|
+
in it). No patch to the rule restores either assumption.
|
|
466
|
+
|
|
467
|
+
**The rule that would repair this one case, and what has NOT been shown.**
|
|
468
|
+
Fire the value-rule on BLOCKED nodes too, so that ``_x3 —s→ b`` is added and
|
|
469
|
+
the clash is seen. It is written down here and deliberately NOT built, because
|
|
470
|
+
no soundness and completeness argument for it has been given in this module:
|
|
471
|
+
a blocked node that carries edges of its own is no longer interpreted purely
|
|
472
|
+
by its blocker, which is the premise of the blocking argument, and the same
|
|
473
|
+
question has to be answered for every other condition that reads the edges at
|
|
474
|
+
a named node (irreflexivity, role disjointness, negative role assertions, ``≤n``
|
|
475
|
+
counting at ``b``). Until that argument exists, the sound answer is the one
|
|
476
|
+
given: refuse.
|
|
477
|
+
|
|
478
|
+
**What decides a value restriction instead:** the FOL image
|
|
479
|
+
(:func:`~unicode_logic_kit.dl.translate.kb_to_fol`, then ``api.prove``), and the
|
|
480
|
+
external, HermiT-backed reasoner — all nine ``dl.external_*`` entry points
|
|
481
|
+
translate it (as owlready2's ``prop.value(individual)``).
|
|
482
|
+
|
|
483
|
+
**What this module keeps from that work:** ``SameIndividual`` merging
|
|
484
|
+
(:func:`_apply_same_assertions` — the ≤-rule's own merge, sound without any
|
|
485
|
+
nominal), negative role assertions and their clash condition, the rule that
|
|
486
|
+
only a GENERATED node may block (see :func:`_blocked`), and the anonymous
|
|
487
|
+
root's name ``"_root"`` (see :func:`concept_satisfiable`). Nothing reads an
|
|
488
|
+
individual name out of a concept any more, so a merge needs no alias map on the
|
|
489
|
+
branch: :func:`_apply_same_assertions` keeps the one it needs, locally.
|
|
490
|
+
|
|
491
|
+
Domain and range axioms — ordinary GCIs, a direct FOL image
|
|
492
|
+
--------------------------------------------------------------
|
|
493
|
+
``ObjectPropertyDomain(P C)`` IS the GCI ``∃P.⊤ ⊑ C`` and
|
|
494
|
+
``ObjectPropertyRange(P C)`` is ``⊤ ⊑ ∀P.C``, so both are internalised (in
|
|
495
|
+
``_new_branch``, where the functional-role GCI already lives) and decided by
|
|
496
|
+
the existing ⊓/⊔/∃/∀ rules with NO new machinery and no change to the
|
|
497
|
+
termination argument. The ∀-rule's sub-role closure gives the right
|
|
498
|
+
propagation for free: a sub-``P`` edge IS a ``P`` edge, so its target must be
|
|
499
|
+
in a range axiom's ``C``.
|
|
500
|
+
|
|
501
|
+
Worth one line in passing: a RANGE axiom is a pure label-growing
|
|
502
|
+
internalisation (cheap), while a DOMAIN axiom is a genuine disjunction
|
|
503
|
+
(``∀P.⊥ ⊔ C``) and costs one extra ⊔ branch per node.
|
|
504
|
+
|
|
505
|
+
The FOL image is deliberately NOT the GCI rewrite: see
|
|
506
|
+
:meth:`TBox.add_role_domain`.
|
|
507
|
+
|
|
508
|
+
Same-individual assertions — decided by merging
|
|
509
|
+
--------------------------------------------------
|
|
510
|
+
``SameIndividual(a b)`` is decided by genuine node MERGING, not approximated:
|
|
511
|
+
:func:`abox_consistent` applies the assertions after the whole branch is set
|
|
512
|
+
up and before any completion rule runs, closed under the equivalence they
|
|
513
|
+
generate (see :func:`_apply_same_assertions`), reusing the ≤-rule's own
|
|
514
|
+
:func:`_merge`. ``instance_check`` then works with no further change, because
|
|
515
|
+
``a = b`` with ``a : C`` puts both ``C`` and ``¬C`` in the survivor's label,
|
|
516
|
+
which is the existing atomic clash.
|
|
517
|
+
|
|
518
|
+
Negative role assertions — a clash condition over forbidden edges
|
|
519
|
+
--------------------------------------------------------------------
|
|
520
|
+
``NegativeObjectPropertyAssertion(r a b)`` is seeded into
|
|
521
|
+
``_Branch.negative_edges`` (copied in :meth:`_Branch.copy`, and re-mapped by
|
|
522
|
+
:func:`_merge` exactly like a real edge, so ``assert_same(b, c)`` with
|
|
523
|
+
``r(a, c)`` and ``¬r(a, b)`` clashes after the merge), and :func:`_clash`
|
|
524
|
+
reports a clash when the branch has an entailed edge between the two. Refused
|
|
525
|
+
by name on a non-simple role, for the reason
|
|
526
|
+
:func:`_non_simple_edge_message` gives.
|
|
527
|
+
|
|
528
|
+
Inverse roles and nominals (I, O) — refused, not decided
|
|
529
|
+
-----------------------------------------------------------
|
|
530
|
+
:mod:`unicode_logic_kit.dl.concepts` also has
|
|
531
|
+
:class:`~unicode_logic_kit.dl.concepts.InverseRole` (``r⁻``, a role EXPRESSION
|
|
532
|
+
usable wherever a plain role name is) and
|
|
533
|
+
:class:`~unicode_logic_kit.dl.concepts.Nominal` (``{a}``) — the **I** and **O**
|
|
534
|
+
of SHIQ/SHOIQ. (:class:`~unicode_logic_kit.dl.concepts.HasValue` is the nominal in
|
|
535
|
+
its disguise as a value restriction; see "Value restrictions (ObjectHasValue)"
|
|
536
|
+
above for why it is refused too.)
|
|
537
|
+
This tableau does NOT decide either: inverse roles break the
|
|
538
|
+
subset-blocking argument above (blocking needs a node's label to depend only
|
|
539
|
+
on what was pushed FORWARD from its ancestors — see "Termination" above — and
|
|
540
|
+
an inverse role lets a successor's label depend on what lies BACKWARD across
|
|
541
|
+
an edge, which subset blocking's soundness/completeness proof does not cover;
|
|
542
|
+
this is exactly why C45, an in-house SHIQ/SHOIQ tableau rewrite, was
|
|
543
|
+
rejected), and nominals need an equality/merging machinery over NAMED
|
|
544
|
+
individuals this tableau has none of (``_Branch`` has no notion that two
|
|
545
|
+
DIFFERENT node names might denote the SAME nominal-forced individual).
|
|
546
|
+
Rather than attempt either and risk an unsound or silently-incomplete result,
|
|
547
|
+
:func:`_reject_beyond_alc` walks every concept :func:`concept_satisfiable`/
|
|
548
|
+
:func:`abox_consistent` are about to reason over — the query concept (or
|
|
549
|
+
every ABox concept assertion) plus every TBox inclusion — and raises
|
|
550
|
+
:class:`UnsupportedConceptError`, NAMING the exact construct, before the
|
|
551
|
+
tableau starts. :func:`~unicode_logic_kit.dl.concepts.nnf`'s own top-level
|
|
552
|
+
dispatch independently refuses a bare ``Nominal`` too (see its docstring) —
|
|
553
|
+
belt-and-suspenders, not redundant: ``_reject_beyond_alc`` is the guard that
|
|
554
|
+
actually runs first and produces the precise :class:`UnsupportedConceptError`
|
|
555
|
+
message; ``nnf``'s refusal is what stops a ``Nominal`` from ever being
|
|
556
|
+
silently treated as an ordinary :class:`~unicode_logic_kit.dl.concepts.Atomic`
|
|
557
|
+
concept if ``_reject_beyond_alc`` is ever bypassed (a future call site, a
|
|
558
|
+
missed nested occurrence) — see ``tests/test_dl_alc.py``'s dedicated
|
|
559
|
+
regression test, which calls ``nnf`` directly to prove this second line of
|
|
560
|
+
defense still holds on its own. Use
|
|
561
|
+
:mod:`unicode_logic_kit.dl.owl_reasoner`'s external, HermiT-backed reasoner —
|
|
562
|
+
which DOES decide the full ALCHQ **+ I + O** fragment — for a concept that
|
|
563
|
+
genuinely needs either construct.
|
|
564
|
+
|
|
565
|
+
The data layer — refused here, translated by the FOL image
|
|
566
|
+
------------------------------------------------------------
|
|
567
|
+
OWL 2's DATA half (data properties, datatypes, literals and facets — see
|
|
568
|
+
:mod:`unicode_logic_kit.dl.datatypes`) is NOT decided by this tableau. It has no
|
|
569
|
+
data domain: deciding ``∃d.xsd:integer[≥ 5] ⊓ ∀d.xsd:integer[≤ 3]`` needs the
|
|
570
|
+
interval algebra of the datatype, deciding ``≥2 d.xsd:boolean`` needs the
|
|
571
|
+
CARDINALITY of its value space (exactly 2), and two literals of one datatype
|
|
572
|
+
denote different data values, so no ≤-rule may ever merge two data nodes. None
|
|
573
|
+
of that is a rule this module has, and a tableau that skipped it would answer
|
|
574
|
+
for a WEAKER knowledge base. So it refuses, by name, at both levels:
|
|
575
|
+
|
|
576
|
+
* every data AXIOM kind (``SubDataPropertyOf``, ``DisjointDataProperties``,
|
|
577
|
+
``FunctionalDataProperty``, ``DataPropertyDomain``, ``DataPropertyRange``,
|
|
578
|
+
``DatatypeDefinition``, ``DataPropertyAssertion``,
|
|
579
|
+
``NegativeDataPropertyAssertion``) is a ``"refused"`` row of
|
|
580
|
+
:data:`_AXIOM_KINDS`, so :func:`_reject_unsupported` raises
|
|
581
|
+
:class:`UnsupportedAxiomError` for it;
|
|
582
|
+
* every data CONCEPT (``DataExists``, ``DataForAll``, ``DataHasValue``,
|
|
583
|
+
``DataAtLeast``, ``DataAtMost``), in a query or in an inclusion, makes
|
|
584
|
+
:func:`_reject_beyond_alc` raise :class:`UnsupportedConceptError` — and
|
|
585
|
+
:func:`~unicode_logic_kit.dl.concepts.nnf` refuses it too, independently,
|
|
586
|
+
exactly as it refuses a bare ``Nominal``.
|
|
587
|
+
|
|
588
|
+
The FOL image translates all of it (guarded ONE-sorted first-order logic over
|
|
589
|
+
the reserved predicates ``OwlThing``/``OwlData``; the sorts are side axioms of
|
|
590
|
+
:func:`~unicode_logic_kit.dl.translate.kb_to_fol`), so for the data layer the
|
|
591
|
+
route that answers is ``api.prove`` over that image. The cross-check that
|
|
592
|
+
usually runs tableau-against-prover runs the other way here: the image against
|
|
593
|
+
hand-derived verdicts, and the facet arithmetic against ``atp.z3_arith``.
|
|
594
|
+
|
|
595
|
+
The axiom-kind table: what each route does with each axiom kind
|
|
596
|
+
----------------------------------------------------------------
|
|
597
|
+
This kit answers a question about a knowledge base by TWO routes — this
|
|
598
|
+
tableau, and the FOL image :mod:`unicode_logic_kit.dl.translate` builds for
|
|
599
|
+
``api.prove``. The two must never answer the same question differently, and
|
|
600
|
+
"the FOL route handles it while the tableau quietly ignores it" is the shape
|
|
601
|
+
that failure takes. :data:`_AXIOM_KINDS` is what makes that impossible to do
|
|
602
|
+
by accident: ONE row per axiom kind a :class:`TBox`/:class:`ABox` can hold,
|
|
603
|
+
and the row carries four facts.
|
|
604
|
+
|
|
605
|
+
* ``field`` — the :class:`TBox`/:class:`ABox` attribute that stores it, plus
|
|
606
|
+
the ``builder`` method that fills it.
|
|
607
|
+
* ``tableau`` — what THIS module does with it: ``"internalised"`` (it IS a
|
|
608
|
+
general concept inclusion — written as one, or exactly equivalent to one —
|
|
609
|
+
turned into a label concept every node carries, so no new rule and subset
|
|
610
|
+
blocking's termination argument is untouched. ``SubClassOf`` and
|
|
611
|
+
``EquivalentClasses`` are turned by :meth:`TBox.internalized`;
|
|
612
|
+
``FunctionalObjectProperty`` (``⊤ ⊑ ≤1 P.⊤``) and
|
|
613
|
+
``ObjectPropertyDomain``/``ObjectPropertyRange`` (``∃P.⊤ ⊑ C``, ``⊤ ⊑ ∀P.C``)
|
|
614
|
+
by :func:`_new_branch`, which :meth:`TBox.internalized` deliberately leaves
|
|
615
|
+
to it),
|
|
616
|
+
``"rule"`` (dedicated machinery — a completion rule, a clash condition, or
|
|
617
|
+
initial-branch seeding — whose termination argument is written in this
|
|
618
|
+
docstring), or ``"refused"`` (:func:`_reject_unsupported` raises
|
|
619
|
+
:class:`UnsupportedAxiomError`, naming it).
|
|
620
|
+
* ``fol`` — what the FOL image does with it: ``"fol"``
|
|
621
|
+
(:func:`~unicode_logic_kit.dl.translate.kb_to_fol` renders it),
|
|
622
|
+
``"two-sorted"`` (only the two-sorted entry point renders it) or ``"none"``.
|
|
623
|
+
* ``part`` — WHERE in the FOL image it lands: ``"concepts"`` (the
|
|
624
|
+
concept-inclusion image :func:`~unicode_logic_kit.dl.translate.tbox_to_fol`
|
|
625
|
+
builds), ``"side"`` (a side axiom, a premise and never a conjunct of the
|
|
626
|
+
knowledge-base formula), ``"assertions"`` (the ABox image) or ``"none"``.
|
|
627
|
+
:meth:`TBox.has_side_axioms` reads this column, which is how
|
|
628
|
+
``tbox_to_fol``'s "you are dropping half the TBox" guard is DERIVED from the
|
|
629
|
+
table rather than hand-written over whichever fields existed that week.
|
|
630
|
+
|
|
631
|
+
Two consequences follow mechanically, and they are the whole point of the
|
|
632
|
+
table. First, builders NEVER refuse: :meth:`TBox.add` and friends accept every
|
|
633
|
+
kind, because a builder that refuses cannot hold an ontology read from a file
|
|
634
|
+
(and the honest answer to "is this knowledge base consistent?" is an answer,
|
|
635
|
+
not a constructor exception — see :meth:`ABox.assert_distinct`). The refusal
|
|
636
|
+
is raised at QUERY time and at RENDER time. Second, at query time there is ONE
|
|
637
|
+
axiom-level guard, :func:`_reject_unsupported`. It is reached through
|
|
638
|
+
:func:`_reject_role_box` (which adds the value-level checks) as the first
|
|
639
|
+
statement of :func:`concept_satisfiable`, of :func:`abox_consistent` and of
|
|
640
|
+
:func:`~unicode_logic_kit.dl.classification.classify`, and through
|
|
641
|
+
:func:`_reject_inputs` (the same call plus the concept guard over every stored
|
|
642
|
+
class expression) at the top of :func:`instance_retrieval`, :func:`realize`,
|
|
643
|
+
:func:`realize_all` and, again, ``classify``. :func:`instance_check`,
|
|
644
|
+
:func:`subsumes`, :func:`equivalent` and :func:`concept_unsatisfiable` have no
|
|
645
|
+
guard of their own: each reduces to :func:`abox_consistent` or
|
|
646
|
+
:func:`concept_satisfiable` with something to decide, and inherits it, for the
|
|
647
|
+
reason :func:`_reject_beyond_alc`'s docstring already gives. Inheriting is only
|
|
648
|
+
a guard WHEN THERE IS SOMETHING TO REDUCE, which is why the other four carry
|
|
649
|
+
their own: ``realize`` with an empty vocabulary, ``realize_all`` and
|
|
650
|
+
``instance_retrieval`` on an empty ABox and ``classify`` with fewer than two
|
|
651
|
+
names make no call to either function, and so would return before any guard
|
|
652
|
+
had run.
|
|
653
|
+
|
|
654
|
+
``tests/test_dl_route_agreement.py`` enforces the table. Its meta-test
|
|
655
|
+
``test_every_tbox_and_abox_field_has_a_row`` goes red the moment a field is
|
|
656
|
+
added to :class:`TBox` or :class:`ABox` without a row, so a new axiom kind
|
|
657
|
+
cannot be shipped with the two routes silently disagreeing about it.
|
|
658
|
+
|
|
659
|
+
Search order
|
|
660
|
+
------------
|
|
661
|
+
Every choice the completion rules leave open is made by a fixed rule that depends
|
|
662
|
+
on the order things were INSERTED into the branch and on nothing else — not on a
|
|
663
|
+
hash, not on a random number — so a run is reproducible and a step count
|
|
664
|
+
(:data:`MAX_STEPS` counts them) means something. The rules are complete under any
|
|
665
|
+
order, so the order never changes a verdict; it changes how much of the search
|
|
666
|
+
space is visited before the branch closes.
|
|
667
|
+
|
|
668
|
+
* **Which disjunction is split** (:func:`_find_disjunction`). A disjunction is
|
|
669
|
+
FORCED when at most one of its alternatives (the leaves of the nested ``⊔``) is
|
|
670
|
+
not already contradicted by its node's label — ``⊥``, or an atom whose
|
|
671
|
+
complement is in the label. Splitting a forced disjunction costs one step: the
|
|
672
|
+
branch of a contradicted alternative closes at once and the other alternative
|
|
673
|
+
is the only way on. So the first forced disjunction is split first, and when none
|
|
674
|
+
is forced, the first unresolved one. "First" is in node creation order and, in a
|
|
675
|
+
label, in the order the label received its concepts. The pigeonhole concept
|
|
676
|
+
PHP(n+1, n) (n+1 pigeons, n holes, every pigeon in some hole, no two in one
|
|
677
|
+
hole) shows the difference: splitting the "no two share hole k" clauses in the
|
|
678
|
+
order they were inserted needs about 9 000 steps for four pigeons and does not
|
|
679
|
+
finish within the default budget for five, while splitting a forced disjunction
|
|
680
|
+
first propagates each pigeon's hole into the clauses of that hole and needs about
|
|
681
|
+
500 and 25 000.
|
|
682
|
+
* **Which rule fires.** Disjunction, choose, merge, ∃, ≥ — in that order, the
|
|
683
|
+
deterministic ⊓ / ∀ rules first. Within a rule, nodes are taken oldest first
|
|
684
|
+
and a node's concepts in insertion order.
|
|
685
|
+
* **Which pair a ≤-restriction merges first.** The ≤-rule tries EVERY candidate
|
|
686
|
+
pair as a branch of its own (see "Qualified number restrictions"), so on a
|
|
687
|
+
branch that closes the order of the pairs changes nothing; the pairs are in
|
|
688
|
+
neighbour order.
|
|
689
|
+
|
|
690
|
+
Public API: :class:`TBox`, :class:`ABox`, :func:`concept_satisfiable`,
|
|
691
|
+
:func:`subsumes`, :func:`equivalent`, :func:`concept_unsatisfiable`,
|
|
692
|
+
:func:`abox_consistent`, :func:`instance_check`, :func:`instance_retrieval`,
|
|
693
|
+
:func:`realize`, :func:`realize_all`, :class:`NonSimpleRoleError`,
|
|
694
|
+
:class:`UnsupportedConceptError`, :class:`UnsupportedAxiomError`,
|
|
695
|
+
:class:`RoleExpressionError`.
|
|
696
|
+
"""
|
|
697
|
+
|
|
698
|
+
from collections.abc import Set as _AbstractSet
|
|
699
|
+
from dataclasses import dataclass, field
|
|
700
|
+
from dataclasses import fields as dataclass_fields
|
|
701
|
+
from itertools import combinations
|
|
702
|
+
from typing import Dict, FrozenSet, Iterable, List, Optional, Sequence, Set, Tuple, Union
|
|
703
|
+
|
|
704
|
+
from .concepts import (
|
|
705
|
+
Concept, Top, Bottom, Atomic, Not, And, Or, Exists, ForAll, AtLeast, AtMost,
|
|
706
|
+
InverseRole, Nominal, HasValue, DATA_CONCEPTS, DataHasValue, DataForAll,
|
|
707
|
+
DataAtLeast, DataAtMost, nnf,
|
|
708
|
+
)
|
|
709
|
+
from .datatypes import (
|
|
710
|
+
BUILTIN_DATATYPES, DataRange, Datatype, Literal, UnsupportedDatatypeError,
|
|
711
|
+
canonical_datatype_name, datarange_datatypes,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
MAX_STEPS = 1_000_000
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class NonSimpleRoleError(ValueError):
|
|
718
|
+
"""Raised when an ``AtLeast``/``AtMost`` (qualified number restriction) targets a
|
|
719
|
+
NON-SIMPLE role — one that is transitive, or has a transitive sub-role via the
|
|
720
|
+
RBox. Number restrictions on non-simple roles make the logic undecidable (see
|
|
721
|
+
"Qualified number restrictions" in the module docstring), so this kit refuses the
|
|
722
|
+
combination by name rather than risk an unsound or non-terminating result.
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
class UnsupportedConceptError(ValueError):
|
|
727
|
+
"""Raised when a concept reachable from a :func:`concept_satisfiable`/
|
|
728
|
+
:func:`abox_consistent` call contains a :class:`~unicode_logic_kit.dl.concepts.Nominal`,
|
|
729
|
+
a :class:`~unicode_logic_kit.dl.concepts.HasValue` (a nominal in disguise) or an
|
|
730
|
+
:class:`~unicode_logic_kit.dl.concepts.InverseRole`-valued role — the **I**
|
|
731
|
+
(inverse roles) and **O** (nominals) beyond this tableau's **ALCHQ** fragment
|
|
732
|
+
(see "Inverse roles and nominals (I, O) — refused, not decided" and "Value
|
|
733
|
+
restrictions (ObjectHasValue)" in the module docstring). Raised by
|
|
734
|
+
:func:`_reject_beyond_alc`, named for the exact offending construct, before
|
|
735
|
+
the tableau ever runs — never a silent, too-permissive approximation. Use
|
|
736
|
+
:mod:`unicode_logic_kit.dl.owl_reasoner`'s external, HermiT-backed reasoner, or
|
|
737
|
+
the FOL image (:func:`~unicode_logic_kit.dl.translate.kb_to_fol` with
|
|
738
|
+
``api.prove``), to decide a concept that needs any of these constructs.
|
|
739
|
+
"""
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
class UnsupportedAxiomError(ValueError):
|
|
743
|
+
"""Raised by :func:`concept_satisfiable`/:func:`abox_consistent` when the
|
|
744
|
+
:class:`TBox`/:class:`ABox` they were handed carries an axiom KIND no
|
|
745
|
+
in-house tableau rule decides — a ``"refused"`` row of
|
|
746
|
+
:data:`_AXIOM_KINDS` (see "The axiom-kind table" in the module docstring).
|
|
747
|
+
|
|
748
|
+
Deliberately NOT a reuse of :class:`UnsupportedConceptError`, which is
|
|
749
|
+
about a CONCEPT reachable from the query (a nominal, an inverse role): the
|
|
750
|
+
two have different remedies, and a caller that catches the concept-level
|
|
751
|
+
refusal should not silently start catching axiom-level ones as well.
|
|
752
|
+
|
|
753
|
+
The message names EVERY refused kind present, with its count, in one go —
|
|
754
|
+
a real ontology hits several at once and a one-at-a-time loop is a bad
|
|
755
|
+
experience — and points at the two routes that DO answer: the external,
|
|
756
|
+
HermiT-backed reasoner (``dl.external_*``) and the FOL image
|
|
757
|
+
(:func:`~unicode_logic_kit.dl.translate.kb_to_fol` + ``api.prove``).
|
|
758
|
+
"""
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
class RoleExpressionError(ValueError):
|
|
762
|
+
"""Raised when a role-box builder (``TBox.add_role_inclusion`` and the rest)
|
|
763
|
+
is handed something that is not a usable role in that POSITION — and, as a
|
|
764
|
+
second line of defence behind the builders, by the ONE validation of a
|
|
765
|
+
stored role box (:func:`_validate_role_box`) that BOTH the tableau's shared
|
|
766
|
+
guard and :func:`~unicode_logic_kit.dl.translate.rbox_to_fol` run, so a
|
|
767
|
+
``TBox`` built through the dataclass constructor or mutated in place fails
|
|
768
|
+
loudly on either route instead of answering (tableau) or rendering a
|
|
769
|
+
nonsense atom (FOL). It is also what the query-time and translation-time
|
|
770
|
+
guards raise for an OWL 2 built-in property name inside a class expression
|
|
771
|
+
or an ABox assertion, which no builder sees (builders never refuse).
|
|
772
|
+
|
|
773
|
+
Four cases, each named in the message with the spelling to use instead:
|
|
774
|
+
|
|
775
|
+
* a value that is not a role at all — a tuple or list (a caller reaching for
|
|
776
|
+
a property chain: :meth:`TBox.add_role_chain`), or any other type;
|
|
777
|
+
* an :class:`~unicode_logic_kit.dl.concepts.InverseRole` in a position that
|
|
778
|
+
takes a plain role NAME. What to write instead depends on the axiom, and
|
|
779
|
+
the message says it: ``Trans(r⁻)``, ``Sym(r⁻)``, ``Asym(r⁻)``,
|
|
780
|
+
``Irr(r⁻)`` and ``Refl(r⁻)`` ARE the plain-name axioms (each is preserved
|
|
781
|
+
by taking the converse); ``Func(r⁻)`` is ``InvFunc(r)`` and the other way
|
|
782
|
+
round, a range axiom on ``r⁻`` is the domain axiom on ``r``, and for
|
|
783
|
+
disjointness and chains no plain-name spelling exists. (An
|
|
784
|
+
``InverseRole`` on either side of a role INCLUSION is accepted, and
|
|
785
|
+
rendered correctly, since ``r ⊑ s⁻`` is a genuinely different axiom from
|
|
786
|
+
``r ⊑ s``.)
|
|
787
|
+
* one of the four OWL 2 built-in role names (:data:`RESERVED_ROLE_SPELLINGS`)
|
|
788
|
+
— see "The OWL 2 built-in roles" in the module docstring;
|
|
789
|
+
* a role called ``=`` or ``≠``, which the FOL image would render as the
|
|
790
|
+
equality atom.
|
|
791
|
+
|
|
792
|
+
This is NOT the "builders never refuse" rule being broken (see "The
|
|
793
|
+
axiom-kind table"): that rule is about axiom KINDS, every one of which a
|
|
794
|
+
``TBox`` must be able to hold because a parser fills it from a file. These
|
|
795
|
+
three are malformed ARGUMENTS — there is no axiom to hold — and the
|
|
796
|
+
exception landing on the call that is wrong is the whole point.
|
|
797
|
+
"""
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
#: Every spelling of an OWL 2 built-in property name, mapped to its canonical
|
|
801
|
+
#: abbreviated form. All four built-ins (top/bottom x object/data) in both the
|
|
802
|
+
#: abbreviated and the full-IRI form, in ONE table: the data-property pair is
|
|
803
|
+
#: shared with the data-property layer, and two tables would drift.
|
|
804
|
+
RESERVED_ROLE_SPELLINGS: Dict[str, str] = {
|
|
805
|
+
"owl:topObjectProperty": "owl:topObjectProperty",
|
|
806
|
+
"http://www.w3.org/2002/07/owl#topObjectProperty": "owl:topObjectProperty",
|
|
807
|
+
"owl:bottomObjectProperty": "owl:bottomObjectProperty",
|
|
808
|
+
"http://www.w3.org/2002/07/owl#bottomObjectProperty": "owl:bottomObjectProperty",
|
|
809
|
+
"owl:topDataProperty": "owl:topDataProperty",
|
|
810
|
+
"http://www.w3.org/2002/07/owl#topDataProperty": "owl:topDataProperty",
|
|
811
|
+
"owl:bottomDataProperty": "owl:bottomDataProperty",
|
|
812
|
+
"http://www.w3.org/2002/07/owl#bottomDataProperty": "owl:bottomDataProperty",
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
#: The two built-ins denoting the UNIVERSAL property (every pair), canonical
|
|
816
|
+
#: spelling. An inclusion INTO one of these is a tautology.
|
|
817
|
+
RESERVED_TOP_ROLES: FrozenSet[str] = frozenset(
|
|
818
|
+
{"owl:topObjectProperty", "owl:topDataProperty"})
|
|
819
|
+
|
|
820
|
+
#: The two built-ins denoting the EMPTY property, canonical spelling. An
|
|
821
|
+
#: inclusion OUT OF one of these is a tautology.
|
|
822
|
+
RESERVED_BOTTOM_ROLES: FrozenSet[str] = frozenset(
|
|
823
|
+
{"owl:bottomObjectProperty", "owl:bottomDataProperty"})
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def reserved_role(name) -> Optional[str]:
|
|
827
|
+
"""The canonical built-in name ``name`` spells, or None if it is an ordinary
|
|
828
|
+
role name (or not a name at all).
|
|
829
|
+
"""
|
|
830
|
+
if not isinstance(name, str):
|
|
831
|
+
return None
|
|
832
|
+
# Both OWL readers store an IRI without its angle brackets; a hand-built
|
|
833
|
+
# name may still carry one pair, and it is the same built-in property.
|
|
834
|
+
if len(name) > 2 and name.startswith("<") and name.endswith(">"):
|
|
835
|
+
name = name[1:-1]
|
|
836
|
+
return RESERVED_ROLE_SPELLINGS.get(name)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def is_tautological_role_inclusion(sub_role, super_role) -> bool:
|
|
840
|
+
"""True iff ``sub_role ⊑ super_role`` is valid in EVERY interpretation
|
|
841
|
+
because of a built-in role name: a reserved TOP name as the super-role
|
|
842
|
+
(everything is included in the universal property), or a reserved BOTTOM
|
|
843
|
+
name as the sub-role (the empty property is included in everything).
|
|
844
|
+
|
|
845
|
+
``owl:topObjectProperty ⊑ P`` ("P is universal") and
|
|
846
|
+
``P ⊑ owl:bottomObjectProperty`` ("P is empty") are deliberately NOT in
|
|
847
|
+
this class: both are genuine constraints, and both are refused by name
|
|
848
|
+
(see "The OWL 2 built-in roles" in the module docstring).
|
|
849
|
+
"""
|
|
850
|
+
return (reserved_role(super_role) in RESERVED_TOP_ROLES
|
|
851
|
+
or reserved_role(sub_role) in RESERVED_BOTTOM_ROLES)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
#: Role names the FOL image cannot carry as ordinary predicates: it renders an
|
|
855
|
+
#: atom named ``=`` / ``≠`` as the equality / disequality of its two arguments
|
|
856
|
+
#: (that is how ``⊤`` and ``⊥`` are spelled), so a role called that would be
|
|
857
|
+
#: silently read as a different axiom. No OWL source can produce such a name.
|
|
858
|
+
_EQUALITY_ROLE_NAMES: FrozenSet[str] = frozenset({"=", "≠"})
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _inverse_advice(kind: Optional[str], role: str) -> str:
|
|
862
|
+
"""What to write INSTEAD of ``r⁻`` where an axiom of OWL keyword ``kind``
|
|
863
|
+
takes a plain role name — and only what is TRUE of that axiom.
|
|
864
|
+
|
|
865
|
+
Five characteristics are preserved by taking the converse
|
|
866
|
+
(``Trans(r⁻) ⇔ Trans(r)``, and the same for symmetric, asymmetric,
|
|
867
|
+
irreflexive and reflexive), so for those the plain spelling IS the same
|
|
868
|
+
axiom. The others are NOT: ``Func(r⁻)`` says an element has at most one
|
|
869
|
+
r-PREDECESSOR, which is ``InvFunc(r)``, and the other way round; a range
|
|
870
|
+
axiom on ``r⁻`` is the domain axiom on ``r``. For disjointness and chains
|
|
871
|
+
no plain-name spelling exists, and saying "pass r instead" would silently
|
|
872
|
+
build a DIFFERENT axiom — the defect this table replaces.
|
|
873
|
+
"""
|
|
874
|
+
r = repr(role)
|
|
875
|
+
same = {
|
|
876
|
+
"TransitiveObjectProperty": "Trans", "SymmetricObjectProperty": "Sym",
|
|
877
|
+
"AsymmetricObjectProperty": "Asym", "IrreflexiveObjectProperty": "Irr",
|
|
878
|
+
"ReflexiveObjectProperty": "Refl",
|
|
879
|
+
}
|
|
880
|
+
if kind in same:
|
|
881
|
+
return (f"{same[kind]}(r⁻) holds exactly when {same[kind]}(r) does "
|
|
882
|
+
f"(this characteristic is preserved by taking the converse), "
|
|
883
|
+
f"so the plain spelling is the same axiom: pass {r} instead.")
|
|
884
|
+
if kind == "FunctionalObjectProperty":
|
|
885
|
+
return (f"Functional(r⁻) is NOT Functional(r): it says an element has "
|
|
886
|
+
f"at most one r-PREDECESSOR, which is InverseFunctional(r). "
|
|
887
|
+
f"Use dl.TBox.add_inverse_functional_role({r}).")
|
|
888
|
+
if kind == "InverseFunctionalObjectProperty":
|
|
889
|
+
return (f"InverseFunctional(r⁻) is NOT InverseFunctional(r): it says "
|
|
890
|
+
f"an element has at most one r-SUCCESSOR, which is "
|
|
891
|
+
f"Functional(r). Use dl.TBox.add_functional_role({r}).")
|
|
892
|
+
if kind == "ObjectPropertyDomain":
|
|
893
|
+
return (f"Domain(r⁻, C) is the RANGE axiom on r, not the domain axiom: "
|
|
894
|
+
f"use dl.TBox.add_role_range({r}, C).")
|
|
895
|
+
if kind == "ObjectPropertyRange":
|
|
896
|
+
return (f"Range(r⁻, C) is the DOMAIN axiom on r, not the range axiom: "
|
|
897
|
+
f"use dl.TBox.add_role_domain({r}, C).")
|
|
898
|
+
if kind == "DisjointObjectProperties":
|
|
899
|
+
return (f"Disjoint(p, q⁻) is NOT Disjoint(p, q) — it forbids p(x, y) "
|
|
900
|
+
f"together with q(y, x) — and no spelling over plain role "
|
|
901
|
+
f"names states it, so this kit cannot hold it as an axiom. "
|
|
902
|
+
f"Pass it to api.prove as the FOL premise "
|
|
903
|
+
f"∀x ∀y ¬(p(x, y) ∧ q(y, x)).")
|
|
904
|
+
if kind == "InverseObjectProperties":
|
|
905
|
+
return (f"Inverses cancel: an inverse role on exactly ONE side of "
|
|
906
|
+
f"InverseObjectProperties makes it the equality p ≡ q (use "
|
|
907
|
+
f"dl.TBox.add_equivalent_roles(p, q)), and on BOTH sides "
|
|
908
|
+
f"InverseObjectProperties(p⁻, q⁻) is InverseObjectProperties"
|
|
909
|
+
f"(p, q) over the plain names.")
|
|
910
|
+
if kind == "EquivalentObjectProperties":
|
|
911
|
+
return (f"State each direction with dl.TBox.add_role_inclusion, which "
|
|
912
|
+
f"does accept an inverse role: add_role_inclusion(p, r⁻) and "
|
|
913
|
+
f"add_role_inclusion(r⁻, p).")
|
|
914
|
+
if kind == "ObjectPropertyChain":
|
|
915
|
+
return (f"An inverse role inside a property chain has no spelling over "
|
|
916
|
+
f"plain role names, so this kit cannot hold it as an axiom. "
|
|
917
|
+
f"Pass it to api.prove as the FOL premise, e.g. "
|
|
918
|
+
f"p ∘ q⁻ ⊑ s is ∀x ∀y ∀z (p(x, y) ∧ q(z, y) → s(x, z)).")
|
|
919
|
+
if kind in ("ObjectPropertyAssertion", "NegativeObjectPropertyAssertion"):
|
|
920
|
+
return (f"r⁻(a, b) is r(b, a): swap the two individuals and pass "
|
|
921
|
+
f"{r}.")
|
|
922
|
+
if kind is not None and "Data" in kind:
|
|
923
|
+
return ("A DATA property has no inverse (only an object property "
|
|
924
|
+
"does); pass the data property's own name.")
|
|
925
|
+
return ("This position takes a plain role name, and no rewrite is offered "
|
|
926
|
+
"because the right one depends on which axiom is meant — it is "
|
|
927
|
+
"NOT always the plain name (Functional(r⁻) is InverseFunctional(r), "
|
|
928
|
+
"for one).")
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _equality_name_message(where: str, name: str) -> str:
|
|
932
|
+
"""The refusal for a role (or property) called ``=`` or ``≠`` — see
|
|
933
|
+
:data:`_EQUALITY_ROLE_NAMES`."""
|
|
934
|
+
return (f"{where}: {name!r} is the name of the "
|
|
935
|
+
f"{'EQUALITY' if name == '=' else 'DISEQUALITY'} atom the FOL image "
|
|
936
|
+
f"renders (it is how ⊤ and ⊥ are spelled), not a role name: every "
|
|
937
|
+
f"image of an axiom over it would be printed as "
|
|
938
|
+
f"{'x = y' if name == '=' else 'x ≠ y'}, a different axiom, and no "
|
|
939
|
+
f"OWL ontology can produce such a name. Give the role an ordinary "
|
|
940
|
+
f"name.")
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _kind_of_builder(where: str) -> Optional[str]:
|
|
944
|
+
"""The OWL keyword of the axiom a ``where`` like
|
|
945
|
+
``"dl.TBox.add_functional_role"`` builds, read off :data:`_AXIOM_KINDS`'
|
|
946
|
+
``builder`` column (plus the two equivalence builders, which store
|
|
947
|
+
inclusions and so have no row of their own). ``None`` when ``where`` is not
|
|
948
|
+
a builder — the caller then passes ``kind=`` itself, or gets the generic
|
|
949
|
+
advice."""
|
|
950
|
+
builder = where.rsplit(".", 1)[-1]
|
|
951
|
+
extra = {"add_equivalent_roles": "EquivalentObjectProperties",
|
|
952
|
+
"add_equivalent_data_properties": "EquivalentDataProperties",
|
|
953
|
+
"assert_role": "ObjectPropertyAssertion",
|
|
954
|
+
"assert_negative_role": "NegativeObjectPropertyAssertion"}
|
|
955
|
+
if builder in extra:
|
|
956
|
+
return extra[builder]
|
|
957
|
+
for row in _AXIOM_KINDS:
|
|
958
|
+
if row.builder == builder:
|
|
959
|
+
return row.kind
|
|
960
|
+
return None
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _check_role_name(value, *, where: str, allow_inverse: bool = False,
|
|
964
|
+
kind: Optional[str] = None) -> None:
|
|
965
|
+
"""Raise :class:`RoleExpressionError` unless ``value`` is a usable role in
|
|
966
|
+
this position: a plain role name that is not an OWL 2 built-in and not the
|
|
967
|
+
name of an equality atom, or — when ``allow_inverse`` — an
|
|
968
|
+
:class:`~unicode_logic_kit.dl.concepts.InverseRole` over one.
|
|
969
|
+
|
|
970
|
+
``where`` names the function the caller actually called, so the message
|
|
971
|
+
points at the call that is wrong rather than at this guard. ``kind`` is the
|
|
972
|
+
OWL keyword of the axiom the role sits in; it picks the advice given for an
|
|
973
|
+
``InverseRole`` (see :func:`_inverse_advice`) and is read off ``where`` when
|
|
974
|
+
``where`` names a builder.
|
|
975
|
+
"""
|
|
976
|
+
if isinstance(value, InverseRole):
|
|
977
|
+
if allow_inverse:
|
|
978
|
+
_check_role_name(value.role, where=where, kind=kind)
|
|
979
|
+
return
|
|
980
|
+
raise RoleExpressionError(
|
|
981
|
+
f"{where}: an InverseRole ({value.role}⁻) is not a role NAME, and "
|
|
982
|
+
f"this position takes one. "
|
|
983
|
+
f"{_inverse_advice(kind or _kind_of_builder(where), value.role)} "
|
|
984
|
+
f"An InverseRole IS accepted on either side of "
|
|
985
|
+
f"dl.TBox.add_role_inclusion, where r ⊑ s⁻ is a genuinely "
|
|
986
|
+
f"different axiom from r ⊑ s.")
|
|
987
|
+
if isinstance(value, (tuple, list)):
|
|
988
|
+
raise RoleExpressionError(
|
|
989
|
+
f"{where}: expected a role NAME (str), got a "
|
|
990
|
+
f"{type(value).__name__} {value!r}. A sequence of roles is a "
|
|
991
|
+
f"PROPERTY CHAIN — use dl.TBox.add_role_chain(chain, super_role), "
|
|
992
|
+
f"whose FOL image is ∀x ∀y ∀z (P1(x, y) ∧ P2(y, z) → Q(x, z)). "
|
|
993
|
+
f"Storing it as a role name instead silently builds an axiom about "
|
|
994
|
+
f"an atomic role nobody named.")
|
|
995
|
+
if not isinstance(value, str):
|
|
996
|
+
raise RoleExpressionError(
|
|
997
|
+
f"{where}: expected a role NAME (str), got "
|
|
998
|
+
f"{type(value).__name__} {value!r}.")
|
|
999
|
+
if value in _EQUALITY_ROLE_NAMES:
|
|
1000
|
+
raise RoleExpressionError(_equality_name_message(where, value))
|
|
1001
|
+
builtin = reserved_role(value)
|
|
1002
|
+
if builtin is None:
|
|
1003
|
+
return
|
|
1004
|
+
if builtin in RESERVED_TOP_ROLES:
|
|
1005
|
+
what = ("the universal property: it relates every pair of individuals")
|
|
1006
|
+
remedy = ("An inclusion INTO it is a tautology, and "
|
|
1007
|
+
"dl.parse_owl_functional consumes that shape as a documented "
|
|
1008
|
+
"no-op; 'P is universal' (an inclusion OUT OF it) is the "
|
|
1009
|
+
"universal role of SROIQ, which breaks the tree-model "
|
|
1010
|
+
"property ALCHQ relies on and this kit does not have.")
|
|
1011
|
+
else:
|
|
1012
|
+
what = "the empty property: it relates no pair at all"
|
|
1013
|
+
remedy = ("An inclusion OUT OF it is a tautology, and "
|
|
1014
|
+
"dl.parse_owl_functional consumes that shape as a documented "
|
|
1015
|
+
"no-op; 'P is empty' is the concept inclusion ⊤ ⊑ ∀P.⊥, "
|
|
1016
|
+
"which this kit does decide.")
|
|
1017
|
+
raise RoleExpressionError(
|
|
1018
|
+
f"{where}: {value!r} is an OWL 2 BUILT-IN property ({builtin} — "
|
|
1019
|
+
f"{what}), not an ordinary role name: ALCHQ (this kit's in-house DL "
|
|
1020
|
+
f"fragment) has neither the universal nor the empty role, so no role "
|
|
1021
|
+
f"box entry may carry it. {remedy}")
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _check_chain_shape(chain, *, where: str, stored: bool = False) -> Tuple:
|
|
1025
|
+
"""The property chain ``chain`` as a ``tuple`` of role names, or a
|
|
1026
|
+
:class:`RoleExpressionError` when it is not an ORDERED collection of them.
|
|
1027
|
+
|
|
1028
|
+
Two inputs are refused by what they are. A plain ``str`` (or ``bytes``) is
|
|
1029
|
+
not a chain: it is a sequence of one-character strings, so ``tuple("rs")``
|
|
1030
|
+
is ``("r", "s")`` and ``add_role_chain("rs", "t")`` was accepted and stored
|
|
1031
|
+
the two roles ``r`` and ``s`` nobody named, and ``add_role_chain("PartOf",
|
|
1032
|
+
"R")`` the six single-letter roles of ``P, a, r, t, O, f``. A set-like
|
|
1033
|
+
collection (``set``, ``frozenset``, a dict's key view) has no order to
|
|
1034
|
+
preserve, and the order of a chain is its meaning.
|
|
1035
|
+
|
|
1036
|
+
Any other iterable — a list, a tuple, a generator, an iterator, ``map`` — is
|
|
1037
|
+
ordered, and is MATERIALISED with ``tuple()``: a generator of role names
|
|
1038
|
+
worked before the shape guard and still does. The length and the element
|
|
1039
|
+
types are checked by the callers on the tuple that comes back.
|
|
1040
|
+
|
|
1041
|
+
``stored=True`` is the second line of defence over an entry a hand-built
|
|
1042
|
+
``TBox`` already holds: there a chain must BE a tuple or a list, since
|
|
1043
|
+
materialising a stored iterator would consume it.
|
|
1044
|
+
"""
|
|
1045
|
+
if isinstance(chain, (str, bytes)):
|
|
1046
|
+
reason = ("A str would be split into its CHARACTERS, so 'rs' would "
|
|
1047
|
+
"silently become the two roles 'r' and 's'. "
|
|
1048
|
+
if isinstance(chain, str) else
|
|
1049
|
+
"A bytes value would be split into its integers. ")
|
|
1050
|
+
elif isinstance(chain, _AbstractSet):
|
|
1051
|
+
reason = ("A chain is ordered, and an unordered collection has no "
|
|
1052
|
+
"order to preserve. ")
|
|
1053
|
+
elif stored and not isinstance(chain, Sequence):
|
|
1054
|
+
reason = ("A stored chain is a tuple (or list) of role names. ")
|
|
1055
|
+
else:
|
|
1056
|
+
try:
|
|
1057
|
+
return tuple(chain)
|
|
1058
|
+
except TypeError:
|
|
1059
|
+
reason = "It is not a collection of role names at all. "
|
|
1060
|
+
raise RoleExpressionError(
|
|
1061
|
+
f"{where}: a property chain is a SEQUENCE of role names — a tuple "
|
|
1062
|
+
f"or a list such as ('r', 's') — got {type(chain).__name__} "
|
|
1063
|
+
f"{chain!r}. {reason}Write dl.TBox.add_role_chain(['r', 's'], 't').")
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _builtin_family_note(builtin: str, concept_is_data: bool) -> str:
|
|
1067
|
+
"""A sentence naming a type mismatch — a DATA built-in in an object
|
|
1068
|
+
restriction, or an object one in a data restriction — or ``""``."""
|
|
1069
|
+
builtin_is_data = "Data" in builtin
|
|
1070
|
+
if builtin_is_data == concept_is_data:
|
|
1071
|
+
return ""
|
|
1072
|
+
return (f" It is also ill-typed OWL 2: {builtin} is a "
|
|
1073
|
+
f"{'data' if builtin_is_data else 'object'} property, and this is "
|
|
1074
|
+
f"a {'data' if concept_is_data else 'object'}-property "
|
|
1075
|
+
f"restriction.")
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _builtin_in_concept_message(where: str, concept, name: str, builtin: str) -> str:
|
|
1079
|
+
"""The refusal for an OWL 2 built-in property name as the ROLE of a class
|
|
1080
|
+
expression (``∃owl:topObjectProperty.A`` and the like), with what to write
|
|
1081
|
+
instead — and only what is TRUE of that shape.
|
|
1082
|
+
|
|
1083
|
+
The universal property relates every pair, so a restriction over it talks
|
|
1084
|
+
about the WHOLE domain: that is a global statement (the **U** of SROIQ),
|
|
1085
|
+
which no ALCHQ concept makes; a global "every element is in C" is the
|
|
1086
|
+
general concept inclusion ``⊤ ⊑ C``. ``HasValue`` is the exception —
|
|
1087
|
+
``∃U.{a}`` holds of every element (each is related to ``a``), so it is
|
|
1088
|
+
``⊤``. The empty property relates no pair, so ``∃``/``≥n`` (n ≥ 1) over it
|
|
1089
|
+
and ``HasValue`` are unsatisfiable (``⊥``) and ``∀``/``≤n`` (and ``≥0``)
|
|
1090
|
+
hold of everything (``⊤``).
|
|
1091
|
+
"""
|
|
1092
|
+
shown = concept.to_unicode()
|
|
1093
|
+
if builtin in RESERVED_TOP_ROLES:
|
|
1094
|
+
what = "the universal property: it relates every pair of elements"
|
|
1095
|
+
if isinstance(concept, (HasValue, DataHasValue)):
|
|
1096
|
+
remedy = ("Every element is related to the individual by it, so "
|
|
1097
|
+
"the restriction holds of EVERY element: write dl.Top().")
|
|
1098
|
+
else:
|
|
1099
|
+
remedy = ("A restriction over it talks about the WHOLE domain — a "
|
|
1100
|
+
"global statement, the universal role of SROIQ, which "
|
|
1101
|
+
"ALCHQ (this kit's in-house DL fragment) does not have "
|
|
1102
|
+
"and no concept can make. A global 'every element is in "
|
|
1103
|
+
"C' is the general concept inclusion ⊤ ⊑ C "
|
|
1104
|
+
"(dl.TBox.add(dl.Top(), C)).")
|
|
1105
|
+
else:
|
|
1106
|
+
what = "the empty property: it relates no pair at all"
|
|
1107
|
+
vacuous = (isinstance(concept, (ForAll, AtMost, DataForAll, DataAtMost))
|
|
1108
|
+
or (isinstance(concept, (AtLeast, DataAtLeast)) and concept.n == 0))
|
|
1109
|
+
remedy = ("The restriction holds of every element, so it is ⊤: write "
|
|
1110
|
+
"dl.Top()." if vacuous else
|
|
1111
|
+
"No element can satisfy the restriction, so it is ⊥: write "
|
|
1112
|
+
"dl.Bottom().")
|
|
1113
|
+
return (f"{where}: the OWL 2 BUILT-IN property {name!r} ({builtin} — "
|
|
1114
|
+
f"{what}) is the role of {type(concept).__name__} ({shown}), and "
|
|
1115
|
+
f"it would be read as an ORDINARY role of that name — a different "
|
|
1116
|
+
f"restriction, with a different verdict. {remedy}"
|
|
1117
|
+
f"{_builtin_family_note(builtin, isinstance(concept, DATA_CONCEPTS))}")
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
#: The concept classes whose ``role`` field names an OBJECT property.
|
|
1121
|
+
_OBJECT_ROLE_CONCEPTS = (Exists, ForAll, AtLeast, AtMost, HasValue)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _reject_concept_role(concept, *, where: str) -> None:
|
|
1125
|
+
"""Refuse, by name, a role that is not usable as the role of ``concept``
|
|
1126
|
+
ITSELF (not its sub-concepts: the caller walks) — an OWL 2 built-in
|
|
1127
|
+
property name, the name of an equality atom, or a value that is no role at
|
|
1128
|
+
all.
|
|
1129
|
+
|
|
1130
|
+
One function for the three places a concept's role is read — the
|
|
1131
|
+
tableau's concept guard (:func:`_reject_beyond_alc`), the standard
|
|
1132
|
+
translation (:func:`~unicode_logic_kit.dl.translate.concept_to_fol` and
|
|
1133
|
+
friends) and the external route — so the SAME expression is refused with
|
|
1134
|
+
the SAME words wherever it is asked. The builders and the glyph/Manchester
|
|
1135
|
+
parsers never reach it for a concept built directly, which is exactly why
|
|
1136
|
+
it has to be a QUERY-time and TRANSLATION-time check ("builders never
|
|
1137
|
+
refuse").
|
|
1138
|
+
|
|
1139
|
+
A data restriction's ``prop`` is read the same way: the four built-in names
|
|
1140
|
+
include the two data ones.
|
|
1141
|
+
"""
|
|
1142
|
+
if isinstance(concept, _OBJECT_ROLE_CONCEPTS):
|
|
1143
|
+
role = concept.role
|
|
1144
|
+
elif isinstance(concept, DATA_CONCEPTS):
|
|
1145
|
+
role = concept.prop
|
|
1146
|
+
else:
|
|
1147
|
+
return
|
|
1148
|
+
plain = role.role if isinstance(role, InverseRole) else role
|
|
1149
|
+
if isinstance(plain, str):
|
|
1150
|
+
if plain in _EQUALITY_ROLE_NAMES:
|
|
1151
|
+
raise RoleExpressionError(_equality_name_message(where, plain))
|
|
1152
|
+
builtin = reserved_role(plain)
|
|
1153
|
+
if builtin is not None:
|
|
1154
|
+
raise RoleExpressionError(
|
|
1155
|
+
_builtin_in_concept_message(where, concept, plain, builtin))
|
|
1156
|
+
return
|
|
1157
|
+
_check_role_name(role, where=where, allow_inverse=True)
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _reject_concept_roles_deep(concept: Concept, *, where: str) -> None:
|
|
1161
|
+
""":func:`_reject_concept_role` over ``concept`` and every sub-concept —
|
|
1162
|
+
for a route (the external one) that has no recursive guard of its own over
|
|
1163
|
+
the whole fragment, the way :func:`_reject_beyond_alc` is the tableau's."""
|
|
1164
|
+
stack = [concept]
|
|
1165
|
+
while stack:
|
|
1166
|
+
node = stack.pop()
|
|
1167
|
+
_reject_concept_role(node, where=where)
|
|
1168
|
+
for attribute in ("concept", "left", "right"):
|
|
1169
|
+
child = getattr(node, attribute, None)
|
|
1170
|
+
if isinstance(child, Concept):
|
|
1171
|
+
stack.append(child)
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _builtin_in_assertion_message(where: str, kind: str, entry, name: str,
|
|
1175
|
+
builtin: str) -> str:
|
|
1176
|
+
"""The refusal for an OWL 2 built-in property name in an ABOX assertion,
|
|
1177
|
+
with the consequence in OWL 2's own semantics: asserting the universal
|
|
1178
|
+
property (or denying the empty one) states nothing, and asserting the empty
|
|
1179
|
+
property (or denying the universal one) makes the knowledge base
|
|
1180
|
+
inconsistent."""
|
|
1181
|
+
negative = kind.startswith("Negative")
|
|
1182
|
+
universal = builtin in RESERVED_TOP_ROLES
|
|
1183
|
+
holds_always = universal != negative
|
|
1184
|
+
first = entry[0]
|
|
1185
|
+
if holds_always:
|
|
1186
|
+
consequence = ("The assertion holds in EVERY interpretation, so it "
|
|
1187
|
+
"constrains nothing: drop it.")
|
|
1188
|
+
else:
|
|
1189
|
+
consequence = (f"The assertion holds in NO interpretation, so it makes "
|
|
1190
|
+
f"the knowledge base INCONSISTENT: state that with "
|
|
1191
|
+
f"abox.assert_concept({first!r}, dl.Bottom()) if it is "
|
|
1192
|
+
f"what you mean.")
|
|
1193
|
+
what = ("the universal property: it relates every pair"
|
|
1194
|
+
if universal else "the empty property: it relates no pair at all")
|
|
1195
|
+
return (f"{where}: the OWL 2 BUILT-IN property {name!r} ({builtin} — "
|
|
1196
|
+
f"{what}) occurs in the {kind} {entry!r}, and it would be read as an "
|
|
1197
|
+
f"ORDINARY role of that name — an uninterpreted relation, which "
|
|
1198
|
+
f"answers a different question. {consequence}"
|
|
1199
|
+
f"{_builtin_family_note(builtin, kind.startswith(('Data', 'NegativeData')))}")
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
#: ``(ABox field, OWL keyword, index of the property in the stored tuple)`` for
|
|
1203
|
+
#: every assertion that names a property.
|
|
1204
|
+
_ABOX_PROPERTY_FIELDS: Tuple[Tuple[str, str, int], ...] = (
|
|
1205
|
+
("role_assertions", "ObjectPropertyAssertion", 2),
|
|
1206
|
+
("negative_role_assertions", "NegativeObjectPropertyAssertion", 2),
|
|
1207
|
+
("data_assertions", "DataPropertyAssertion", 1),
|
|
1208
|
+
("negative_data_assertions", "NegativeDataPropertyAssertion", 1),
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _reject_abox_roles(abox: Optional["ABox"], *, where: str) -> None:
|
|
1213
|
+
"""Refuse, by name, an ABox assertion whose property is an OWL 2 built-in
|
|
1214
|
+
name, the name of an equality atom, an inverse role or no name at all.
|
|
1215
|
+
|
|
1216
|
+
The ABox builders accept every ``str`` (builders never refuse), so this is
|
|
1217
|
+
where the assertion is first CHECKED: at query time by the shared guard
|
|
1218
|
+
and at translation time by :func:`~unicode_logic_kit.dl.translate.abox_to_fol`.
|
|
1219
|
+
``r⁻(a, b)`` is refused with the true remedy (swap the individuals); a
|
|
1220
|
+
built-in is refused with its OWL 2 consequence, because the two readings —
|
|
1221
|
+
an ordinary uninterpreted role, and the universal/empty property — give
|
|
1222
|
+
different verdicts (``a`` related to ``b`` by the EMPTY property is
|
|
1223
|
+
inconsistent; by an ordinary role it is not).
|
|
1224
|
+
"""
|
|
1225
|
+
if abox is None:
|
|
1226
|
+
return
|
|
1227
|
+
for field_name, kind, index in _ABOX_PROPERTY_FIELDS:
|
|
1228
|
+
for entry in getattr(abox, field_name):
|
|
1229
|
+
name = entry[index]
|
|
1230
|
+
if isinstance(name, str) and name not in _EQUALITY_ROLE_NAMES:
|
|
1231
|
+
builtin = reserved_role(name)
|
|
1232
|
+
if builtin is not None:
|
|
1233
|
+
raise RoleExpressionError(_builtin_in_assertion_message(
|
|
1234
|
+
where, kind, entry, name, builtin))
|
|
1235
|
+
_check_role_name(name, where=where, kind=kind)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _stored_pair(entry, kind: str, where: str) -> Tuple[object, object]:
|
|
1239
|
+
"""``entry`` as the two-element pair a TBox list of ``kind`` stores, or a
|
|
1240
|
+
:class:`RoleExpressionError` — never the bare ``ValueError`` that unpacking
|
|
1241
|
+
a 3-tuple raises, which names no axiom and no call."""
|
|
1242
|
+
if not (isinstance(entry, (tuple, list)) and len(entry) == 2):
|
|
1243
|
+
raise RoleExpressionError(
|
|
1244
|
+
f"{where}: a stored {kind} entry is a PAIR of two values, got "
|
|
1245
|
+
f"{entry!r}. Build the TBox with the builder that stores {kind} "
|
|
1246
|
+
f"instead of assembling its list by hand.")
|
|
1247
|
+
return entry[0], entry[1]
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
#: ``(TBox field, OWL keyword)`` of every object-role axiom that stores plain
|
|
1251
|
+
#: role NAMES, set-valued.
|
|
1252
|
+
_NAME_SET_FIELDS: Tuple[Tuple[str, str], ...] = (
|
|
1253
|
+
("transitive_roles", "TransitiveObjectProperty"),
|
|
1254
|
+
("symmetric_roles", "SymmetricObjectProperty"),
|
|
1255
|
+
("asymmetric_roles", "AsymmetricObjectProperty"),
|
|
1256
|
+
("reflexive_roles", "ReflexiveObjectProperty"),
|
|
1257
|
+
("irreflexive_roles", "IrreflexiveObjectProperty"),
|
|
1258
|
+
("functional_roles", "FunctionalObjectProperty"),
|
|
1259
|
+
("inverse_functional_roles", "InverseFunctionalObjectProperty"),
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _validate_role_box(tbox: "TBox", *, where: str) -> None:
|
|
1264
|
+
"""Raise :class:`RoleExpressionError` unless every value STORED in ``tbox``'s
|
|
1265
|
+
object role box is usable: a role name (an inverse role only on either side
|
|
1266
|
+
of a role inclusion), a pair where a pair is stored, a chain that is a
|
|
1267
|
+
sequence of at least two role names.
|
|
1268
|
+
|
|
1269
|
+
The ONE validation of a stored role box, called by BOTH routes — the
|
|
1270
|
+
tableau's shared guard (:func:`_reject_role_box`) and the FOL image
|
|
1271
|
+
(:func:`~unicode_logic_kit.dl.translate.rbox_to_fol`) — so that a ``TBox``
|
|
1272
|
+
built through the dataclass constructor, or mutated in place, is refused
|
|
1273
|
+
the same way by both. Until 0.30.0 only the FOL route had this second line
|
|
1274
|
+
of defence behind the builders, and the tableau ANSWERED, silently, for a
|
|
1275
|
+
role inclusion between two tuples that the FOL route rejected — the two
|
|
1276
|
+
routes disagreeing about whether there was a question.
|
|
1277
|
+
"""
|
|
1278
|
+
for entry in tbox.role_inclusions:
|
|
1279
|
+
sub, sup = _stored_pair(entry, "SubObjectPropertyOf", where)
|
|
1280
|
+
_check_role_name(sub, where=where, allow_inverse=True,
|
|
1281
|
+
kind="SubObjectPropertyOf")
|
|
1282
|
+
_check_role_name(sup, where=where, allow_inverse=True,
|
|
1283
|
+
kind="SubObjectPropertyOf")
|
|
1284
|
+
for field_name, kind in _NAME_SET_FIELDS:
|
|
1285
|
+
for role in sorted(getattr(tbox, field_name), key=repr):
|
|
1286
|
+
_check_role_name(role, where=where, kind=kind)
|
|
1287
|
+
for field_name, kind in (("inverse_role_pairs", "InverseObjectProperties"),
|
|
1288
|
+
("disjoint_role_pairs", "DisjointObjectProperties")):
|
|
1289
|
+
for entry in getattr(tbox, field_name):
|
|
1290
|
+
for role in _stored_pair(entry, kind, where):
|
|
1291
|
+
_check_role_name(role, where=where, kind=kind)
|
|
1292
|
+
for chain_entry in tbox.role_chains:
|
|
1293
|
+
stored_chain, super_role = _stored_pair(chain_entry, "ObjectPropertyChain", where)
|
|
1294
|
+
chain = _check_chain_shape(stored_chain, where=where, stored=True)
|
|
1295
|
+
if len(chain) < 2:
|
|
1296
|
+
raise RoleExpressionError(
|
|
1297
|
+
f"{where}: a stored property chain needs at least 2 roles, "
|
|
1298
|
+
f"got {len(chain)} ({tuple(chain)!r}). OWL 2's grammar is "
|
|
1299
|
+
f"ObjectPropertyChain(OPE OPE+); a single role on the left is "
|
|
1300
|
+
f"an ordinary role inclusion (dl.TBox.add_role_inclusion).")
|
|
1301
|
+
for role in chain:
|
|
1302
|
+
_check_role_name(role, where=where, kind="ObjectPropertyChain")
|
|
1303
|
+
_check_role_name(super_role, where=where, kind="ObjectPropertyChain")
|
|
1304
|
+
for field_name, kind in (("role_domains", "ObjectPropertyDomain"),
|
|
1305
|
+
("role_ranges", "ObjectPropertyRange")):
|
|
1306
|
+
for entry in getattr(tbox, field_name):
|
|
1307
|
+
role, _filler = _stored_pair(entry, kind, where)
|
|
1308
|
+
_check_role_name(role, where=where, kind=kind)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _validate_data_box(tbox: "TBox", *, where: str) -> None:
|
|
1312
|
+
""":func:`_validate_role_box`'s counterpart for the DATA box: every stored
|
|
1313
|
+
data property name is a plain name (a data property has no inverse), and
|
|
1314
|
+
every stored pair is a pair."""
|
|
1315
|
+
for field_name, kind in (("data_property_inclusions", "SubDataPropertyOf"),
|
|
1316
|
+
("disjoint_data_property_pairs",
|
|
1317
|
+
"DisjointDataProperties")):
|
|
1318
|
+
for entry in getattr(tbox, field_name):
|
|
1319
|
+
for prop in _stored_pair(entry, kind, where):
|
|
1320
|
+
_check_role_name(prop, where=where, kind=kind)
|
|
1321
|
+
for prop in sorted(tbox.functional_data_properties, key=repr):
|
|
1322
|
+
_check_role_name(prop, where=where, kind="FunctionalDataProperty")
|
|
1323
|
+
for field_name, kind in (("data_property_domains", "DataPropertyDomain"),
|
|
1324
|
+
("data_property_ranges", "DataPropertyRange")):
|
|
1325
|
+
for entry in getattr(tbox, field_name):
|
|
1326
|
+
prop, _filler = _stored_pair(entry, kind, where)
|
|
1327
|
+
_check_role_name(prop, where=where, kind=kind)
|
|
1328
|
+
_check_datatype_definitions(tbox.datatype_definitions, where=where)
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _definition_references(datarange: DataRange, defined: Iterable[str]) -> List[str]:
|
|
1332
|
+
"""The DEFINED datatype names ``datarange`` mentions, in order, once each."""
|
|
1333
|
+
seen: List[str] = []
|
|
1334
|
+
for name in datarange_datatypes(datarange):
|
|
1335
|
+
if name in defined and name not in seen:
|
|
1336
|
+
seen.append(name)
|
|
1337
|
+
return seen
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _definition_cycle(start: str, definitions: Dict[str, DataRange]) -> Optional[List[str]]:
|
|
1341
|
+
"""A path ``start → … → start`` through the DEFINED datatype names, or
|
|
1342
|
+
``None``: the cycle OWL 2 (Structural Specification §9.4) forbids a set of
|
|
1343
|
+
datatype definitions to have, found by a depth-first walk from ``start``."""
|
|
1344
|
+
path: List[str] = [start]
|
|
1345
|
+
visiting: Set[str] = set()
|
|
1346
|
+
|
|
1347
|
+
def walk(name: str) -> Optional[List[str]]:
|
|
1348
|
+
visiting.add(name)
|
|
1349
|
+
for ref in _definition_references(definitions[name], definitions):
|
|
1350
|
+
if ref == start:
|
|
1351
|
+
return path + [start]
|
|
1352
|
+
if ref in visiting:
|
|
1353
|
+
continue
|
|
1354
|
+
path.append(ref)
|
|
1355
|
+
found = walk(ref)
|
|
1356
|
+
if found is not None:
|
|
1357
|
+
return found
|
|
1358
|
+
path.pop()
|
|
1359
|
+
return None
|
|
1360
|
+
|
|
1361
|
+
return walk(start)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _duplicate_definition_error(name: str, first: DataRange, second: DataRange,
|
|
1365
|
+
where: str) -> "UnsupportedDatatypeError":
|
|
1366
|
+
return UnsupportedDatatypeError(
|
|
1367
|
+
f"{where}: the datatype {name!r} has two DatatypeDefinitions "
|
|
1368
|
+
f"({first.to_unicode()} and {second.to_unicode()}). OWL 2 (Structural "
|
|
1369
|
+
f"Specification §9.4) gives a datatype ONE definition, and the image of "
|
|
1370
|
+
f"two would be two biconditionals that force the two data ranges to be "
|
|
1371
|
+
f"equal — a constraint nobody wrote. Merge them into one data range, or "
|
|
1372
|
+
f"name one of the datatypes differently.")
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _cyclic_definition_error(path: List[str], where: str) -> "UnsupportedDatatypeError":
|
|
1376
|
+
return UnsupportedDatatypeError(
|
|
1377
|
+
f"{where}: the datatype definitions are cyclic ({' → '.join(path)}), "
|
|
1378
|
+
f"and OWL 2 (Structural Specification §9.4) requires them to be "
|
|
1379
|
+
f"acyclic: a datatype may not be defined in terms of itself, directly "
|
|
1380
|
+
f"or through other defined datatypes. The image of a cycle "
|
|
1381
|
+
f"(∀v (P(v) ↔ Q(v)) with ∀v (Q(v) ↔ OwlData(v) ∧ ¬P(v))) has no model "
|
|
1382
|
+
f"once the data domain is non-empty, so it would make the knowledge "
|
|
1383
|
+
f"base inconsistent.")
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _check_datatype_definitions(entries, *, where: str) -> None:
|
|
1387
|
+
"""The shared validation of the stored ``DatatypeDefinition`` entries — the
|
|
1388
|
+
one every route runs on a TBox that may have been assembled by hand (the
|
|
1389
|
+
builder :meth:`TBox.add_datatype_definition` runs the same three tests on
|
|
1390
|
+
the entry it is handed): the name is not a built-in datatype, no name has
|
|
1391
|
+
two DIFFERENT definitions (an identical repeat is the same axiom), and the
|
|
1392
|
+
definitions are acyclic. Raises :class:`UnsupportedDatatypeError`."""
|
|
1393
|
+
definitions: Dict[str, DataRange] = {}
|
|
1394
|
+
for entry in entries:
|
|
1395
|
+
name, datarange = _stored_pair(entry, "DatatypeDefinition", where)
|
|
1396
|
+
if not isinstance(name, str) or not isinstance(datarange, DataRange):
|
|
1397
|
+
raise UnsupportedDatatypeError(
|
|
1398
|
+
f"{where}: a stored DatatypeDefinition is (datatype name, data "
|
|
1399
|
+
f"range), got {entry!r}. Build the TBox with "
|
|
1400
|
+
f"TBox.add_datatype_definition instead of assembling its list "
|
|
1401
|
+
f"by hand.")
|
|
1402
|
+
name = canonical_datatype_name(name)
|
|
1403
|
+
if name in BUILTIN_DATATYPES:
|
|
1404
|
+
raise UnsupportedDatatypeError(
|
|
1405
|
+
f"{where}: {name!r} is a built-in datatype of the OWL 2 "
|
|
1406
|
+
f"datatype map, and OWL 2 does not allow redefining one. Name "
|
|
1407
|
+
f"the new datatype something else.")
|
|
1408
|
+
if name in definitions and definitions[name] != datarange:
|
|
1409
|
+
raise _duplicate_definition_error(name, definitions[name], datarange, where)
|
|
1410
|
+
definitions[name] = datarange
|
|
1411
|
+
for name in definitions:
|
|
1412
|
+
cycle = _definition_cycle(name, definitions)
|
|
1413
|
+
if cycle is not None:
|
|
1414
|
+
raise _cyclic_definition_error(cycle, where)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
#: A role in a role-box position: a plain role name, or (only where the module
|
|
1418
|
+
#: docstring says so — on either side of a role inclusion) an inverse role.
|
|
1419
|
+
RoleExpr = Union[str, InverseRole]
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _as_datarange(value, where: str) -> DataRange:
|
|
1423
|
+
"""``value`` as a :class:`~unicode_logic_kit.dl.datatypes.DataRange`: a data
|
|
1424
|
+
range is returned as is and a ``str`` is the datatype of that name; anything
|
|
1425
|
+
else is a caller mistake, named at the call that made it."""
|
|
1426
|
+
if isinstance(value, DataRange):
|
|
1427
|
+
return value
|
|
1428
|
+
if isinstance(value, str):
|
|
1429
|
+
return Datatype(value)
|
|
1430
|
+
raise TypeError(
|
|
1431
|
+
f"{where}: expected a data range (dl.Datatype, dl.DatatypeRestriction, "
|
|
1432
|
+
f"dl.DataOneOf, …) or a datatype name (str), got "
|
|
1433
|
+
f"{type(value).__name__} {value!r}.")
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
@dataclass
|
|
1437
|
+
class TBox:
|
|
1438
|
+
"""A general TBox with an RBox on top: concept inclusions ``C ⊑ D`` and
|
|
1439
|
+
equivalences ``C ≡ D`` (the concept-level TBox), plus the whole OWL 2 object
|
|
1440
|
+
property box — role inclusions ``r ⊑ s``, transitivity declarations
|
|
1441
|
+
``Trans(r)``, inverse-property pairs, property chains, role disjointness and
|
|
1442
|
+
the six remaining role characteristics.
|
|
1443
|
+
|
|
1444
|
+
See "Role hierarchies and transitive roles (RBox)" and "The rest of the OWL 2
|
|
1445
|
+
role box" in the module docstring for which of them this tableau decides,
|
|
1446
|
+
which it refuses by name, and the soundness/termination argument for each.
|
|
1447
|
+
Every kind is accepted here regardless: a ``TBox`` is what a parser fills
|
|
1448
|
+
from a file, so a builder that refused a KIND could not hold an ontology the
|
|
1449
|
+
kit is supposed to report on (the refusal is at query time, from
|
|
1450
|
+
:func:`_reject_unsupported`). Malformed ARGUMENTS are a different matter and
|
|
1451
|
+
are refused on the spot — see :class:`RoleExpressionError`.
|
|
1452
|
+
"""
|
|
1453
|
+
|
|
1454
|
+
inclusions: List[Tuple[Concept, Concept]] = field(default_factory=list)
|
|
1455
|
+
role_inclusions: List[Tuple[RoleExpr, RoleExpr]] = field(default_factory=list)
|
|
1456
|
+
transitive_roles: Set[str] = field(default_factory=set)
|
|
1457
|
+
inverse_role_pairs: List[Tuple[str, str]] = field(default_factory=list)
|
|
1458
|
+
role_chains: List[Tuple[Tuple[str, ...], str]] = field(default_factory=list)
|
|
1459
|
+
disjoint_role_pairs: List[Tuple[str, str]] = field(default_factory=list)
|
|
1460
|
+
symmetric_roles: Set[str] = field(default_factory=set)
|
|
1461
|
+
asymmetric_roles: Set[str] = field(default_factory=set)
|
|
1462
|
+
reflexive_roles: Set[str] = field(default_factory=set)
|
|
1463
|
+
irreflexive_roles: Set[str] = field(default_factory=set)
|
|
1464
|
+
functional_roles: Set[str] = field(default_factory=set)
|
|
1465
|
+
inverse_functional_roles: Set[str] = field(default_factory=set)
|
|
1466
|
+
role_domains: List[Tuple[str, Concept]] = field(default_factory=list)
|
|
1467
|
+
role_ranges: List[Tuple[str, Concept]] = field(default_factory=list)
|
|
1468
|
+
# The data half of the property box (see "The data layer" in the module
|
|
1469
|
+
# docstring): stored, and rendered by the FOL image, but REFUSED by the
|
|
1470
|
+
# tableau, which has no data domain.
|
|
1471
|
+
data_property_inclusions: List[Tuple[str, str]] = field(default_factory=list)
|
|
1472
|
+
disjoint_data_property_pairs: List[Tuple[str, str]] = field(default_factory=list)
|
|
1473
|
+
functional_data_properties: Set[str] = field(default_factory=set)
|
|
1474
|
+
data_property_domains: List[Tuple[str, Concept]] = field(default_factory=list)
|
|
1475
|
+
data_property_ranges: List[Tuple[str, DataRange]] = field(default_factory=list)
|
|
1476
|
+
datatype_definitions: List[Tuple[str, DataRange]] = field(default_factory=list)
|
|
1477
|
+
|
|
1478
|
+
def add(self, sub: Concept, sup: Concept) -> "TBox":
|
|
1479
|
+
"""Add a general concept inclusion ``sub ⊑ sup`` and return self (chainable)."""
|
|
1480
|
+
self.inclusions.append((sub, sup))
|
|
1481
|
+
return self
|
|
1482
|
+
|
|
1483
|
+
def add_equivalence(self, c: Concept, d: Concept) -> "TBox":
|
|
1484
|
+
"""Add an equivalence ``c ≡ d`` (as the two inclusions ``c ⊑ d``, ``d ⊑ c``)."""
|
|
1485
|
+
self.inclusions.append((c, d))
|
|
1486
|
+
self.inclusions.append((d, c))
|
|
1487
|
+
return self
|
|
1488
|
+
|
|
1489
|
+
def add_role_inclusion(self, sub_role: RoleExpr, super_role: RoleExpr) -> "TBox":
|
|
1490
|
+
"""Add a role inclusion ``sub_role ⊑ super_role`` and return self (chainable).
|
|
1491
|
+
|
|
1492
|
+
Every ``sub_role``-edge is then also treated as a ``super_role``-edge by the
|
|
1493
|
+
tableau's ∀-rule (RBox rule "H"; see the module docstring).
|
|
1494
|
+
|
|
1495
|
+
Either side may be an :class:`~unicode_logic_kit.dl.concepts.InverseRole`:
|
|
1496
|
+
``r ⊑ s⁻`` is a genuinely different axiom from ``r ⊑ s``, and
|
|
1497
|
+
:func:`~unicode_logic_kit.dl.translate.rbox_to_fol` renders it correctly
|
|
1498
|
+
(``∀x ∀y (r(x, y) → s(y, x))``). The TABLEAU refuses such a role box by
|
|
1499
|
+
name, as the **I** of SHIQ — see the module docstring.
|
|
1500
|
+
|
|
1501
|
+
Raises:
|
|
1502
|
+
RoleExpressionError: a side is not a role name or inverse role, or
|
|
1503
|
+
is an OWL 2 built-in property name.
|
|
1504
|
+
"""
|
|
1505
|
+
_check_role_name(sub_role, where="dl.TBox.add_role_inclusion",
|
|
1506
|
+
allow_inverse=True)
|
|
1507
|
+
_check_role_name(super_role, where="dl.TBox.add_role_inclusion",
|
|
1508
|
+
allow_inverse=True)
|
|
1509
|
+
self.role_inclusions.append((sub_role, super_role))
|
|
1510
|
+
return self
|
|
1511
|
+
|
|
1512
|
+
def add_role_chain(self, chain: Sequence[str], super_role: str) -> "TBox":
|
|
1513
|
+
"""Add the complex role inclusion ``P1 ∘ … ∘ Pn ⊑ super_role``
|
|
1514
|
+
(``SubObjectPropertyOf(ObjectPropertyChain(P1 … Pn) Q)``), ``n ≥ 2``, and
|
|
1515
|
+
return self (chainable).
|
|
1516
|
+
|
|
1517
|
+
``chain`` is stored as a ``tuple``. The FOL image is
|
|
1518
|
+
``∀x ∀y ∀z (P1(x, y) ∧ P2(y, z) → Q(x, z))`` for ``n = 2``, with further
|
|
1519
|
+
positions taking minted variables; the tableau refuses it by name (the
|
|
1520
|
+
**R** of SROIQ — see the module docstring, which also records the rule
|
|
1521
|
+
that would decide it and the regularity problem that defers it).
|
|
1522
|
+
|
|
1523
|
+
Raises:
|
|
1524
|
+
RoleExpressionError: ``chain`` is not an ordered collection of role
|
|
1525
|
+
names (a plain ``str`` or ``bytes`` is refused: it would be
|
|
1526
|
+
split into its CHARACTERS, ``"rs"`` into the roles ``r`` and
|
|
1527
|
+
``s``; a set-like collection has no order; any other iterable,
|
|
1528
|
+
such as a generator, is read in order), has fewer than two
|
|
1529
|
+
roles (a length-1 "chain" is an ordinary role inclusion — the
|
|
1530
|
+
message says so), or a role is not a usable role name.
|
|
1531
|
+
"""
|
|
1532
|
+
chain = _check_chain_shape(chain, where="dl.TBox.add_role_chain")
|
|
1533
|
+
if len(chain) < 2:
|
|
1534
|
+
raise RoleExpressionError(
|
|
1535
|
+
f"dl.TBox.add_role_chain: a property chain needs at least 2 "
|
|
1536
|
+
f"roles, got {len(chain)} ({chain!r}). OWL 2's own grammar is "
|
|
1537
|
+
f"ObjectPropertyChain(OPE OPE+); a single role on the left is "
|
|
1538
|
+
f"the ordinary role inclusion dl.TBox.add_role_inclusion"
|
|
1539
|
+
+ (f"({chain[0]!r}, {super_role!r})" if chain else "(sub, sup)")
|
|
1540
|
+
+ ", which this tableau decides.")
|
|
1541
|
+
for role in chain:
|
|
1542
|
+
_check_role_name(role, where="dl.TBox.add_role_chain")
|
|
1543
|
+
_check_role_name(super_role, where="dl.TBox.add_role_chain")
|
|
1544
|
+
self.role_chains.append((chain, super_role))
|
|
1545
|
+
return self
|
|
1546
|
+
|
|
1547
|
+
def add_equivalent_roles(self, *roles: str) -> "TBox":
|
|
1548
|
+
"""Declare ``roles`` pairwise equivalent
|
|
1549
|
+
(``EquivalentObjectProperties(P1 … Pk)``, ``k ≥ 2``) and return self.
|
|
1550
|
+
|
|
1551
|
+
Stored as the role INCLUSIONS it abbreviates — both ways round for each
|
|
1552
|
+
CONSECUTIVE pair — exactly as :meth:`add_equivalence` stores a concept
|
|
1553
|
+
equivalence as the pair of inclusions. Consecutive suffices because
|
|
1554
|
+
``⊑`` is transitive, so ``P1 ≡ P2 ≡ P3`` already entails ``P1 ≡ P3``;
|
|
1555
|
+
contrast :meth:`add_disjoint_roles`, where disjointness has no such
|
|
1556
|
+
closure and ALL pairs are needed. So there is no new field and nothing
|
|
1557
|
+
new in the tableau: ``_RBox`` already treats a ⊑-cycle as one synonym
|
|
1558
|
+
class.
|
|
1559
|
+
|
|
1560
|
+
Raises:
|
|
1561
|
+
RoleExpressionError: fewer than two roles, or a role is not a usable
|
|
1562
|
+
role name.
|
|
1563
|
+
"""
|
|
1564
|
+
if len(roles) < 2:
|
|
1565
|
+
raise RoleExpressionError(
|
|
1566
|
+
f"dl.TBox.add_equivalent_roles: expected at least 2 roles, got "
|
|
1567
|
+
f"{len(roles)} ({roles!r}) — OWL 2's own grammar is "
|
|
1568
|
+
f"EquivalentObjectProperties(OPE OPE+), and one role is "
|
|
1569
|
+
f"equivalent to itself in every interpretation (nothing to add).")
|
|
1570
|
+
for role in roles:
|
|
1571
|
+
_check_role_name(role, where="dl.TBox.add_equivalent_roles")
|
|
1572
|
+
for left, right in zip(roles, roles[1:]):
|
|
1573
|
+
self.add_role_inclusion(left, right)
|
|
1574
|
+
self.add_role_inclusion(right, left)
|
|
1575
|
+
return self
|
|
1576
|
+
|
|
1577
|
+
def add_inverse_roles(self, p: str, q: str) -> "TBox":
|
|
1578
|
+
"""Declare ``p`` and ``q`` mutually inverse
|
|
1579
|
+
(``InverseObjectProperties(p q)``, W3C arity exactly 2) and return self.
|
|
1580
|
+
|
|
1581
|
+
The FOL image is the single biconditional
|
|
1582
|
+
``∀x ∀y (p(x, y) ↔ q(y, x))`` — the axiom is an EQUALITY of relations,
|
|
1583
|
+
not two separate inclusions. The tableau refuses it by name (the **I**
|
|
1584
|
+
of SHIQ).
|
|
1585
|
+
|
|
1586
|
+
Raises:
|
|
1587
|
+
RoleExpressionError: a side is not a usable role name.
|
|
1588
|
+
"""
|
|
1589
|
+
_check_role_name(p, where="dl.TBox.add_inverse_roles")
|
|
1590
|
+
_check_role_name(q, where="dl.TBox.add_inverse_roles")
|
|
1591
|
+
self.inverse_role_pairs.append((p, q))
|
|
1592
|
+
return self
|
|
1593
|
+
|
|
1594
|
+
def add_disjoint_roles(self, *roles: str) -> "TBox":
|
|
1595
|
+
"""Declare ``roles`` pairwise disjoint
|
|
1596
|
+
(``DisjointObjectProperties(P1 … Pk)``, ``k ≥ 2``) and return self.
|
|
1597
|
+
|
|
1598
|
+
Expanded at BUILD time into every unordered pair, each stored with its
|
|
1599
|
+
two names SORTED (so ``(r, s)`` and ``(s, r)`` are one entry and ``TBox``
|
|
1600
|
+
equality is spelling-independent), exactly as ``DisjointClasses`` and
|
|
1601
|
+
``DifferentIndividuals`` are expanded by the parser. ALL ``C(k, 2)``
|
|
1602
|
+
pairs, never a consecutive chain: disjointness has no transitive
|
|
1603
|
+
shortcut — ``P1 ∩ P2 = ∅`` and ``P2 ∩ P3 = ∅`` say nothing about
|
|
1604
|
+
``P1 ∩ P3`` — so ``k - 1`` axioms would be a STRICTLY WEAKER theory.
|
|
1605
|
+
A repeated name gives the degenerate pair ``(P, P)``, i.e.
|
|
1606
|
+
``∀x ∀y ¬P(x, y)`` ("P is empty"), which is kept rather than dropped.
|
|
1607
|
+
|
|
1608
|
+
Raises:
|
|
1609
|
+
RoleExpressionError: fewer than two roles, or a role is not a usable
|
|
1610
|
+
role name.
|
|
1611
|
+
"""
|
|
1612
|
+
if len(roles) < 2:
|
|
1613
|
+
raise RoleExpressionError(
|
|
1614
|
+
f"dl.TBox.add_disjoint_roles: expected at least 2 roles, got "
|
|
1615
|
+
f"{len(roles)} ({roles!r}) — OWL 2's own grammar is "
|
|
1616
|
+
f"DisjointObjectProperties(OPE OPE+). To say a single role is "
|
|
1617
|
+
f"EMPTY, pass it twice (the degenerate pair (P, P) is "
|
|
1618
|
+
f"∀x ∀y ¬P(x, y)) or write the concept inclusion ⊤ ⊑ ∀P.⊥.")
|
|
1619
|
+
for role in roles:
|
|
1620
|
+
_check_role_name(role, where="dl.TBox.add_disjoint_roles")
|
|
1621
|
+
for i, left in enumerate(roles):
|
|
1622
|
+
for right in roles[i + 1:]:
|
|
1623
|
+
first, second = sorted((left, right))
|
|
1624
|
+
pair = (first, second)
|
|
1625
|
+
if pair not in self.disjoint_role_pairs:
|
|
1626
|
+
self.disjoint_role_pairs.append(pair)
|
|
1627
|
+
return self
|
|
1628
|
+
|
|
1629
|
+
def add_transitive_role(self, role: str) -> "TBox":
|
|
1630
|
+
"""Declare ``role`` transitive (``Trans(role)``) and return self (chainable).
|
|
1631
|
+
|
|
1632
|
+
The tableau's ∀+-rule then propagates a ``∀role.C`` restriction along the
|
|
1633
|
+
whole chain of ``role``-successors, not just the immediate one (RBox rule
|
|
1634
|
+
"S"; see the module docstring).
|
|
1635
|
+
|
|
1636
|
+
Raises:
|
|
1637
|
+
RoleExpressionError: ``role`` is not a usable role name (an
|
|
1638
|
+
``InverseRole`` is refused here, naming the plain spelling:
|
|
1639
|
+
``Trans(r⁻)`` is logically ``Trans(r)``).
|
|
1640
|
+
"""
|
|
1641
|
+
_check_role_name(role, where="dl.TBox.add_transitive_role")
|
|
1642
|
+
self.transitive_roles.add(role)
|
|
1643
|
+
return self
|
|
1644
|
+
|
|
1645
|
+
def add_symmetric_role(self, role: str) -> "TBox":
|
|
1646
|
+
"""Declare ``role`` symmetric (``SymmetricObjectProperty``): image
|
|
1647
|
+
``∀x ∀y (role(x, y) → role(y, x))``. Refused by the tableau by name —
|
|
1648
|
+
symmetry IS the inverse-role inclusion ``P ⊑ P⁻`` (see the module
|
|
1649
|
+
docstring).
|
|
1650
|
+
"""
|
|
1651
|
+
_check_role_name(role, where="dl.TBox.add_symmetric_role")
|
|
1652
|
+
self.symmetric_roles.add(role)
|
|
1653
|
+
return self
|
|
1654
|
+
|
|
1655
|
+
def add_asymmetric_role(self, role: str) -> "TBox":
|
|
1656
|
+
"""Declare ``role`` asymmetric (``AsymmetricObjectProperty``): image
|
|
1657
|
+
``∀x ∀y (role(x, y) → ¬role(y, x))``, which also ENTAILS irreflexivity.
|
|
1658
|
+
Decided by the tableau, as one clash condition (see the module
|
|
1659
|
+
docstring); OWL 2 §11 requires ``role`` SIMPLE and
|
|
1660
|
+
:func:`_check_simple_role_box` enforces it.
|
|
1661
|
+
"""
|
|
1662
|
+
_check_role_name(role, where="dl.TBox.add_asymmetric_role")
|
|
1663
|
+
self.asymmetric_roles.add(role)
|
|
1664
|
+
return self
|
|
1665
|
+
|
|
1666
|
+
def add_reflexive_role(self, role: str) -> "TBox":
|
|
1667
|
+
"""Declare ``role`` reflexive (``ReflexiveObjectProperty``): image
|
|
1668
|
+
``∀x role(x, x)``. Refused by the tableau by name — a reflexive role
|
|
1669
|
+
makes every individual its own neighbour, and counting a node among its
|
|
1670
|
+
own neighbours lets the ≤-rule merge a node with its own successor (see
|
|
1671
|
+
the module docstring).
|
|
1672
|
+
"""
|
|
1673
|
+
_check_role_name(role, where="dl.TBox.add_reflexive_role")
|
|
1674
|
+
self.reflexive_roles.add(role)
|
|
1675
|
+
return self
|
|
1676
|
+
|
|
1677
|
+
def add_irreflexive_role(self, role: str) -> "TBox":
|
|
1678
|
+
"""Declare ``role`` irreflexive (``IrreflexiveObjectProperty``): image
|
|
1679
|
+
``∀x ¬role(x, x)`` — ONE variable, not two. Decided by the tableau, as
|
|
1680
|
+
one clash condition (see the module docstring); OWL 2 §11 requires
|
|
1681
|
+
``role`` SIMPLE.
|
|
1682
|
+
"""
|
|
1683
|
+
_check_role_name(role, where="dl.TBox.add_irreflexive_role")
|
|
1684
|
+
self.irreflexive_roles.add(role)
|
|
1685
|
+
return self
|
|
1686
|
+
|
|
1687
|
+
def add_functional_role(self, role: str) -> "TBox":
|
|
1688
|
+
"""Declare ``role`` functional (``FunctionalObjectProperty``): image
|
|
1689
|
+
``∀x ∀y ∀z (role(x, y) ∧ role(x, z) → y = z)``.
|
|
1690
|
+
|
|
1691
|
+
Decided by the tableau WITHOUT a new rule: this is exactly the GCI
|
|
1692
|
+
``⊤ ⊑ ≤1 role.⊤``, internalised in ``_new_branch`` (see the module
|
|
1693
|
+
docstring). OWL 2 §11 requires ``role`` SIMPLE.
|
|
1694
|
+
"""
|
|
1695
|
+
_check_role_name(role, where="dl.TBox.add_functional_role")
|
|
1696
|
+
self.functional_roles.add(role)
|
|
1697
|
+
return self
|
|
1698
|
+
|
|
1699
|
+
def add_inverse_functional_role(self, role: str) -> "TBox":
|
|
1700
|
+
"""Declare ``role`` inverse-functional
|
|
1701
|
+
(``InverseFunctionalObjectProperty``): image
|
|
1702
|
+
``∀x ∀y ∀z (role(y, x) ∧ role(z, x) → y = z)`` — the argument order of
|
|
1703
|
+
the two body atoms is the whole content of the axiom.
|
|
1704
|
+
|
|
1705
|
+
Refused by the tableau by name: it is ``≤1 role⁻.⊤``, so it counts
|
|
1706
|
+
role-PREDECESSORS and needs inverse roles. For functionality on ``role``
|
|
1707
|
+
itself use :meth:`add_functional_role`, which the tableau does decide.
|
|
1708
|
+
"""
|
|
1709
|
+
_check_role_name(role, where="dl.TBox.add_inverse_functional_role")
|
|
1710
|
+
self.inverse_functional_roles.add(role)
|
|
1711
|
+
return self
|
|
1712
|
+
|
|
1713
|
+
def add_role_domain(self, role: str, concept: Concept) -> "TBox":
|
|
1714
|
+
"""Add the domain axiom ``ObjectPropertyDomain(role concept)`` — "anything
|
|
1715
|
+
with a ``role``-successor is a ``concept``" — and return self (chainable).
|
|
1716
|
+
|
|
1717
|
+
Stored NATIVELY, beside the rest of the role box, rather than desugared
|
|
1718
|
+
into the equivalent GCI ``∃role.⊤ ⊑ concept``. Three reasons, all of
|
|
1719
|
+
which the requesting OEO project hit:
|
|
1720
|
+
|
|
1721
|
+
* the FOL image must be the direct-semantics sentence
|
|
1722
|
+
``∀x ∀y (role(x, y) → π(concept, x))``, and no GCI can produce it —
|
|
1723
|
+
:func:`~unicode_logic_kit.dl.translate.subsumption_to_fol` would emit
|
|
1724
|
+
``∀x (∃x0 (role(x, x0) ∧ x0 = x0) → π(concept, x))``, carrying a
|
|
1725
|
+
tautological ``x0 = x0`` filler the reader has to decode;
|
|
1726
|
+
* :func:`~unicode_logic_kit.dl.owl_functional.to_owl_functional` must
|
|
1727
|
+
round-trip the axiom back to ``ObjectPropertyDomain(…)``, and a
|
|
1728
|
+
desugared GCI cannot be re-detected reliably (the same reason that
|
|
1729
|
+
module does not re-fold ``DisjointClasses``);
|
|
1730
|
+
* it is an axiom ABOUT A ROLE, like a role inclusion or a transitivity
|
|
1731
|
+
declaration, so it belongs in the role box — which also puts its FOL
|
|
1732
|
+
image in :attr:`~unicode_logic_kit.dl.translate.KnowledgeBaseFOL.axioms`
|
|
1733
|
+
(a premise) rather than inside the knowledge-base formula.
|
|
1734
|
+
|
|
1735
|
+
The TABLEAU, by contrast, does treat it as the GCI it is equivalent to:
|
|
1736
|
+
``_new_branch`` internalises it as ``∀role.⊥ ⊔ concept``, so no new
|
|
1737
|
+
completion rule and no change to the termination argument (see
|
|
1738
|
+
"Domain and range axioms" in the module docstring).
|
|
1739
|
+
|
|
1740
|
+
Raises:
|
|
1741
|
+
RoleExpressionError: ``role`` is not a usable role name.
|
|
1742
|
+
"""
|
|
1743
|
+
_check_role_name(role, where="dl.TBox.add_role_domain")
|
|
1744
|
+
self.role_domains.append((role, concept))
|
|
1745
|
+
return self
|
|
1746
|
+
|
|
1747
|
+
def add_role_range(self, role: str, concept: Concept) -> "TBox":
|
|
1748
|
+
"""Add the range axiom ``ObjectPropertyRange(role concept)`` — "every
|
|
1749
|
+
``role``-successor is a ``concept``" — and return self (chainable).
|
|
1750
|
+
|
|
1751
|
+
Stored natively for the same three reasons as :meth:`add_role_domain`;
|
|
1752
|
+
its FOL image is ``∀x ∀y (role(x, y) → π(concept, y))``, the filler
|
|
1753
|
+
translated at the SECOND variable, which is the only difference.
|
|
1754
|
+
|
|
1755
|
+
Raises:
|
|
1756
|
+
RoleExpressionError: ``role`` is not a usable role name.
|
|
1757
|
+
"""
|
|
1758
|
+
_check_role_name(role, where="dl.TBox.add_role_range")
|
|
1759
|
+
self.role_ranges.append((role, concept))
|
|
1760
|
+
return self
|
|
1761
|
+
|
|
1762
|
+
# -- the data half of the property box ------------------------------- #
|
|
1763
|
+
|
|
1764
|
+
def add_data_property_inclusion(self, sub_prop: str, super_prop: str) -> "TBox":
|
|
1765
|
+
"""Add ``SubDataPropertyOf(sub_prop super_prop)`` and return self.
|
|
1766
|
+
|
|
1767
|
+
FOL image ``∀x ∀v (sub_prop(x, v) → super_prop(x, v))``. Kept apart from
|
|
1768
|
+
:attr:`role_inclusions`, not merged into them: a data property and an
|
|
1769
|
+
object role of the same name are different things in OWL 2, and one
|
|
1770
|
+
shared list would let either be read as the other. Refused by the
|
|
1771
|
+
tableau (no data domain).
|
|
1772
|
+
|
|
1773
|
+
Raises:
|
|
1774
|
+
RoleExpressionError: a side is not a usable property name (an
|
|
1775
|
+
inverse role is refused — a data property has no inverse — and
|
|
1776
|
+
so is an OWL 2 built-in property name).
|
|
1777
|
+
"""
|
|
1778
|
+
_check_role_name(sub_prop, where="dl.TBox.add_data_property_inclusion")
|
|
1779
|
+
_check_role_name(super_prop, where="dl.TBox.add_data_property_inclusion")
|
|
1780
|
+
self.data_property_inclusions.append((sub_prop, super_prop))
|
|
1781
|
+
return self
|
|
1782
|
+
|
|
1783
|
+
def add_equivalent_data_properties(self, *props: str) -> "TBox":
|
|
1784
|
+
"""Declare ``props`` pairwise equivalent
|
|
1785
|
+
(``EquivalentDataProperties(P1 … Pk)``, ``k ≥ 2``) and return self.
|
|
1786
|
+
|
|
1787
|
+
Stored as the data property inclusions it abbreviates — both ways round
|
|
1788
|
+
for each CONSECUTIVE pair, which suffices because ⊑ is transitive — so
|
|
1789
|
+
there is no field and no table row of its own, exactly like
|
|
1790
|
+
:meth:`add_equivalent_roles`.
|
|
1791
|
+
|
|
1792
|
+
Raises:
|
|
1793
|
+
RoleExpressionError: fewer than two properties, or a name is not usable.
|
|
1794
|
+
"""
|
|
1795
|
+
if len(props) < 2:
|
|
1796
|
+
raise RoleExpressionError(
|
|
1797
|
+
f"dl.TBox.add_equivalent_data_properties: expected at least 2 "
|
|
1798
|
+
f"properties, got {len(props)} ({props!r}) — OWL 2's own grammar "
|
|
1799
|
+
f"is EquivalentDataProperties(DPE DPE+).")
|
|
1800
|
+
for prop in props:
|
|
1801
|
+
_check_role_name(prop, where="dl.TBox.add_equivalent_data_properties")
|
|
1802
|
+
for left, right in zip(props, props[1:]):
|
|
1803
|
+
self.add_data_property_inclusion(left, right)
|
|
1804
|
+
self.add_data_property_inclusion(right, left)
|
|
1805
|
+
return self
|
|
1806
|
+
|
|
1807
|
+
def add_disjoint_data_properties(self, *props: str) -> "TBox":
|
|
1808
|
+
"""Declare ``props`` pairwise disjoint
|
|
1809
|
+
(``DisjointDataProperties(P1 … Pk)``, ``k ≥ 2``) and return self.
|
|
1810
|
+
|
|
1811
|
+
Expanded at BUILD time into every unordered pair, each stored sorted —
|
|
1812
|
+
ALL ``C(k, 2)`` pairs, never a consecutive chain, for the reason
|
|
1813
|
+
:meth:`add_disjoint_roles` gives. Image ``∀x ∀v ¬(p(x, v) ∧ q(x, v))``.
|
|
1814
|
+
|
|
1815
|
+
Raises:
|
|
1816
|
+
RoleExpressionError: fewer than two properties, or a name is not usable.
|
|
1817
|
+
"""
|
|
1818
|
+
if len(props) < 2:
|
|
1819
|
+
raise RoleExpressionError(
|
|
1820
|
+
f"dl.TBox.add_disjoint_data_properties: expected at least 2 "
|
|
1821
|
+
f"properties, got {len(props)} ({props!r}) — OWL 2's own grammar "
|
|
1822
|
+
f"is DisjointDataProperties(DPE DPE+).")
|
|
1823
|
+
for prop in props:
|
|
1824
|
+
_check_role_name(prop, where="dl.TBox.add_disjoint_data_properties")
|
|
1825
|
+
for i, left in enumerate(props):
|
|
1826
|
+
for right in props[i + 1:]:
|
|
1827
|
+
first, second = sorted((left, right))
|
|
1828
|
+
pair = (first, second)
|
|
1829
|
+
if pair not in self.disjoint_data_property_pairs:
|
|
1830
|
+
self.disjoint_data_property_pairs.append(pair)
|
|
1831
|
+
return self
|
|
1832
|
+
|
|
1833
|
+
def add_functional_data_property(self, prop: str) -> "TBox":
|
|
1834
|
+
"""Declare ``prop`` functional (``FunctionalDataProperty``): image
|
|
1835
|
+
``∀x ∀v ∀w (prop(x, v) ∧ prop(x, w) → v = w)``. Refused by the tableau.
|
|
1836
|
+
|
|
1837
|
+
Raises:
|
|
1838
|
+
RoleExpressionError: ``prop`` is not a usable property name.
|
|
1839
|
+
"""
|
|
1840
|
+
_check_role_name(prop, where="dl.TBox.add_functional_data_property")
|
|
1841
|
+
self.functional_data_properties.add(prop)
|
|
1842
|
+
return self
|
|
1843
|
+
|
|
1844
|
+
def add_data_property_domain(self, prop: str, concept: Concept) -> "TBox":
|
|
1845
|
+
"""Add ``DataPropertyDomain(prop concept)`` — "anything with a
|
|
1846
|
+
``prop``-value is a ``concept``" — and return self.
|
|
1847
|
+
|
|
1848
|
+
Stored NATIVELY, for the reasons :meth:`add_role_domain` gives (the
|
|
1849
|
+
image must be the direct sentence ``∀x ∀v (prop(x, v) → π(concept, x))``,
|
|
1850
|
+
and the writer must round-trip the axiom). Refused by the tableau.
|
|
1851
|
+
|
|
1852
|
+
Raises:
|
|
1853
|
+
RoleExpressionError: ``prop`` is not a usable property name.
|
|
1854
|
+
"""
|
|
1855
|
+
_check_role_name(prop, where="dl.TBox.add_data_property_domain")
|
|
1856
|
+
self.data_property_domains.append((prop, concept))
|
|
1857
|
+
return self
|
|
1858
|
+
|
|
1859
|
+
def add_data_property_range(self, prop: str, datarange: Union[DataRange, str]) -> "TBox":
|
|
1860
|
+
"""Add ``DataPropertyRange(prop datarange)`` — "every ``prop``-value is in
|
|
1861
|
+
``datarange``" — and return self. A bare string is a datatype name.
|
|
1862
|
+
|
|
1863
|
+
Image ``∀x ∀v (prop(x, v) → δ(datarange, v))``. NOT rewritten as the GCI
|
|
1864
|
+
``⊤ ⊑ ∀prop.datarange``, whose image would carry the ``x = x`` filler of
|
|
1865
|
+
an ``⊤`` antecedent. Refused by the tableau.
|
|
1866
|
+
|
|
1867
|
+
Raises:
|
|
1868
|
+
RoleExpressionError: ``prop`` is not a usable property name.
|
|
1869
|
+
TypeError: ``datarange`` is not a data range or a datatype name.
|
|
1870
|
+
"""
|
|
1871
|
+
_check_role_name(prop, where="dl.TBox.add_data_property_range")
|
|
1872
|
+
self.data_property_ranges.append(
|
|
1873
|
+
(prop, _as_datarange(datarange, "dl.TBox.add_data_property_range")))
|
|
1874
|
+
return self
|
|
1875
|
+
|
|
1876
|
+
def add_datatype_definition(self, name: str,
|
|
1877
|
+
datarange: Union[DataRange, str]) -> "TBox":
|
|
1878
|
+
"""Add ``DatatypeDefinition(name datarange)`` — the named datatype IS the
|
|
1879
|
+
data range — and return self.
|
|
1880
|
+
|
|
1881
|
+
Image ``∀v (name(v) ↔ δ(datarange, v))``: a definition, so a ``↔`` and
|
|
1882
|
+
not a pair of inclusions read one way. The datatype keeps its own guard
|
|
1883
|
+
predicate in the image rather than being inlined, so the image stays
|
|
1884
|
+
the size of the ontology. Refused by the tableau.
|
|
1885
|
+
|
|
1886
|
+
Raises:
|
|
1887
|
+
~unicode_logic_kit.dl.datatypes.UnsupportedDatatypeError:
|
|
1888
|
+
``name`` is a built-in datatype (OWL 2
|
|
1889
|
+
forbids redefining one), occurs in its own definition, closes a
|
|
1890
|
+
cycle through the definitions already stored (``P ≡ Q`` then
|
|
1891
|
+
``Q ≡ ¬P``), or already has a DIFFERENT definition (OWL 2 §9.4:
|
|
1892
|
+
definitions are acyclic and a datatype has one; an identical
|
|
1893
|
+
repeat is the same axiom and is accepted). A hand-built
|
|
1894
|
+
``TBox`` is held to the same three rules by the shared
|
|
1895
|
+
validation every route runs.
|
|
1896
|
+
TypeError: ``datarange`` is not a data range or a datatype name.
|
|
1897
|
+
"""
|
|
1898
|
+
name = canonical_datatype_name(name)
|
|
1899
|
+
datarange = _as_datarange(datarange, "dl.TBox.add_datatype_definition")
|
|
1900
|
+
if name in BUILTIN_DATATYPES:
|
|
1901
|
+
raise UnsupportedDatatypeError(
|
|
1902
|
+
f"dl.TBox.add_datatype_definition: {name!r} is a built-in "
|
|
1903
|
+
f"datatype of the OWL 2 datatype map, and OWL 2 does not allow "
|
|
1904
|
+
f"redefining one. Name the new datatype something else.")
|
|
1905
|
+
if name in datarange_datatypes(datarange):
|
|
1906
|
+
raise UnsupportedDatatypeError(
|
|
1907
|
+
f"dl.TBox.add_datatype_definition: {name!r} occurs in its own "
|
|
1908
|
+
f"definition ({datarange.to_unicode()}), and OWL 2 requires "
|
|
1909
|
+
f"datatype definitions to be acyclic.")
|
|
1910
|
+
where = "dl.TBox.add_datatype_definition"
|
|
1911
|
+
stored = {canonical_datatype_name(other): other_range
|
|
1912
|
+
for other, other_range in self.datatype_definitions}
|
|
1913
|
+
if name in stored and stored[name] != datarange:
|
|
1914
|
+
raise _duplicate_definition_error(name, stored[name], datarange, where)
|
|
1915
|
+
stored[name] = datarange
|
|
1916
|
+
cycle = _definition_cycle(name, stored)
|
|
1917
|
+
if cycle is not None:
|
|
1918
|
+
raise _cyclic_definition_error(cycle, where)
|
|
1919
|
+
self.datatype_definitions.append((name, datarange))
|
|
1920
|
+
return self
|
|
1921
|
+
|
|
1922
|
+
def internalized(self) -> List[Concept]:
|
|
1923
|
+
"""The concepts ``nnf(¬C ⊔ D)`` every individual must satisfy (one per GCI).
|
|
1924
|
+
|
|
1925
|
+
Role-box axioms are NOT part of this list — they are not concepts forced
|
|
1926
|
+
on every individual, but a separate role constraint the tableau consults
|
|
1927
|
+
through ``_RBox`` (see ``_new_branch``), so they only ever change how
|
|
1928
|
+
existing labels propagate across edges, or which edge patterns clash,
|
|
1929
|
+
never what gets internalised onto a label up front.
|
|
1930
|
+
|
|
1931
|
+
``FunctionalObjectProperty`` is the one that LOOKS like an exception and
|
|
1932
|
+
is not: it is the GCI ``⊤ ⊑ ≤1 P.⊤``, and the concept it contributes is
|
|
1933
|
+
added in ``_new_branch`` rather than here, so this method stays exactly
|
|
1934
|
+
the GCI list (``tests/test_dl_rbox.py::test_internalized_unaffected_by_rbox``
|
|
1935
|
+
is that guard, and it is worth keeping: ``internalized()`` is documented
|
|
1936
|
+
as the image of ``inclusions`` and callers read it that way).
|
|
1937
|
+
``ObjectPropertyDomain``/``ObjectPropertyRange`` are the same case and
|
|
1938
|
+
are internalised in the same place, for the same reason.
|
|
1939
|
+
"""
|
|
1940
|
+
return [nnf(Or(Not(sub), sup)) for sub, sup in self.inclusions]
|
|
1941
|
+
|
|
1942
|
+
def has_side_axioms(self) -> bool:
|
|
1943
|
+
"""True iff this TBox carries an axiom that is NOT part of the
|
|
1944
|
+
concept-inclusion image — any ``part == "side"`` row of
|
|
1945
|
+
:data:`_AXIOM_KINDS`: a role-box axiom (role inclusion, transitivity,
|
|
1946
|
+
disjointness, the other characteristics, inverse pairs, chains, domain
|
|
1947
|
+
and range) or a data-box axiom.
|
|
1948
|
+
|
|
1949
|
+
DERIVED from :data:`_AXIOM_KINDS`' ``part`` column rather than written
|
|
1950
|
+
out over whichever fields happened to exist, which is what
|
|
1951
|
+
:func:`~unicode_logic_kit.dl.translate.tbox_to_fol` reads to decide
|
|
1952
|
+
whether rendering only the concept inclusions would silently hand the
|
|
1953
|
+
caller a WEAKER theory than the TBox (see
|
|
1954
|
+
:class:`~unicode_logic_kit.dl.translate.RoleBoxOmittedError`). A new
|
|
1955
|
+
axiom kind is covered by adding its table row, and nothing else.
|
|
1956
|
+
"""
|
|
1957
|
+
return any(getattr(self, field)
|
|
1958
|
+
for field in _holder_fields("tbox", part="side"))
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
def _check_literal(value, where: str) -> None:
|
|
1962
|
+
if not isinstance(value, Literal):
|
|
1963
|
+
raise TypeError(
|
|
1964
|
+
f"{where}: expected a dl.Literal (e.g. dl.Literal('400', 'xsd:integer')), "
|
|
1965
|
+
f"got {type(value).__name__} {value!r}.")
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
@dataclass
|
|
1969
|
+
class ABox:
|
|
1970
|
+
"""An ABox: concept assertions ``a : C``, role assertions ``(a, b) : r``, and
|
|
1971
|
+
(for **Q**, since there is no unique name assumption — see "Qualified number
|
|
1972
|
+
restrictions" in :mod:`unicode_logic_kit.dl.tableau`'s module docstring)
|
|
1973
|
+
``a ≠ b`` distinctness assertions.
|
|
1974
|
+
"""
|
|
1975
|
+
|
|
1976
|
+
concept_assertions: List[Tuple[str, Concept]] = field(default_factory=list)
|
|
1977
|
+
role_assertions: List[Tuple[str, str, str]] = field(default_factory=list)
|
|
1978
|
+
distinct_assertions: List[Tuple[str, str]] = field(default_factory=list)
|
|
1979
|
+
same_assertions: List[Tuple[str, str]] = field(default_factory=list)
|
|
1980
|
+
negative_role_assertions: List[Tuple[str, str, str]] = field(default_factory=list)
|
|
1981
|
+
# Data assertions: stored and rendered by the FOL image, REFUSED by the
|
|
1982
|
+
# tableau (see "The data layer" in the module docstring).
|
|
1983
|
+
data_assertions: List[Tuple[str, str, Literal]] = field(default_factory=list)
|
|
1984
|
+
negative_data_assertions: List[Tuple[str, str, Literal]] = field(default_factory=list)
|
|
1985
|
+
|
|
1986
|
+
def assert_concept(self, individual: str, concept: Concept) -> "ABox":
|
|
1987
|
+
"""Add a concept assertion ``individual : concept`` (chainable)."""
|
|
1988
|
+
self.concept_assertions.append((individual, concept))
|
|
1989
|
+
return self
|
|
1990
|
+
|
|
1991
|
+
def assert_role(self, a: str, b: str, role: str) -> "ABox":
|
|
1992
|
+
"""Add a role assertion ``(a, b) : role`` (chainable)."""
|
|
1993
|
+
self.role_assertions.append((a, b, role))
|
|
1994
|
+
return self
|
|
1995
|
+
|
|
1996
|
+
def assert_distinct(self, a: str, b: str) -> "ABox":
|
|
1997
|
+
"""Add a distinctness assertion ``a ≠ b`` (chainable).
|
|
1998
|
+
|
|
1999
|
+
Without a unique name assumption, two ABox individuals may otherwise denote
|
|
2000
|
+
the SAME domain element as far as the reasoner is concerned (see the module
|
|
2001
|
+
docstring) — this is how to rule that out explicitly, e.g. to make a
|
|
2002
|
+
qualified number restriction like ``≤1 r.⊤`` genuinely forbid two named
|
|
2003
|
+
``r``-successors rather than letting them collapse into one.
|
|
2004
|
+
|
|
2005
|
+
``assert_distinct(a, a)`` is ACCEPTED and makes the ABox inconsistent, the
|
|
2006
|
+
way ``DifferentIndividuals(a a)`` does in OWL and ``a ≠ a`` does in the FOL
|
|
2007
|
+
rendering :func:`~unicode_logic_kit.dl.translate.abox_to_fol` produces. It is
|
|
2008
|
+
not refused here, because an ABox assembled from a real ontology may well
|
|
2009
|
+
contain it and the honest answer to "is this knowledge base consistent?" is
|
|
2010
|
+
no — not an exception from the constructor.
|
|
2011
|
+
"""
|
|
2012
|
+
self.distinct_assertions.append((a, b))
|
|
2013
|
+
return self
|
|
2014
|
+
|
|
2015
|
+
def assert_same(self, a: str, b: str) -> "ABox":
|
|
2016
|
+
"""Add a same-individual assertion ``a = b`` (``SameIndividual(a b)``,
|
|
2017
|
+
chainable).
|
|
2018
|
+
|
|
2019
|
+
The mirror of :meth:`assert_distinct`: there is no unique name
|
|
2020
|
+
assumption here (see the module docstring), so two names MAY denote one
|
|
2021
|
+
element — this is how to say that they DO. The tableau decides it by
|
|
2022
|
+
genuine node MERGING, closed under the equivalence the assertions
|
|
2023
|
+
generate, before any completion rule runs (see "Same-individual
|
|
2024
|
+
assertions" in the module docstring); the FOL image is the atom
|
|
2025
|
+
``a = b``.
|
|
2026
|
+
|
|
2027
|
+
``assert_same(a, a)`` is ACCEPTED and is a no-op — ``a = a`` holds in
|
|
2028
|
+
every model — the way ``assert_distinct(a, a)`` is accepted and makes
|
|
2029
|
+
the ABox inconsistent: an ABox assembled from a real ontology may
|
|
2030
|
+
contain either, and the honest answer is the consistency verdict, not
|
|
2031
|
+
an exception from the constructor.
|
|
2032
|
+
"""
|
|
2033
|
+
self.same_assertions.append((a, b))
|
|
2034
|
+
return self
|
|
2035
|
+
|
|
2036
|
+
def assert_negative_role(self, a: str, b: str, role: str) -> "ABox":
|
|
2037
|
+
"""Add a negative role assertion ``¬role(a, b)``
|
|
2038
|
+
(``NegativeObjectPropertyAssertion(role a b)``, chainable).
|
|
2039
|
+
|
|
2040
|
+
Argument order matches :meth:`assert_role`'s — the two individuals
|
|
2041
|
+
first, the role last. A ground FACT about two individuals, so it is
|
|
2042
|
+
stored natively rather than as the concept assertion
|
|
2043
|
+
``a : ¬∃role.{b}``: its FOL image must be the ground literal
|
|
2044
|
+
``¬role(a, b)``, and
|
|
2045
|
+
:func:`~unicode_logic_kit.dl.owl_functional.to_owl_functional` must
|
|
2046
|
+
round-trip the axiom back to itself.
|
|
2047
|
+
|
|
2048
|
+
The tableau decides it by a clash condition over the branch's edges,
|
|
2049
|
+
closed under the role hierarchy, and REFUSES by name the one fragment
|
|
2050
|
+
it cannot see: a negative assertion on a NON-SIMPLE role (see
|
|
2051
|
+
"Negative role assertions" in the module docstring).
|
|
2052
|
+
"""
|
|
2053
|
+
self.negative_role_assertions.append((a, b, role))
|
|
2054
|
+
return self
|
|
2055
|
+
|
|
2056
|
+
def assert_data(self, individual: str, prop: str, value: Literal) -> "ABox":
|
|
2057
|
+
"""Add ``DataPropertyAssertion(prop individual value)`` — the ground
|
|
2058
|
+
atom ``prop(individual, t)`` for the literal's term ``t`` — and return
|
|
2059
|
+
self (chainable).
|
|
2060
|
+
|
|
2061
|
+
Individual first, to match :meth:`assert_concept`, and not OWL's
|
|
2062
|
+
property-first order. Only the individual is an INDIVIDUAL
|
|
2063
|
+
(:data:`_AXIOM_KINDS`' ``individual_positions``): the literal's term is
|
|
2064
|
+
a data value and is never reported in ``KnowledgeBaseFOL.individuals``.
|
|
2065
|
+
Refused by the tableau (no data domain).
|
|
2066
|
+
|
|
2067
|
+
Raises:
|
|
2068
|
+
TypeError: ``value`` is not a :class:`~unicode_logic_kit.dl.datatypes.Literal`.
|
|
2069
|
+
"""
|
|
2070
|
+
_check_literal(value, "dl.ABox.assert_data")
|
|
2071
|
+
self.data_assertions.append((individual, prop, value))
|
|
2072
|
+
return self
|
|
2073
|
+
|
|
2074
|
+
def assert_negative_data(self, individual: str, prop: str, value: Literal) -> "ABox":
|
|
2075
|
+
"""Add ``NegativeDataPropertyAssertion(prop individual value)`` — the
|
|
2076
|
+
ground literal ``¬prop(individual, t)`` — and return self (chainable).
|
|
2077
|
+
Same argument order as :meth:`assert_data`. Refused by the tableau.
|
|
2078
|
+
|
|
2079
|
+
Raises:
|
|
2080
|
+
TypeError: ``value`` is not a :class:`~unicode_logic_kit.dl.datatypes.Literal`.
|
|
2081
|
+
"""
|
|
2082
|
+
_check_literal(value, "dl.ABox.assert_negative_data")
|
|
2083
|
+
self.negative_data_assertions.append((individual, prop, value))
|
|
2084
|
+
return self
|
|
2085
|
+
|
|
2086
|
+
def copy(self) -> "ABox":
|
|
2087
|
+
"""A shallow copy with FRESH lists: same assertions, independent
|
|
2088
|
+
storage, so mutating the copy's lists (every ``assert_*`` mutates in
|
|
2089
|
+
place) cannot reach back into the original.
|
|
2090
|
+
|
|
2091
|
+
ONE place, so an ABox field added later is carried over by
|
|
2092
|
+
construction. :func:`instance_check` built its probe ABox field by
|
|
2093
|
+
field until 0.30.0, which meant every assertion kind added to
|
|
2094
|
+
:class:`ABox` had to be remembered there as well — and forgetting it
|
|
2095
|
+
was SILENT: ``instance_check``/``instance_retrieval``/``realize``/
|
|
2096
|
+
``realize_all`` would all answer about a strictly WEAKER knowledge base
|
|
2097
|
+
than :func:`abox_consistent` sees on the same ABox, which is the two-
|
|
2098
|
+
routes-disagree bug in its purest form.
|
|
2099
|
+
|
|
2100
|
+
``dataclasses.replace`` is not used because it SHARES the lists it does
|
|
2101
|
+
not replace, which is exactly what the explicit ``list(...)`` calls
|
|
2102
|
+
here exist to avoid.
|
|
2103
|
+
"""
|
|
2104
|
+
return ABox(**{f.name: list(getattr(self, f.name))
|
|
2105
|
+
for f in dataclass_fields(ABox)})
|
|
2106
|
+
|
|
2107
|
+
def is_empty(self) -> bool:
|
|
2108
|
+
"""True iff this ABox carries no assertion of ANY kind.
|
|
2109
|
+
|
|
2110
|
+
DERIVED from :data:`_AXIOM_KINDS` (every ``holder == "abox"`` row's
|
|
2111
|
+
field), not from a hand-written ``or`` over three attributes — which is
|
|
2112
|
+
what :func:`~unicode_logic_kit.dl.translate.kb_to_fol` reads to decide
|
|
2113
|
+
whether the knowledge-base formula has an ABox half at all. A new
|
|
2114
|
+
assertion kind that escaped a hand-written condition there would be a
|
|
2115
|
+
SILENT loss (the ABox half dropped from the formula entirely), not an
|
|
2116
|
+
error; a missing table row is caught by
|
|
2117
|
+
``tests/test_dl_route_agreement.py``'s meta-test instead.
|
|
2118
|
+
"""
|
|
2119
|
+
return not any(getattr(self, field) for field in _holder_fields("abox"))
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
# --------------------------------------------------------------------------- #
|
|
2123
|
+
# The axiom-kind table: one row per axiom kind a TBox/ABox can hold, carrying
|
|
2124
|
+
# what each of the kit's two routes does with it. See "The axiom-kind table"
|
|
2125
|
+
# in the module docstring for the policy this table makes mechanical.
|
|
2126
|
+
# --------------------------------------------------------------------------- #
|
|
2127
|
+
|
|
2128
|
+
_TABLEAU_EFFECTS = ("internalised", "rule", "refused")
|
|
2129
|
+
_FOL_EFFECTS = ("fol", "two-sorted", "none")
|
|
2130
|
+
_IMAGE_PARTS = ("concepts", "side", "assertions", "none")
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
@dataclass(frozen=True)
|
|
2134
|
+
class _AxiomKind:
|
|
2135
|
+
"""One row of :data:`_AXIOM_KINDS`.
|
|
2136
|
+
|
|
2137
|
+
Fields:
|
|
2138
|
+
|
|
2139
|
+
* ``kind`` — the OWL 2 keyword, e.g. ``"SubObjectPropertyOf"``. The OWL
|
|
2140
|
+
name rather than an internal one, so a caller can census an ontology's
|
|
2141
|
+
image per axiom kind (:meth:`KnowledgeBaseFOL.axioms_of_kind`) without a
|
|
2142
|
+
second translation table.
|
|
2143
|
+
* ``holder`` — ``"tbox"`` or ``"abox"``: which of the two classes stores it.
|
|
2144
|
+
* ``field`` — the attribute on that class. Two kinds MAY share a field
|
|
2145
|
+
(``SubClassOf`` and ``EquivalentClasses`` both live in
|
|
2146
|
+
``TBox.inclusions``, because an equivalence is stored as the pair of
|
|
2147
|
+
inclusions it abbreviates).
|
|
2148
|
+
* ``builder`` — the ``add_*``/``assert_*`` method that fills it. Builders
|
|
2149
|
+
never refuse; see the module docstring.
|
|
2150
|
+
* ``tableau`` — one of :data:`_TABLEAU_EFFECTS`.
|
|
2151
|
+
* ``fol`` — one of :data:`_FOL_EFFECTS`.
|
|
2152
|
+
* ``part`` — one of :data:`_IMAGE_PARTS`.
|
|
2153
|
+
* ``individual_positions`` — for an ABox row, the indices of the stored
|
|
2154
|
+
tuple that hold INDIVIDUAL names (``concept_assertions`` stores
|
|
2155
|
+
``(individual, concept)`` so ``(0,)``; ``role_assertions`` stores
|
|
2156
|
+
``(a, b, role)`` so ``(0, 1)``). :func:`_abox_individual_names` and
|
|
2157
|
+
:func:`~unicode_logic_kit.dl.translate._abox_individuals` both read this,
|
|
2158
|
+
so a new assertion kind contributes its individuals to
|
|
2159
|
+
``KnowledgeBaseFOL.individuals`` by declaring positions here rather than
|
|
2160
|
+
by being remembered in two scans.
|
|
2161
|
+
* ``layer`` — ``"object"`` (the default) or ``"data"``: which half of OWL 2
|
|
2162
|
+
the kind belongs to. The data half is the one NO in-house route here
|
|
2163
|
+
decides, and :func:`_reject_unsupported` words its pointer accordingly
|
|
2164
|
+
(the external HermiT route is not wired to it either); the other
|
|
2165
|
+
consumers that must refuse it by name read this column.
|
|
2166
|
+
* ``refusal_note`` — for a ``"refused"`` row, one sentence saying WHY this
|
|
2167
|
+
particular kind has no rule and what to write instead, appended to
|
|
2168
|
+
:func:`_reject_unsupported`'s message. The shared guard names the kind
|
|
2169
|
+
and its count for every refused row alike; the remedy is per construct
|
|
2170
|
+
(``InverseFunctionalObjectProperty`` points at
|
|
2171
|
+
:meth:`TBox.add_functional_role`, ``ReflexiveObjectProperty`` at
|
|
2172
|
+
``⊤ ⊑ ∀r.C``), and a generic message cannot carry that. Empty for a
|
|
2173
|
+
decided kind.
|
|
2174
|
+
"""
|
|
2175
|
+
|
|
2176
|
+
kind: str
|
|
2177
|
+
holder: str
|
|
2178
|
+
field: str
|
|
2179
|
+
builder: str
|
|
2180
|
+
tableau: str
|
|
2181
|
+
fol: str
|
|
2182
|
+
part: str
|
|
2183
|
+
individual_positions: Tuple[int, ...] = ()
|
|
2184
|
+
refusal_note: str = ""
|
|
2185
|
+
layer: str = "object"
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
#: The refusal note every DATA row carries: one reason, because it is one
|
|
2189
|
+
#: reason (the tableau has no data domain), plus the route that answers.
|
|
2190
|
+
_DATA_NOTE = (
|
|
2191
|
+
"the in-house tableau has no data domain: it cannot decide datatype "
|
|
2192
|
+
"membership, facet arithmetic, the cardinality of a value space, or that "
|
|
2193
|
+
"two literals of one datatype denote DIFFERENT values (so no ≤-rule may "
|
|
2194
|
+
"ever merge two data nodes), and skipping the axiom would answer for a "
|
|
2195
|
+
"weaker knowledge base. The FOL image carries the data layer — "
|
|
2196
|
+
"dl.kb_to_fol renders it, with the OwlThing/OwlData and datatype-lattice "
|
|
2197
|
+
"side axioms, for api.prove")
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
_AXIOM_KINDS: Tuple[_AxiomKind, ...] = (
|
|
2201
|
+
# --- the concept-level TBox: ordinary GCIs, internalised onto every label
|
|
2202
|
+
_AxiomKind("SubClassOf", "tbox", "inclusions", "add",
|
|
2203
|
+
tableau="internalised", fol="fol", part="concepts"),
|
|
2204
|
+
_AxiomKind("EquivalentClasses", "tbox", "inclusions", "add_equivalence",
|
|
2205
|
+
tableau="internalised", fol="fol", part="concepts"),
|
|
2206
|
+
# --- the RBox: not internalised (TBox.internalized leaves it out on
|
|
2207
|
+
# purpose) but read by _saturate's ∀-rule through _RBox — rules H and
|
|
2208
|
+
# S of "Role hierarchies and transitive roles (RBox)" above, whose
|
|
2209
|
+
# termination argument is written there. The FOL image renders them as
|
|
2210
|
+
# SIDE axioms, never as conjuncts of the knowledge-base formula.
|
|
2211
|
+
_AxiomKind("SubObjectPropertyOf", "tbox", "role_inclusions", "add_role_inclusion",
|
|
2212
|
+
tableau="rule", fol="fol", part="side"),
|
|
2213
|
+
_AxiomKind("TransitiveObjectProperty", "tbox", "transitive_roles", "add_transitive_role",
|
|
2214
|
+
tableau="rule", fol="fol", part="side"),
|
|
2215
|
+
# --- the rest of the OWL 2 object property box. See "The rest of the OWL 2
|
|
2216
|
+
# role box" in the module docstring for the derivation of every
|
|
2217
|
+
# "rule"/"internalised"/"refused" below; each of them renders as a SIDE
|
|
2218
|
+
# axiom.
|
|
2219
|
+
# Decided, each by one clash condition over the edge closure (_clash):
|
|
2220
|
+
_AxiomKind("DisjointObjectProperties", "tbox", "disjoint_role_pairs",
|
|
2221
|
+
"add_disjoint_roles", tableau="rule", fol="fol", part="side"),
|
|
2222
|
+
_AxiomKind("AsymmetricObjectProperty", "tbox", "asymmetric_roles",
|
|
2223
|
+
"add_asymmetric_role", tableau="rule", fol="fol", part="side"),
|
|
2224
|
+
_AxiomKind("IrreflexiveObjectProperty", "tbox", "irreflexive_roles",
|
|
2225
|
+
"add_irreflexive_role", tableau="rule", fol="fol", part="side"),
|
|
2226
|
+
# Decided by INTERNALISATION, not by a rule: ⊤ ⊑ ≤1 P.⊤ (see
|
|
2227
|
+
# _new_branch), so the existing ALCQ argument covers it unchanged. The
|
|
2228
|
+
# row says "internalised" for that reason; it said "rule" while this
|
|
2229
|
+
# very comment said the opposite.
|
|
2230
|
+
_AxiomKind("FunctionalObjectProperty", "tbox", "functional_roles",
|
|
2231
|
+
"add_functional_role", tableau="internalised", fol="fol",
|
|
2232
|
+
part="side"),
|
|
2233
|
+
# Refused by name, with the construct's own remedy:
|
|
2234
|
+
_AxiomKind("InverseObjectProperties", "tbox", "inverse_role_pairs",
|
|
2235
|
+
"add_inverse_roles", tableau="refused", fol="fol", part="side",
|
|
2236
|
+
refusal_note=(
|
|
2237
|
+
"InverseObjectProperties(P Q) is the I of SHIQ: with P ≡ Q⁻, "
|
|
2238
|
+
"an edge x —P→ y makes y —Q→ x hold, so y : ∀Q.C forces C "
|
|
2239
|
+
"onto x — a node's label would depend on what lies BACKWARD "
|
|
2240
|
+
"across an edge, which subset blocking's soundness/"
|
|
2241
|
+
"completeness argument does not cover (see the module "
|
|
2242
|
+
"docstring's 'Inverse roles and nominals (I, O)' section, "
|
|
2243
|
+
"the same reason a bare InverseRole concept is refused)")),
|
|
2244
|
+
_AxiomKind("SymmetricObjectProperty", "tbox", "symmetric_roles",
|
|
2245
|
+
"add_symmetric_role", tableau="refused", fol="fol", part="side",
|
|
2246
|
+
refusal_note=(
|
|
2247
|
+
"a symmetric role IS the inverse-role inclusion P ⊑ P⁻: "
|
|
2248
|
+
"materialising the converse edge would put a cycle in the "
|
|
2249
|
+
"completion graph, so a blocked node's blocker can become "
|
|
2250
|
+
"its own descendant and subset blocking no longer applies")),
|
|
2251
|
+
_AxiomKind("ReflexiveObjectProperty", "tbox", "reflexive_roles",
|
|
2252
|
+
"add_reflexive_role", tableau="refused", fol="fol", part="side",
|
|
2253
|
+
refusal_note=(
|
|
2254
|
+
"a reflexive role makes every individual its OWN neighbour, "
|
|
2255
|
+
"and counting a node among its own neighbours lets the "
|
|
2256
|
+
"≤-rule merge a node with its own successor — the back-edge "
|
|
2257
|
+
"case this tableau's termination argument excludes (see the "
|
|
2258
|
+
"module docstring's 'Qualified number restrictions'). The "
|
|
2259
|
+
"restriction ⊤ ⊑ ∀r.C is the concept-level way to spell what "
|
|
2260
|
+
"a reflexive r would propagate")),
|
|
2261
|
+
_AxiomKind("InverseFunctionalObjectProperty", "tbox",
|
|
2262
|
+
"inverse_functional_roles", "add_inverse_functional_role",
|
|
2263
|
+
tableau="refused", fol="fol", part="side",
|
|
2264
|
+
refusal_note=(
|
|
2265
|
+
"InverseFunctionalObjectProperty(P) is the number "
|
|
2266
|
+
"restriction ≤1 P⁻.⊤, so it counts P-PREDECESSORS and needs "
|
|
2267
|
+
"inverse roles (the I of SHIQ). If you meant functionality "
|
|
2268
|
+
"on P itself, use dl.TBox.add_functional_role, which this "
|
|
2269
|
+
"tableau does decide")),
|
|
2270
|
+
_AxiomKind("ObjectPropertyChain", "tbox", "role_chains", "add_role_chain",
|
|
2271
|
+
tableau="refused", fol="fol", part="side",
|
|
2272
|
+
refusal_note=(
|
|
2273
|
+
"a complex role inclusion P1 ∘ … ∘ Pn ⊑ Q is the R of "
|
|
2274
|
+
"SROIQ: deciding it needs the role automaton and the "
|
|
2275
|
+
"regularity restriction of Horrocks, Kutz & Sattler 2006, "
|
|
2276
|
+
"which this tableau does not implement (the local label "
|
|
2277
|
+
"rule that would cover an acyclic chain set is written out "
|
|
2278
|
+
"in the module docstring). Ignoring the chain instead would "
|
|
2279
|
+
"report 'not subsumed' for a subsumption the FOL image "
|
|
2280
|
+
"proves, so this refuses")),
|
|
2281
|
+
# Decided by INTERNALISATION as well, and the only role-box kinds whose
|
|
2282
|
+
# axiom carries a CLASS EXPRESSION: a domain axiom is the GCI
|
|
2283
|
+
# ∃P.⊤ ⊑ C and a range axiom is ⊤ ⊑ ∀P.C (see "Domain and range
|
|
2284
|
+
# axioms" in the module docstring), so the existing ⊓/⊔/∃/∀ rules
|
|
2285
|
+
# decide them with no new machinery. Their FOL image is nevertheless
|
|
2286
|
+
# the direct ∀x ∀y (P(x, y) → C(x/y)) sentence, not the GCI rewrite.
|
|
2287
|
+
_AxiomKind("ObjectPropertyDomain", "tbox", "role_domains", "add_role_domain",
|
|
2288
|
+
tableau="internalised", fol="fol", part="side"),
|
|
2289
|
+
_AxiomKind("ObjectPropertyRange", "tbox", "role_ranges", "add_role_range",
|
|
2290
|
+
tableau="internalised", fol="fol", part="side"),
|
|
2291
|
+
# --- the ABox: seeded into the initial branch by abox_consistent and
|
|
2292
|
+
# decided by the clash conditions (_clash), which is the "rule"
|
|
2293
|
+
# column's third form.
|
|
2294
|
+
_AxiomKind("ClassAssertion", "abox", "concept_assertions", "assert_concept",
|
|
2295
|
+
tableau="rule", fol="fol", part="assertions",
|
|
2296
|
+
individual_positions=(0,)),
|
|
2297
|
+
_AxiomKind("ObjectPropertyAssertion", "abox", "role_assertions", "assert_role",
|
|
2298
|
+
tableau="rule", fol="fol", part="assertions",
|
|
2299
|
+
individual_positions=(0, 1)),
|
|
2300
|
+
_AxiomKind("DifferentIndividuals", "abox", "distinct_assertions", "assert_distinct",
|
|
2301
|
+
tableau="rule", fol="fol", part="assertions",
|
|
2302
|
+
individual_positions=(0, 1)),
|
|
2303
|
+
# Decided by node MERGING at setup, closed under the equivalence the
|
|
2304
|
+
# assertions generate (see "Same-individual assertions" in the module
|
|
2305
|
+
# docstring) -- the ≤-rule's own _merge, reused.
|
|
2306
|
+
_AxiomKind("SameIndividual", "abox", "same_assertions", "assert_same",
|
|
2307
|
+
tableau="rule", fol="fol", part="assertions",
|
|
2308
|
+
individual_positions=(0, 1)),
|
|
2309
|
+
# Decided by a clash condition over _Branch.negative_edges, closed
|
|
2310
|
+
# under the role hierarchy (see "Negative role assertions").
|
|
2311
|
+
_AxiomKind("NegativeObjectPropertyAssertion", "abox",
|
|
2312
|
+
"negative_role_assertions", "assert_negative_role",
|
|
2313
|
+
tableau="rule", fol="fol", part="assertions",
|
|
2314
|
+
individual_positions=(0, 1)),
|
|
2315
|
+
# --- the DATA layer: every kind REFUSED by the tableau (it has no data
|
|
2316
|
+
# domain -- see "The data layer" in the module docstring) and rendered
|
|
2317
|
+
# by the FOL image as an ordinary side axiom / ABox conjunct. The
|
|
2318
|
+
# two-sorted discipline around them (OwlThing/OwlData, the datatype
|
|
2319
|
+
# lattice) is NOT a row: it is derived from the vocabulary by
|
|
2320
|
+
# dl.translate.data_sort_axioms, not stored.
|
|
2321
|
+
_AxiomKind("SubDataPropertyOf", "tbox", "data_property_inclusions",
|
|
2322
|
+
"add_data_property_inclusion", tableau="refused", fol="fol",
|
|
2323
|
+
part="side", refusal_note=_DATA_NOTE, layer="data"),
|
|
2324
|
+
_AxiomKind("DisjointDataProperties", "tbox", "disjoint_data_property_pairs",
|
|
2325
|
+
"add_disjoint_data_properties", tableau="refused", fol="fol",
|
|
2326
|
+
part="side", refusal_note=_DATA_NOTE, layer="data"),
|
|
2327
|
+
_AxiomKind("FunctionalDataProperty", "tbox", "functional_data_properties",
|
|
2328
|
+
"add_functional_data_property", tableau="refused", fol="fol",
|
|
2329
|
+
part="side", refusal_note=_DATA_NOTE, layer="data"),
|
|
2330
|
+
_AxiomKind("DataPropertyDomain", "tbox", "data_property_domains",
|
|
2331
|
+
"add_data_property_domain", tableau="refused", fol="fol",
|
|
2332
|
+
part="side", refusal_note=_DATA_NOTE, layer="data"),
|
|
2333
|
+
_AxiomKind("DataPropertyRange", "tbox", "data_property_ranges",
|
|
2334
|
+
"add_data_property_range", tableau="refused", fol="fol",
|
|
2335
|
+
part="side", refusal_note=_DATA_NOTE, layer="data"),
|
|
2336
|
+
_AxiomKind("DatatypeDefinition", "tbox", "datatype_definitions",
|
|
2337
|
+
"add_datatype_definition", tableau="refused", fol="fol",
|
|
2338
|
+
part="side", refusal_note=_DATA_NOTE, layer="data"),
|
|
2339
|
+
_AxiomKind("DataPropertyAssertion", "abox", "data_assertions", "assert_data",
|
|
2340
|
+
tableau="refused", fol="fol", part="assertions",
|
|
2341
|
+
individual_positions=(0,), refusal_note=_DATA_NOTE, layer="data"),
|
|
2342
|
+
_AxiomKind("NegativeDataPropertyAssertion", "abox",
|
|
2343
|
+
"negative_data_assertions", "assert_negative_data",
|
|
2344
|
+
tableau="refused", fol="fol", part="assertions",
|
|
2345
|
+
individual_positions=(0,), refusal_note=_DATA_NOTE, layer="data"),
|
|
2346
|
+
)
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
def _validate_axiom_kinds(rows: Optional[Tuple[_AxiomKind, ...]] = None) -> None:
|
|
2350
|
+
"""Check :data:`_AXIOM_KINDS`' own well-formedness at import time.
|
|
2351
|
+
|
|
2352
|
+
A typo in a vocabulary word (``"refuse"`` for ``"refused"``) would make
|
|
2353
|
+
every guard derived from the column silently fall through, which is the
|
|
2354
|
+
one failure mode the table exists to prevent — so it is caught here, when
|
|
2355
|
+
the module is imported, rather than by whichever test happens to notice.
|
|
2356
|
+
"""
|
|
2357
|
+
rows = _AXIOM_KINDS if rows is None else rows
|
|
2358
|
+
seen = set()
|
|
2359
|
+
for row in rows:
|
|
2360
|
+
if row.kind in seen:
|
|
2361
|
+
raise ValueError(f"_AXIOM_KINDS: duplicate kind {row.kind!r}")
|
|
2362
|
+
seen.add(row.kind)
|
|
2363
|
+
if row.holder not in ("tbox", "abox"):
|
|
2364
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: holder {row.holder!r}")
|
|
2365
|
+
if row.tableau not in _TABLEAU_EFFECTS:
|
|
2366
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: tableau {row.tableau!r} "
|
|
2367
|
+
f"is not one of {_TABLEAU_EFFECTS}")
|
|
2368
|
+
if row.fol not in _FOL_EFFECTS:
|
|
2369
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: fol {row.fol!r} "
|
|
2370
|
+
f"is not one of {_FOL_EFFECTS}")
|
|
2371
|
+
if row.part not in _IMAGE_PARTS:
|
|
2372
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: part {row.part!r} "
|
|
2373
|
+
f"is not one of {_IMAGE_PARTS}")
|
|
2374
|
+
holder = TBox if row.holder == "tbox" else ABox
|
|
2375
|
+
if row.field not in {f.name for f in dataclass_fields(holder)}:
|
|
2376
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: {holder.__name__} has "
|
|
2377
|
+
f"no field {row.field!r}")
|
|
2378
|
+
if not callable(getattr(holder, row.builder, None)):
|
|
2379
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: {holder.__name__} has "
|
|
2380
|
+
f"no builder method {row.builder!r}")
|
|
2381
|
+
if row.holder == "tbox" and row.individual_positions:
|
|
2382
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: individual_positions "
|
|
2383
|
+
"is for ABox rows only")
|
|
2384
|
+
if (row.fol == "none") != (row.part == "none"):
|
|
2385
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: fol={row.fol!r} and "
|
|
2386
|
+
f"part={row.part!r} disagree about whether the FOL "
|
|
2387
|
+
"image renders this kind")
|
|
2388
|
+
if row.layer not in ("object", "data"):
|
|
2389
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: layer {row.layer!r} "
|
|
2390
|
+
"is not one of ('object', 'data')")
|
|
2391
|
+
if row.tableau != "refused" and row.refusal_note:
|
|
2392
|
+
raise ValueError(f"_AXIOM_KINDS[{row.kind}]: tableau={row.tableau!r} "
|
|
2393
|
+
"but a refusal_note is set — a decided kind has "
|
|
2394
|
+
"nothing to refuse")
|
|
2395
|
+
|
|
2396
|
+
|
|
2397
|
+
_validate_axiom_kinds()
|
|
2398
|
+
|
|
2399
|
+
|
|
2400
|
+
def _holder_fields(holder: str, *, part: Optional[str] = None,
|
|
2401
|
+
tableau: Optional[str] = None,
|
|
2402
|
+
layer: Optional[str] = None) -> Tuple[str, ...]:
|
|
2403
|
+
"""The distinct ``field`` names of :data:`_AXIOM_KINDS`' rows for ``holder``,
|
|
2404
|
+
optionally narrowed to one ``part``, one ``tableau`` effect and/or one
|
|
2405
|
+
``layer`` (``"object"`` or ``"data"``).
|
|
2406
|
+
|
|
2407
|
+
De-duplicated and in table order, because two kinds may share a field and
|
|
2408
|
+
a caller counting a field twice would report an axiom twice.
|
|
2409
|
+
"""
|
|
2410
|
+
names = []
|
|
2411
|
+
for row in _AXIOM_KINDS:
|
|
2412
|
+
if row.holder != holder:
|
|
2413
|
+
continue
|
|
2414
|
+
if part is not None and row.part != part:
|
|
2415
|
+
continue
|
|
2416
|
+
if tableau is not None and row.tableau != tableau:
|
|
2417
|
+
continue
|
|
2418
|
+
if layer is not None and row.layer != layer:
|
|
2419
|
+
continue
|
|
2420
|
+
if row.field not in names:
|
|
2421
|
+
names.append(row.field)
|
|
2422
|
+
return tuple(names)
|
|
2423
|
+
|
|
2424
|
+
|
|
2425
|
+
def _concept_individual_names(concept) -> Set[str]:
|
|
2426
|
+
"""Every individual a class expression names, at any depth: the filler of a
|
|
2427
|
+
:class:`~unicode_logic_kit.dl.concepts.Nominal` or of a
|
|
2428
|
+
:class:`~unicode_logic_kit.dl.concepts.HasValue`. Generic over the dataclass
|
|
2429
|
+
fields, so a new concept kind with a nested concept is reached without being
|
|
2430
|
+
named here (the ``individual`` field is the one thing this must know)."""
|
|
2431
|
+
names: Set[str] = set()
|
|
2432
|
+
stack = [concept]
|
|
2433
|
+
while stack:
|
|
2434
|
+
node = stack.pop()
|
|
2435
|
+
if isinstance(node, (Nominal, HasValue)):
|
|
2436
|
+
names.add(node.individual)
|
|
2437
|
+
for fld in dataclass_fields(node) if hasattr(node, "__dataclass_fields__") else ():
|
|
2438
|
+
value = getattr(node, fld.name)
|
|
2439
|
+
if isinstance(value, Concept):
|
|
2440
|
+
stack.append(value)
|
|
2441
|
+
return names
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
def _abox_individual_names(abox: "ABox") -> Set[str]:
|
|
2445
|
+
"""Every individual name ``abox`` mentions, read off :data:`_AXIOM_KINDS`'
|
|
2446
|
+
``individual_positions`` rather than by scanning three fields by hand —
|
|
2447
|
+
plus the individuals named INSIDE an asserted class expression: an
|
|
2448
|
+
individual in an ABox assertion is an individual of the ABox wherever in the
|
|
2449
|
+
assertion it stands, so ``a : ∃r.{b}`` (a value restriction or a nominal, at
|
|
2450
|
+
any depth) makes ``b`` one as well. The fillers of a TBox's class expressions
|
|
2451
|
+
stay out, as :attr:`~unicode_logic_kit.dl.translate.KnowledgeBaseFOL.individuals`
|
|
2452
|
+
documents.
|
|
2453
|
+
"""
|
|
2454
|
+
names: Set[str] = set()
|
|
2455
|
+
for row in _AXIOM_KINDS:
|
|
2456
|
+
if row.holder != "abox" or not row.individual_positions:
|
|
2457
|
+
continue
|
|
2458
|
+
for stored in getattr(abox, row.field):
|
|
2459
|
+
for position in row.individual_positions:
|
|
2460
|
+
names.add(stored[position])
|
|
2461
|
+
for _individual, concept in abox.concept_assertions:
|
|
2462
|
+
names |= _concept_individual_names(concept)
|
|
2463
|
+
return names
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def _data_layer_kinds(tbox: Optional["TBox"], abox: Optional["ABox"]) -> List[str]:
|
|
2467
|
+
"""The ``layer == "data"`` kinds ``(tbox, abox)`` actually carries, in table
|
|
2468
|
+
order — every one of them, refused by the tableau or not (today they are the
|
|
2469
|
+
same set). What the OTHER consumers that must refuse the data layer by
|
|
2470
|
+
name (``dl.owl_reasoner``) ask.
|
|
2471
|
+
"""
|
|
2472
|
+
found: List[str] = []
|
|
2473
|
+
for row in _AXIOM_KINDS:
|
|
2474
|
+
if row.layer != "data":
|
|
2475
|
+
continue
|
|
2476
|
+
obj = tbox if row.holder == "tbox" else abox
|
|
2477
|
+
if obj is not None and getattr(obj, row.field):
|
|
2478
|
+
found.append(row.kind)
|
|
2479
|
+
return found
|
|
2480
|
+
|
|
2481
|
+
|
|
2482
|
+
def _refused_axioms(tbox: Optional["TBox"],
|
|
2483
|
+
abox: Optional["ABox"]) -> List[Tuple[str, int, Tuple[str, ...]]]:
|
|
2484
|
+
"""``(kind-name, count, notes)`` for every ``"refused"`` row of
|
|
2485
|
+
:data:`_AXIOM_KINDS` that ``(tbox, abox)`` actually carries, in table order.
|
|
2486
|
+
|
|
2487
|
+
Kinds that share a storing field are reported as one entry naming both
|
|
2488
|
+
(``"A/B"``), since the field's length is the only count there is; ``notes``
|
|
2489
|
+
then carries each of their ``refusal_note``s.
|
|
2490
|
+
"""
|
|
2491
|
+
found: List[Tuple[str, int, Tuple[str, ...]]] = []
|
|
2492
|
+
for holder, obj in (("tbox", tbox), ("abox", abox)):
|
|
2493
|
+
if obj is None:
|
|
2494
|
+
continue
|
|
2495
|
+
for field in _holder_fields(holder, tableau="refused"):
|
|
2496
|
+
count = len(getattr(obj, field))
|
|
2497
|
+
if not count:
|
|
2498
|
+
continue
|
|
2499
|
+
rows = [row for row in _AXIOM_KINDS
|
|
2500
|
+
if row.holder == holder and row.field == field
|
|
2501
|
+
and row.tableau == "refused"]
|
|
2502
|
+
found.append(("/".join(row.kind for row in rows), count,
|
|
2503
|
+
tuple(row.refusal_note for row in rows if row.refusal_note)))
|
|
2504
|
+
return found
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
def _reject_unsupported(tbox: Optional["TBox"], abox: Optional["ABox"]) -> None:
|
|
2508
|
+
"""Raise :class:`UnsupportedAxiomError` if ``(tbox, abox)`` carries an axiom
|
|
2509
|
+
kind this tableau has no rule for — every such kind at once, with counts.
|
|
2510
|
+
|
|
2511
|
+
THE single shared axiom-level guard — it has ONE caller, :func:`_reject_role_box`,
|
|
2512
|
+
which is the first statement of :func:`concept_satisfiable`,
|
|
2513
|
+
:func:`abox_consistent` and ``classify``, and which :func:`_reject_inputs`
|
|
2514
|
+
calls for the entry points that can return WITHOUT reaching either
|
|
2515
|
+
(``realize`` on an empty vocabulary, ``realize_all``/``instance_retrieval``
|
|
2516
|
+
on an empty ABox, ``classify`` on fewer than two names).
|
|
2517
|
+
``instance_check``/``subsumes``/``equivalent``/``concept_unsatisfiable``
|
|
2518
|
+
hold no guard of their own: each reduces to one of the two deciding
|
|
2519
|
+
functions with something to decide, and inherits it, exactly as they
|
|
2520
|
+
inherit :func:`_reject_beyond_alc`.
|
|
2521
|
+
|
|
2522
|
+
Each refused kind's own ``refusal_note`` is appended, so the message says
|
|
2523
|
+
not only WHICH construct has no rule but why and what to write instead —
|
|
2524
|
+
the remedy differs per construct (``InverseFunctionalObjectProperty``
|
|
2525
|
+
points at :meth:`TBox.add_functional_role`, ``ReflexiveObjectProperty`` at
|
|
2526
|
+
``⊤ ⊑ ∀r.C``), and the kind name alone cannot carry that.
|
|
2527
|
+
"""
|
|
2528
|
+
refused = _refused_axioms(tbox, abox)
|
|
2529
|
+
if not refused:
|
|
2530
|
+
return
|
|
2531
|
+
listing = ", ".join(f"{kind} x{count}" for kind, count, _ in refused)
|
|
2532
|
+
# De-duplicated, in order: the eight data kinds share one reason, and a
|
|
2533
|
+
# knowledge base carrying several of them should read it once.
|
|
2534
|
+
notes = list(dict.fromkeys(
|
|
2535
|
+
note for _, _, kind_notes in refused for note in kind_notes))
|
|
2536
|
+
detail = ("".join(f" Why {note}." for note in notes)) if notes else ""
|
|
2537
|
+
object_kinds = [name for name, _, _ in refused
|
|
2538
|
+
if any(row.layer == "object" and row.kind in name.split("/")
|
|
2539
|
+
for row in _AXIOM_KINDS)]
|
|
2540
|
+
if object_kinds:
|
|
2541
|
+
remedy = (f"Use dl.owl_reasoner's external, HermiT-backed reasoner "
|
|
2542
|
+
f"(dl.external_abox_consistent, dl.external_subsumes, …) to "
|
|
2543
|
+
f"decide them, or ask the same question of the FOL image with "
|
|
2544
|
+
f"dl.kb_to_fol(tbox, abox) and api.prove.")
|
|
2545
|
+
if len(object_kinds) < len(refused):
|
|
2546
|
+
remedy += (" (The data kinds among them are decided by neither "
|
|
2547
|
+
"tableau nor HermiT — dl.owl_reasoner refuses the data "
|
|
2548
|
+
"layer by name too — only by the FOL image.)")
|
|
2549
|
+
else:
|
|
2550
|
+
remedy = (f"dl.owl_reasoner's external, HermiT-backed reasoner is not "
|
|
2551
|
+
f"wired to the data layer either (it refuses it by name), so "
|
|
2552
|
+
f"ask the FOL image: kb = dl.kb_to_fol(tbox, abox, "
|
|
2553
|
+
f"query=[...the concepts you will ask about]), then api.prove "
|
|
2554
|
+
f"with kb.premises or kb.tbox_premises and the goal from "
|
|
2555
|
+
f"kb.subsumption_goal, kb.unsatisfiability_goal or "
|
|
2556
|
+
f"kb.instance_goal. 'proved' transfers to OWL 2 and 'refuted' "
|
|
2557
|
+
f"does not (kb.refutation_is_decisive: the image is sound, not "
|
|
2558
|
+
f"complete). A facet entailment over the data ranges alone is "
|
|
2559
|
+
f"decided by atp.z3_arith.is_valid_arith.")
|
|
2560
|
+
raise UnsupportedAxiomError(
|
|
2561
|
+
f"dl.tableau: this knowledge base carries axiom kinds that are outside "
|
|
2562
|
+
f"ALCHQ (this kit's in-house DL fragment) — no in-house tableau rule "
|
|
2563
|
+
f"decides them: {listing}. Refusing by name is the only honest answer: "
|
|
2564
|
+
f"a tableau that ignored them would report a verdict about a WEAKER "
|
|
2565
|
+
f"knowledge base than the one it was handed.{detail} {remedy}")
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def _reject_inverse_role_inclusions(tbox: "TBox") -> None:
|
|
2569
|
+
"""Raise :class:`UnsupportedAxiomError` if any role inclusion has an
|
|
2570
|
+
:class:`~unicode_logic_kit.dl.concepts.InverseRole` on either side.
|
|
2571
|
+
|
|
2572
|
+
Not a ``"refused"`` ROW of :data:`_AXIOM_KINDS`, because
|
|
2573
|
+
``SubObjectPropertyOf`` as a KIND is decided: this is a condition on the
|
|
2574
|
+
stored VALUE, and ``r ⊑ s⁻`` is the **I** of SHIQ exactly as
|
|
2575
|
+
``InverseObjectProperties`` is (see that row's ``refusal_note``). The FOL
|
|
2576
|
+
image renders it correctly, so this is the entry that makes the two routes
|
|
2577
|
+
agree about it rather than letting the tableau answer a question about a
|
|
2578
|
+
role box it silently read as atomic.
|
|
2579
|
+
"""
|
|
2580
|
+
for sub_role, super_role in tbox.role_inclusions:
|
|
2581
|
+
for side, value in (("sub-role", sub_role), ("super-role", super_role)):
|
|
2582
|
+
if isinstance(value, InverseRole):
|
|
2583
|
+
raise UnsupportedAxiomError(
|
|
2584
|
+
f"dl.tableau: the role box declares the role inclusion "
|
|
2585
|
+
f"{_role_text(sub_role)} ⊑ {_role_text(super_role)}, whose "
|
|
2586
|
+
f"{side} is an INVERSE role ({value.role}⁻) — the I of "
|
|
2587
|
+
f"SHIQ, outside ALCHQ (this kit's in-house DL fragment): no "
|
|
2588
|
+
f"in-house tableau rule decides it, because a successor's "
|
|
2589
|
+
f"label would then depend on what lies BACKWARD across an "
|
|
2590
|
+
f"edge, which subset blocking's soundness/completeness "
|
|
2591
|
+
f"argument does not cover (see the module docstring's "
|
|
2592
|
+
f"'Inverse roles and nominals (I, O)' section). "
|
|
2593
|
+
f"dl.rbox_to_fol DOES render this axiom correctly, so ask "
|
|
2594
|
+
f"the question of the FOL image with dl.kb_to_fol(tbox, "
|
|
2595
|
+
f"abox) and api.prove, or decide the knowledge base with "
|
|
2596
|
+
f"dl.owl_reasoner's external, HermiT-backed reasoner "
|
|
2597
|
+
f"(dl.external_subsumes, dl.external_abox_consistent).")
|
|
2598
|
+
|
|
2599
|
+
|
|
2600
|
+
def _role_text(role) -> str:
|
|
2601
|
+
"""``role`` as it should read in a message: ``'r'`` or ``'r'⁻``."""
|
|
2602
|
+
if isinstance(role, InverseRole):
|
|
2603
|
+
return f"{role.role!r}⁻"
|
|
2604
|
+
return repr(role)
|
|
2605
|
+
|
|
2606
|
+
|
|
2607
|
+
#: ``(TBox field, OWL keyword, how the stored entries name roles)`` for every
|
|
2608
|
+
#: axiom kind OWL 2 (Structural Specification §11) restricts to SIMPLE roles.
|
|
2609
|
+
#: :func:`_check_simple_role_box` is driven by this, so a kind added later is
|
|
2610
|
+
#: covered by one line rather than by being remembered inside a loop.
|
|
2611
|
+
_SIMPLE_ROLE_AXIOMS: Tuple[Tuple[str, str, str], ...] = (
|
|
2612
|
+
("asymmetric_roles", "AsymmetricObjectProperty", "name"),
|
|
2613
|
+
("irreflexive_roles", "IrreflexiveObjectProperty", "name"),
|
|
2614
|
+
("functional_roles", "FunctionalObjectProperty", "name"),
|
|
2615
|
+
("inverse_functional_roles", "InverseFunctionalObjectProperty", "name"),
|
|
2616
|
+
("disjoint_role_pairs", "DisjointObjectProperties", "pair"),
|
|
2617
|
+
)
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
class _OrderedSet(dict):
|
|
2621
|
+
"""A set that iterates in the order its members were first added.
|
|
2622
|
+
|
|
2623
|
+
What the search order of the tableau is made of. A branch's labels and
|
|
2624
|
+
edges were plain ``set`` s, and a ``set`` of strings iterates in an order
|
|
2625
|
+
that depends on ``PYTHONHASHSEED``: the completion rules each take "the
|
|
2626
|
+
first" unresolved disjunction, existential, ≥-restriction, choice or merge
|
|
2627
|
+
they meet, so the SAME knowledge base was searched in a different order
|
|
2628
|
+
— and used a different number of steps, near the step budget a verdict on
|
|
2629
|
+
one run and "step budget exhausted" on the next — from one process to the
|
|
2630
|
+
next. Here iteration order is a function of the sequence of insertions
|
|
2631
|
+
alone, and every insertion the tableau makes is itself driven by an
|
|
2632
|
+
ordered traversal (the TBox lists, the sorted individuals, this
|
|
2633
|
+
container), so a run is reproducible whatever the hash seed.
|
|
2634
|
+
|
|
2635
|
+
A ``dict`` subclass, so that membership, iteration and ``len`` stay at the
|
|
2636
|
+
speed of the built-in container; only the set operations the tableau uses
|
|
2637
|
+
are spelled out (``add``, ``update``, ``|=``, ``>=`` and ``copy``).
|
|
2638
|
+
"""
|
|
2639
|
+
|
|
2640
|
+
__slots__ = ()
|
|
2641
|
+
|
|
2642
|
+
def __init__(self, members=()):
|
|
2643
|
+
dict.__init__(self, dict.fromkeys(members))
|
|
2644
|
+
|
|
2645
|
+
def add(self, member) -> None:
|
|
2646
|
+
self[member] = None
|
|
2647
|
+
|
|
2648
|
+
def update(self, members) -> None: # type: ignore[override] # a set stored as a dict: takes members, not entries
|
|
2649
|
+
dict.update(self, dict.fromkeys(members))
|
|
2650
|
+
|
|
2651
|
+
def __ior__(self, members): # type: ignore[misc] # set union in place, not dict's merge of entries
|
|
2652
|
+
self.update(members)
|
|
2653
|
+
return self
|
|
2654
|
+
|
|
2655
|
+
def __ge__(self, other) -> bool:
|
|
2656
|
+
"""Superset test, as for a ``set`` (``other`` may be any set-like)."""
|
|
2657
|
+
return self.keys() >= (other.keys() if isinstance(other, dict) else other)
|
|
2658
|
+
|
|
2659
|
+
def copy(self) -> "_OrderedSet":
|
|
2660
|
+
duplicate = _OrderedSet()
|
|
2661
|
+
dict.update(duplicate, self)
|
|
2662
|
+
return duplicate
|
|
2663
|
+
|
|
2664
|
+
def __repr__(self) -> str:
|
|
2665
|
+
return f"_OrderedSet({list(self)!r})"
|
|
2666
|
+
|
|
2667
|
+
|
|
2668
|
+
class _Generated:
|
|
2669
|
+
"""A node the tableau itself created: the witness of an ``∃`` or a ``≥``
|
|
2670
|
+
restriction.
|
|
2671
|
+
|
|
2672
|
+
Whether a node is generated or named is a property of the node — its CLASS —
|
|
2673
|
+
and never of how a name is spelled. A named individual is a ``str``, a
|
|
2674
|
+
generated node is a ``_Generated``, and the two are never equal, so a
|
|
2675
|
+
knowledge base may call an individual anything at all (``_x1`` and ``x0``
|
|
2676
|
+
included) without it ever being merged with a witness, blocked as one, or
|
|
2677
|
+
blocking one. Only a generated node may be blocked or block
|
|
2678
|
+
(:func:`_blocked`), and only a named node is kept when a merge has the
|
|
2679
|
+
choice (:func:`_merge_order`).
|
|
2680
|
+
|
|
2681
|
+
Two generated nodes are equal iff they carry the same serial number, which
|
|
2682
|
+
is unique within a branch. The hash is that of the serial, so the order a
|
|
2683
|
+
container of nodes iterates in never depends on ``PYTHONHASHSEED``.
|
|
2684
|
+
"""
|
|
2685
|
+
|
|
2686
|
+
__slots__ = ("serial",)
|
|
2687
|
+
|
|
2688
|
+
def __init__(self, serial: int) -> None:
|
|
2689
|
+
self.serial = serial
|
|
2690
|
+
|
|
2691
|
+
def __eq__(self, other: object) -> bool:
|
|
2692
|
+
return isinstance(other, _Generated) and other.serial == self.serial
|
|
2693
|
+
|
|
2694
|
+
def __hash__(self) -> int:
|
|
2695
|
+
return hash(self.serial)
|
|
2696
|
+
|
|
2697
|
+
def __repr__(self) -> str:
|
|
2698
|
+
return f"<generated node {self.serial}>"
|
|
2699
|
+
|
|
2700
|
+
|
|
2701
|
+
#: A node of a branch: a named individual (or the anonymous root) is a ``str``, a
|
|
2702
|
+
#: generated one a :class:`_Generated`.
|
|
2703
|
+
_Node = Union[str, _Generated]
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
class _Branch:
|
|
2707
|
+
"""A single open tableau branch: per-individual labels, role edges, order, and
|
|
2708
|
+
(for **Q**) a pairwise-distinctness relation — see "Qualified number
|
|
2709
|
+
restrictions" in the module docstring.
|
|
2710
|
+
"""
|
|
2711
|
+
|
|
2712
|
+
__slots__ = ("label", "edges", "order", "counter", "distinct",
|
|
2713
|
+
"self_distinct", "negative_edges")
|
|
2714
|
+
|
|
2715
|
+
def __init__(self):
|
|
2716
|
+
self.label = {} # node -> ordered set of concepts
|
|
2717
|
+
self.edges = _OrderedSet() # (src, role, dst), in insertion order
|
|
2718
|
+
self.order = [] # nodes in creation order (for blocking)
|
|
2719
|
+
self.counter = 0
|
|
2720
|
+
self.distinct = set() # frozenset({a, b}) pairs forced distinct
|
|
2721
|
+
self.self_distinct = False # an individual asserted distinct from ITSELF
|
|
2722
|
+
self.negative_edges = set() # (src, role, dst) FORBIDDEN by a
|
|
2723
|
+
# NegativeObjectPropertyAssertion
|
|
2724
|
+
|
|
2725
|
+
def copy(self) -> "_Branch":
|
|
2726
|
+
b = _Branch.__new__(_Branch)
|
|
2727
|
+
b.label = {k: v.copy() for k, v in self.label.items()}
|
|
2728
|
+
b.edges = self.edges.copy()
|
|
2729
|
+
b.order = list(self.order)
|
|
2730
|
+
b.counter = self.counter
|
|
2731
|
+
b.distinct = set(self.distinct)
|
|
2732
|
+
b.self_distinct = self.self_distinct
|
|
2733
|
+
b.negative_edges = set(self.negative_edges)
|
|
2734
|
+
return b
|
|
2735
|
+
|
|
2736
|
+
def add_node(self, node: "_Node") -> None:
|
|
2737
|
+
if node not in self.label:
|
|
2738
|
+
self.label[node] = _OrderedSet()
|
|
2739
|
+
self.order.append(node)
|
|
2740
|
+
|
|
2741
|
+
def fresh(self) -> "_Generated":
|
|
2742
|
+
"""A new GENERATED node, added to the branch.
|
|
2743
|
+
|
|
2744
|
+
It is a :class:`_Generated`, not a name: it equals no string, so it can
|
|
2745
|
+
be neither a named individual nor merged with one by accident, whatever
|
|
2746
|
+
the individuals are called.
|
|
2747
|
+
"""
|
|
2748
|
+
self.counter += 1
|
|
2749
|
+
node = _Generated(self.counter)
|
|
2750
|
+
self.add_node(node)
|
|
2751
|
+
return node
|
|
2752
|
+
|
|
2753
|
+
def mark_distinct(self, a: _Node, b: _Node) -> None:
|
|
2754
|
+
"""Force ``a`` and ``b`` (already-added nodes) pairwise distinct.
|
|
2755
|
+
|
|
2756
|
+
``a`` and ``b`` being the SAME node is recorded in ``self_distinct``, which
|
|
2757
|
+
:func:`_clash` reads: ``a ≠ a`` has no model. Dropping it instead — which
|
|
2758
|
+
this method did until 0.30.0 — made ``abox_consistent`` report True for an
|
|
2759
|
+
ABox whose own FOL rendering ``a ≠ a`` is refutable, so the tableau and the
|
|
2760
|
+
FOL cross-check disagreed. The ≥-rule's own witnesses can never reach this:
|
|
2761
|
+
it marks FRESH nodes distinct from each other, pairwise, never from
|
|
2762
|
+
themselves.
|
|
2763
|
+
"""
|
|
2764
|
+
if a == b:
|
|
2765
|
+
self.self_distinct = True
|
|
2766
|
+
else:
|
|
2767
|
+
self.distinct.add(frozenset((a, b)))
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
class _Ctx:
|
|
2771
|
+
def __init__(self, max_steps: int):
|
|
2772
|
+
self.steps = max_steps
|
|
2773
|
+
|
|
2774
|
+
def tick(self) -> None:
|
|
2775
|
+
self.steps -= 1
|
|
2776
|
+
if self.steps <= 0:
|
|
2777
|
+
raise RuntimeError("dl tableau: step budget exhausted (raise dl.tableau.MAX_STEPS).")
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
class _RBox:
|
|
2781
|
+
"""The role-box view of a ``TBox``, precomputed once per :func:`_solve` call
|
|
2782
|
+
(see ``_new_branch``) and consulted read-only from :func:`_saturate`'s
|
|
2783
|
+
∀-rule and from :func:`_clash`.
|
|
2784
|
+
|
|
2785
|
+
See "Role hierarchies and transitive roles (RBox)" and "The rest of the OWL 2
|
|
2786
|
+
role box" in the module docstring for the algorithm this implements and its
|
|
2787
|
+
soundness/termination argument.
|
|
2788
|
+
|
|
2789
|
+
Every parameter after the first two is KEYWORD-ONLY with a default, so the
|
|
2790
|
+
two-positional form ``_RBox(role_inclusions, transitive_roles)`` keeps
|
|
2791
|
+
working; :meth:`from_tbox` is the one real call site.
|
|
2792
|
+
"""
|
|
2793
|
+
|
|
2794
|
+
__slots__ = ("_ancestors", "transitive", "transitive_in_order", "disjoint_pairs",
|
|
2795
|
+
"asymmetric", "irreflexive", "reflexive", "composite",
|
|
2796
|
+
"has_edge_constraints")
|
|
2797
|
+
|
|
2798
|
+
def __init__(self, role_inclusions: List[Tuple[str, str]],
|
|
2799
|
+
transitive_roles: Set[str], *,
|
|
2800
|
+
disjoint_pairs: Iterable[Tuple[str, str]] = (),
|
|
2801
|
+
asymmetric: Iterable[str] = (),
|
|
2802
|
+
irreflexive: Iterable[str] = (),
|
|
2803
|
+
reflexive: Iterable[str] = (),
|
|
2804
|
+
chain_super_roles: Iterable[str] = ()):
|
|
2805
|
+
direct: Dict[str, Set[str]] = {}
|
|
2806
|
+
for sub, sup in role_inclusions:
|
|
2807
|
+
direct.setdefault(sub, set()).add(sup)
|
|
2808
|
+
direct.setdefault(sup, set())
|
|
2809
|
+
for role in transitive_roles:
|
|
2810
|
+
direct.setdefault(role, set())
|
|
2811
|
+
# Reflexive-transitive closure of ⊑ per role, by BFS. A ⊑-cycle just merges
|
|
2812
|
+
# the roles on it into one ancestor set (sound — see the module docstring).
|
|
2813
|
+
ancestors: Dict[str, FrozenSet[str]] = {}
|
|
2814
|
+
for start in direct:
|
|
2815
|
+
seen = {start}
|
|
2816
|
+
frontier = [start]
|
|
2817
|
+
while frontier:
|
|
2818
|
+
cur = frontier.pop()
|
|
2819
|
+
for nxt in direct[cur]:
|
|
2820
|
+
if nxt not in seen:
|
|
2821
|
+
seen.add(nxt)
|
|
2822
|
+
frontier.append(nxt)
|
|
2823
|
+
ancestors[start] = frozenset(seen)
|
|
2824
|
+
self._ancestors = ancestors
|
|
2825
|
+
# Public: the declared-transitive role names, as a set to test
|
|
2826
|
+
# membership against. The ∀+-rule (RBox rule S) in _saturate iterates
|
|
2827
|
+
# ``transitive_in_order`` instead, below.
|
|
2828
|
+
self.transitive: FrozenSet[str] = frozenset(transitive_roles)
|
|
2829
|
+
# The same roles in a fixed order: the ∀+-rule adds one restriction per
|
|
2830
|
+
# qualifying role to a label, and the order of those additions is part of
|
|
2831
|
+
# the search order, so it must not follow the hash of the role names.
|
|
2832
|
+
self.transitive_in_order: Tuple[str, ...] = tuple(
|
|
2833
|
+
sorted(self.transitive, key=repr))
|
|
2834
|
+
# The three EDGE-PATTERN constraints _clash decides (see "The rest of
|
|
2835
|
+
# the OWL 2 role box" in the module docstring).
|
|
2836
|
+
self.disjoint_pairs: FrozenSet[Tuple[str, str]] = frozenset(disjoint_pairs)
|
|
2837
|
+
self.asymmetric: FrozenSet[str] = frozenset(asymmetric)
|
|
2838
|
+
self.irreflexive: FrozenSet[str] = frozenset(irreflexive)
|
|
2839
|
+
# Carried for completeness of the view (and for the external routes);
|
|
2840
|
+
# the clash rules never read it, because a reflexive role box is
|
|
2841
|
+
# refused before the tableau starts.
|
|
2842
|
+
self.reflexive: FrozenSet[str] = frozenset(reflexive)
|
|
2843
|
+
# COMPOSITE in OWL 2's sense (Structural Specification §11): declared
|
|
2844
|
+
# transitive, or the super-role of a property chain. `_is_simple_role`
|
|
2845
|
+
# reads this, so adding chains to the tableau later needs no second
|
|
2846
|
+
# definition of "simple".
|
|
2847
|
+
self.composite: FrozenSet[str] = frozenset(transitive_roles) | frozenset(chain_super_roles)
|
|
2848
|
+
# The short-circuit `_clash` tests before touching the edge set at all:
|
|
2849
|
+
# `_clash` is the innermost loop of the reasoner and a plain ALC/ALCQ
|
|
2850
|
+
# TBox must not pay for a feature it does not use.
|
|
2851
|
+
self.has_edge_constraints: bool = bool(
|
|
2852
|
+
self.disjoint_pairs or self.asymmetric or self.irreflexive)
|
|
2853
|
+
|
|
2854
|
+
@classmethod
|
|
2855
|
+
def from_tbox(cls, tbox: "TBox") -> "_RBox":
|
|
2856
|
+
"""The role-box view of ``tbox`` — the one real construction site.
|
|
2857
|
+
|
|
2858
|
+
Only inclusions between PLAIN role names enter the view. An inclusion
|
|
2859
|
+
with an :class:`~unicode_logic_kit.dl.concepts.InverseRole` on a side is
|
|
2860
|
+
the **I** of SHIQ: the in-house tableau refuses it before it builds a
|
|
2861
|
+
branch (:func:`_reject_inverse_role_inclusions`), and the one other
|
|
2862
|
+
caller, the external route, reads this view only for the simple-role
|
|
2863
|
+
check, which asks whether a role reaches a NAMED composite role. An
|
|
2864
|
+
inverse-role node was never one, so leaving it out changes no verdict.
|
|
2865
|
+
"""
|
|
2866
|
+
plain_inclusions = [
|
|
2867
|
+
(sub_role, super_role)
|
|
2868
|
+
for sub_role, super_role in tbox.role_inclusions
|
|
2869
|
+
if isinstance(sub_role, str) and isinstance(super_role, str)]
|
|
2870
|
+
return cls(plain_inclusions, tbox.transitive_roles,
|
|
2871
|
+
disjoint_pairs=tbox.disjoint_role_pairs,
|
|
2872
|
+
asymmetric=tbox.asymmetric_roles,
|
|
2873
|
+
irreflexive=tbox.irreflexive_roles,
|
|
2874
|
+
reflexive=tbox.reflexive_roles,
|
|
2875
|
+
chain_super_roles=[super_role for _, super_role in tbox.role_chains])
|
|
2876
|
+
|
|
2877
|
+
def ancestors(self, role: str) -> FrozenSet[str]:
|
|
2878
|
+
"""Every role ``role`` entails via ⊑* (``role`` itself included).
|
|
2879
|
+
|
|
2880
|
+
A role that never occurs in any RBox axiom falls back to the reflexive
|
|
2881
|
+
singleton ``{role}`` — so a plain ALC ``TBox`` with an empty RBox degrades
|
|
2882
|
+
every lookup here to exactly the pre-RBox exact-match behaviour.
|
|
2883
|
+
"""
|
|
2884
|
+
return self._ancestors.get(role, frozenset((role,)))
|
|
2885
|
+
|
|
2886
|
+
|
|
2887
|
+
_EMPTY_RBOX = _RBox([], set())
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
def _edge_closure(branch: _Branch,
|
|
2891
|
+
rbox: _RBox) -> Dict[Tuple[_Node, _Node], Set[str]]:
|
|
2892
|
+
"""``(source, destination) -> every role the branch's edges between them
|
|
2893
|
+
entail via ⊑*`` — the one index the three edge-pattern clash conditions read.
|
|
2894
|
+
|
|
2895
|
+
Built once per :func:`_clash` call rather than per condition, and only when
|
|
2896
|
+
``rbox.has_edge_constraints``. On a SIMPLE role box this closure is exactly
|
|
2897
|
+
the entailed relation (see the module docstring's completeness argument),
|
|
2898
|
+
which is what makes the three conditions exact and not merely sound.
|
|
2899
|
+
"""
|
|
2900
|
+
closure: Dict[Tuple[_Node, _Node], Set[str]] = {}
|
|
2901
|
+
for (source, role, destination) in branch.edges:
|
|
2902
|
+
closure.setdefault((source, destination), set()).update(rbox.ancestors(role))
|
|
2903
|
+
return closure
|
|
2904
|
+
|
|
2905
|
+
|
|
2906
|
+
def _edge_pattern_clash(branch: _Branch, rbox: _RBox) -> bool:
|
|
2907
|
+
"""True iff the branch's edges violate an irreflexivity, asymmetry or role
|
|
2908
|
+
disjointness declaration (see "The rest of the OWL 2 role box" in the module
|
|
2909
|
+
docstring for the derivation of all three, and for why they are strictly
|
|
2910
|
+
SUBTRACTIVE and so change nothing else about the algorithm).
|
|
2911
|
+
"""
|
|
2912
|
+
closure = _edge_closure(branch, rbox)
|
|
2913
|
+
for (source, destination), roles in closure.items():
|
|
2914
|
+
if source == destination and rbox.irreflexive & roles:
|
|
2915
|
+
return True # IrreflexiveObjectProperty
|
|
2916
|
+
if rbox.asymmetric & roles: # AsymmetricObjectProperty
|
|
2917
|
+
converse = closure.get((destination, source))
|
|
2918
|
+
if converse is not None and rbox.asymmetric & roles & converse:
|
|
2919
|
+
return True # (x == y included: entailed
|
|
2920
|
+
# irreflexivity)
|
|
2921
|
+
for left, right in rbox.disjoint_pairs: # DisjointObjectProperties
|
|
2922
|
+
if left in roles and right in roles:
|
|
2923
|
+
return True
|
|
2924
|
+
return False
|
|
2925
|
+
|
|
2926
|
+
|
|
2927
|
+
def _has_entailed_edge(branch: _Branch, rbox: _RBox, source: _Node, role,
|
|
2928
|
+
destination: _Node) -> bool:
|
|
2929
|
+
"""True iff the branch has an edge from ``source`` to ``destination`` whose
|
|
2930
|
+
OWN role entails ``role`` via ⊑* — i.e. iff the branch already says
|
|
2931
|
+
``role(source, destination)``.
|
|
2932
|
+
|
|
2933
|
+
Hierarchy-aware for the reason the ∀-rule's rule H is (see the module
|
|
2934
|
+
docstring): an ``r``-edge with ``r ⊑* role`` IS a ``role``-edge in every
|
|
2935
|
+
model, so it VIOLATES the negative role assertion ``¬role(source,
|
|
2936
|
+
destination)`` — the one reader of this function.
|
|
2937
|
+
"""
|
|
2938
|
+
ancestors_of = rbox.ancestors
|
|
2939
|
+
return any(s == source and d == destination and role in ancestors_of(r)
|
|
2940
|
+
for (s, r, d) in branch.edges)
|
|
2941
|
+
|
|
2942
|
+
|
|
2943
|
+
def _clash(branch: _Branch, rbox: _RBox) -> bool:
|
|
2944
|
+
"""True iff some individual's label contains ⊥, a complementary atomic pair, an
|
|
2945
|
+
individual asserted distinct from ITSELF, or (for **Q**) a ``≤n r.C`` together
|
|
2946
|
+
with ``n+1`` PAIRWISE-DISTINCT ``r``-neighbours all in ``C`` (see "Qualified
|
|
2947
|
+
number restrictions" in the module docstring) — or the branch's EDGES violate
|
|
2948
|
+
an irreflexivity/asymmetry/role-disjointness declaration (see "The rest of the
|
|
2949
|
+
OWL 2 role box") or a negative role assertion (see "Negative role
|
|
2950
|
+
assertions").
|
|
2951
|
+
"""
|
|
2952
|
+
if branch.self_distinct:
|
|
2953
|
+
# ``a ≠ a`` has no model, whatever else the branch says — and unlike every
|
|
2954
|
+
# other clash condition here it needs no label or neighbour to look at.
|
|
2955
|
+
return True
|
|
2956
|
+
if rbox.has_edge_constraints and _edge_pattern_clash(branch, rbox):
|
|
2957
|
+
return True
|
|
2958
|
+
for (source, role, destination) in branch.negative_edges:
|
|
2959
|
+
# NegativeObjectPropertyAssertion(role a b) says <a, b> ∉ role^I, so an
|
|
2960
|
+
# entailed edge between the two is a direct contradiction.
|
|
2961
|
+
if _has_entailed_edge(branch, rbox, source, role, destination):
|
|
2962
|
+
return True
|
|
2963
|
+
for node, concepts in branch.label.items():
|
|
2964
|
+
if Bottom() in concepts:
|
|
2965
|
+
return True
|
|
2966
|
+
for c in concepts:
|
|
2967
|
+
if isinstance(c, Not) and isinstance(c.concept, Atomic) and c.concept in concepts:
|
|
2968
|
+
return True
|
|
2969
|
+
if isinstance(c, AtMost):
|
|
2970
|
+
witnesses = _concept_neighbours(branch, rbox, node, c.role, c.concept)
|
|
2971
|
+
if _has_pairwise_distinct_subset(branch, witnesses, c.n + 1):
|
|
2972
|
+
return True
|
|
2973
|
+
return False
|
|
2974
|
+
|
|
2975
|
+
|
|
2976
|
+
def _saturate(branch: _Branch, rbox: _RBox) -> bool:
|
|
2977
|
+
"""Apply the deterministic ⊓ and ∀ rules once over the branch; return True if changed.
|
|
2978
|
+
|
|
2979
|
+
The ∀-rule implements both RBox extensions in place (see "Role hierarchies and
|
|
2980
|
+
transitive roles (RBox)" in the module docstring): rule H generalises the
|
|
2981
|
+
edge/restriction match from ``role == c.role`` to ``c.role ∈ rbox.ancestors(role)``
|
|
2982
|
+
(``role ⊑* c.role``), and rule S (the "∀+"-rule) additionally re-adds the whole
|
|
2983
|
+
restriction ``ForAll(R, c.concept)`` for every transitive role ``R`` reachable
|
|
2984
|
+
from ``role`` and itself reaching ``c.role`` in the role hierarchy, so the
|
|
2985
|
+
restriction keeps firing along further ``R``-chains out of the successor.
|
|
2986
|
+
"""
|
|
2987
|
+
changed = False
|
|
2988
|
+
for node in list(branch.label):
|
|
2989
|
+
for c in list(branch.label[node]):
|
|
2990
|
+
if isinstance(c, And):
|
|
2991
|
+
for part in (c.left, c.right):
|
|
2992
|
+
if part not in branch.label[node]:
|
|
2993
|
+
branch.label[node].add(part)
|
|
2994
|
+
changed = True
|
|
2995
|
+
elif isinstance(c, ForAll):
|
|
2996
|
+
for (s, role, d) in branch.edges:
|
|
2997
|
+
if s != node:
|
|
2998
|
+
continue
|
|
2999
|
+
role_ancestors = rbox.ancestors(role)
|
|
3000
|
+
if c.role not in role_ancestors:
|
|
3001
|
+
continue # rule H: role ⊑* c.role fails
|
|
3002
|
+
if c.concept not in branch.label[d]:
|
|
3003
|
+
branch.label[d].add(c.concept)
|
|
3004
|
+
changed = True
|
|
3005
|
+
for trans_role in rbox.transitive_in_order: # rule S: ∀+-rule
|
|
3006
|
+
if trans_role not in role_ancestors:
|
|
3007
|
+
continue # role ⊑* trans_role fails
|
|
3008
|
+
if c.role not in rbox.ancestors(trans_role):
|
|
3009
|
+
continue # trans_role ⊑* c.role fails
|
|
3010
|
+
propagated = ForAll(trans_role, c.concept)
|
|
3011
|
+
if propagated not in branch.label[d]:
|
|
3012
|
+
branch.label[d].add(propagated)
|
|
3013
|
+
changed = True
|
|
3014
|
+
return changed
|
|
3015
|
+
|
|
3016
|
+
|
|
3017
|
+
def _contradicted(label: "_OrderedSet", c: Concept) -> bool:
|
|
3018
|
+
"""True iff ``c`` cannot hold at a node carrying ``label``: it is ⊥, or its
|
|
3019
|
+
complement is in the label (``A`` against ``¬A``, either way round)."""
|
|
3020
|
+
if isinstance(c, Bottom):
|
|
3021
|
+
return True
|
|
3022
|
+
if isinstance(c, Atomic):
|
|
3023
|
+
return Not(c) in label
|
|
3024
|
+
if isinstance(c, Not):
|
|
3025
|
+
return c.concept in label
|
|
3026
|
+
return False
|
|
3027
|
+
|
|
3028
|
+
|
|
3029
|
+
def _is_forced(label: "_OrderedSet", disjunction: Concept) -> bool:
|
|
3030
|
+
"""True iff at most ONE alternative of ``disjunction`` can still hold at a node
|
|
3031
|
+
carrying ``label``. The alternatives are the leaves of the (nested) ``⊔``;
|
|
3032
|
+
one is out when :func:`_contradicted`."""
|
|
3033
|
+
alive = 0
|
|
3034
|
+
stack = [disjunction]
|
|
3035
|
+
while stack:
|
|
3036
|
+
alternative = stack.pop()
|
|
3037
|
+
if isinstance(alternative, Or):
|
|
3038
|
+
stack.append(alternative.right)
|
|
3039
|
+
stack.append(alternative.left)
|
|
3040
|
+
elif not _contradicted(label, alternative):
|
|
3041
|
+
alive += 1
|
|
3042
|
+
if alive > 1:
|
|
3043
|
+
return False
|
|
3044
|
+
return True
|
|
3045
|
+
|
|
3046
|
+
|
|
3047
|
+
def _find_disjunction(branch: _Branch):
|
|
3048
|
+
"""An unresolved ``x : C ⊔ D`` (neither disjunct present yet), or None.
|
|
3049
|
+
|
|
3050
|
+
Which one is a fixed rule, because the ⊔-rule may be applied to any of them and
|
|
3051
|
+
the verdict never depends on the choice, while the size of the search does
|
|
3052
|
+
("Search order" in the module docstring): the first unresolved disjunction
|
|
3053
|
+
that is FORCED comes first (:func:`_is_forced`: at most one alternative is not
|
|
3054
|
+
already contradicted by its node's label, so one branch of the split dies at
|
|
3055
|
+
once and the other is the only way on), and if there is none, the first
|
|
3056
|
+
unresolved disjunction. "First" is in node creation order, then in the order
|
|
3057
|
+
a label received its concepts: a function of the insertion order alone.
|
|
3058
|
+
"""
|
|
3059
|
+
first: Optional[Tuple[_Node, Concept]] = None
|
|
3060
|
+
for node in branch.order:
|
|
3061
|
+
label = branch.label[node]
|
|
3062
|
+
for c in label:
|
|
3063
|
+
if not isinstance(c, Or) or c.left in label or c.right in label:
|
|
3064
|
+
continue
|
|
3065
|
+
if first is None:
|
|
3066
|
+
first = (node, c)
|
|
3067
|
+
if _is_forced(label, c):
|
|
3068
|
+
return node, c
|
|
3069
|
+
return first
|
|
3070
|
+
|
|
3071
|
+
|
|
3072
|
+
def _blocked(branch: _Branch, node: _Node) -> bool:
|
|
3073
|
+
"""Subset blocking: a GENERATED node subsumed by an earlier GENERATED node
|
|
3074
|
+
is not expanded.
|
|
3075
|
+
|
|
3076
|
+
Both occurrences of "generated" matter, and the second one is a 0.30.0
|
|
3077
|
+
restriction. Until then any earlier node could block — including a NAMED
|
|
3078
|
+
ABox individual, which ``abox_consistent`` adds first, so a generated node
|
|
3079
|
+
with a small label was routinely blocked by a named one. That is
|
|
3080
|
+
legitimate only as long as no formula can distinguish two elements that
|
|
3081
|
+
satisfy the same concepts: subset blocking's soundness argument (Horrocks
|
|
3082
|
+
& Sattler 1999) is a statement about the UNRAVELLING, in which a blocked
|
|
3083
|
+
node is interpreted by its blocker. A negative role assertion ``¬r(a, b)``
|
|
3084
|
+
is such a formula — it refers to elements BY NAME. Collapsing a generated
|
|
3085
|
+
``r``-successor of ``a`` onto the NAMED node ``b`` would give ``a`` an
|
|
3086
|
+
``r``-edge to ``b`` in the model that the assertion forbids, and no clash
|
|
3087
|
+
would fire, because no such edge was ever added to the graph. So a named
|
|
3088
|
+
node may not block. (The value restriction ``∃r.{a}``, which names an
|
|
3089
|
+
element too, is what exposed this; it is refused now — see "Value
|
|
3090
|
+
restrictions (ObjectHasValue)" in the module docstring — but a negative
|
|
3091
|
+
role assertion still needs the restriction.)
|
|
3092
|
+
|
|
3093
|
+
Collapsing a generated node onto an earlier GENERATED node is the standard
|
|
3094
|
+
case the module docstring's termination argument covers: the generated
|
|
3095
|
+
part of a branch is a tree whose labels are pushed forward from the
|
|
3096
|
+
ancestors, so a blocker whose label is a superset of the blocked node's has
|
|
3097
|
+
every obligation the blocked node has.
|
|
3098
|
+
|
|
3099
|
+
Unconditional, not gated on "does this knowledge base contain a negative
|
|
3100
|
+
role assertion": a flag that changes the blocking condition is right in the
|
|
3101
|
+
commit that adds it and wrong three releases later. The restriction only
|
|
3102
|
+
ever makes the search space LARGER, never a verdict different, and
|
|
3103
|
+
termination is untouched, because the finite subconcept/RBox-role label
|
|
3104
|
+
closure still forces a repeat among generated nodes within a bounded
|
|
3105
|
+
number of generations, which is the whole content of the existing
|
|
3106
|
+
argument; only the set of eligible blockers shrinks.
|
|
3107
|
+
"""
|
|
3108
|
+
if not isinstance(node, _Generated):
|
|
3109
|
+
return False # named / root individuals are never blocked
|
|
3110
|
+
idx = branch.order.index(node)
|
|
3111
|
+
lbl = branch.label[node]
|
|
3112
|
+
return any(branch.label[other] >= lbl
|
|
3113
|
+
for other in branch.order[:idx] if isinstance(other, _Generated))
|
|
3114
|
+
|
|
3115
|
+
|
|
3116
|
+
def _find_exists(branch: _Branch):
|
|
3117
|
+
"""An unsatisfied, unblocked ``x : ∃r.C`` to generate a witness for, or None."""
|
|
3118
|
+
for node in branch.order:
|
|
3119
|
+
if _blocked(branch, node):
|
|
3120
|
+
continue
|
|
3121
|
+
for c in branch.label[node]:
|
|
3122
|
+
if isinstance(c, Exists):
|
|
3123
|
+
if not any(s == node and role == c.role and c.concept in branch.label[d]
|
|
3124
|
+
for (s, role, d) in branch.edges):
|
|
3125
|
+
return node, c
|
|
3126
|
+
return None
|
|
3127
|
+
|
|
3128
|
+
|
|
3129
|
+
# --------------------------------------------------------------------------- #
|
|
3130
|
+
# Qualified number restrictions (ALCQ) — see "Qualified number restrictions" in
|
|
3131
|
+
# the module docstring for the algorithm these implement.
|
|
3132
|
+
# --------------------------------------------------------------------------- #
|
|
3133
|
+
|
|
3134
|
+
def _role_neighbours(branch: _Branch, rbox: _RBox, x: _Node, role: str) -> List[_Node]:
|
|
3135
|
+
"""Every DISTINCT ``y`` reached from ``x`` by an edge whose OWN role entails
|
|
3136
|
+
``role`` via the role hierarchy (``r' ⊑* role`` — RBox rule H, generalised from
|
|
3137
|
+
the ∀-rule to number restrictions; see the module docstring).
|
|
3138
|
+
|
|
3139
|
+
A number restriction counts NEIGHBOURS, not edges: ``x`` can be linked to the
|
|
3140
|
+
same ``y`` by more than one entailing edge (e.g. a direct ``hasChild``-edge and
|
|
3141
|
+
an ``hasSon``-edge with ``hasSon ⊑ hasChild``, or simply two role names ``r``,
|
|
3142
|
+
``s`` both declared ``⊑ role``), and ``y`` must still be counted ONCE. Without
|
|
3143
|
+
this dedup, ``y`` would appear twice in the returned list; downstream, that
|
|
3144
|
+
degenerate self-pair ``(y, y)`` looks to :func:`_find_mergeable` like two
|
|
3145
|
+
distinct witnesses eligible to be merged into each other, and :func:`_merge`
|
|
3146
|
+
would then delete ``y`` from the branch while edges/labels/distinctness still
|
|
3147
|
+
reference it as the survivor too — corrupting the branch (a dangling label
|
|
3148
|
+
lookup crashes the very next step). ``dict.fromkeys`` dedups while preserving
|
|
3149
|
+
the (branch-``edges``-set-determined) enumeration order.
|
|
3150
|
+
"""
|
|
3151
|
+
return list(dict.fromkeys(d for (s, r, d) in branch.edges if s == x and role in rbox.ancestors(r)))
|
|
3152
|
+
|
|
3153
|
+
|
|
3154
|
+
def _in_concept(branch: _Branch, node: _Node, concept: Concept) -> bool:
|
|
3155
|
+
"""True iff ``node`` is (syntactically) known to be in ``concept`` — ``concept``
|
|
3156
|
+
is ⊤ (trivially true for everyone, whether or not ``Top()`` was ever literally
|
|
3157
|
+
added to a label) or ``concept`` is literally in ``node``'s label.
|
|
3158
|
+
"""
|
|
3159
|
+
return isinstance(concept, Top) or concept in branch.label[node]
|
|
3160
|
+
|
|
3161
|
+
|
|
3162
|
+
def _concept_neighbours(branch: _Branch, rbox: _RBox, x: _Node, role: str,
|
|
3163
|
+
concept: Concept) -> List[_Node]:
|
|
3164
|
+
"""Every ``role``-neighbour of ``x`` (role-hierarchy-aware) that is in ``concept``."""
|
|
3165
|
+
return [y for y in _role_neighbours(branch, rbox, x, role) if _in_concept(branch, y, concept)]
|
|
3166
|
+
|
|
3167
|
+
|
|
3168
|
+
def _marked_distinct(branch: _Branch, a: _Node, b: _Node) -> bool:
|
|
3169
|
+
"""True iff ``a`` and ``b`` are FORCED distinct on this branch."""
|
|
3170
|
+
return a != b and frozenset((a, b)) in branch.distinct
|
|
3171
|
+
|
|
3172
|
+
|
|
3173
|
+
def _has_pairwise_distinct_subset(branch: _Branch, nodes: List[_Node], k: int) -> bool:
|
|
3174
|
+
"""True iff some size-``k`` subset of ``nodes`` is pairwise FORCED-distinct.
|
|
3175
|
+
|
|
3176
|
+
Brute-force over ``C(len(nodes), k)`` combinations — deciding "is there a
|
|
3177
|
+
pairwise-distinct k-subset" is a clique-existence question in general, but the
|
|
3178
|
+
node sets this reasoner ever builds stay small at the scale it targets (see the
|
|
3179
|
+
module docstring's "Qualified number restrictions" section), so this is fine in
|
|
3180
|
+
practice; it is not meant for adversarially large ``n``.
|
|
3181
|
+
"""
|
|
3182
|
+
if k <= 0:
|
|
3183
|
+
return True
|
|
3184
|
+
if len(nodes) < k:
|
|
3185
|
+
return False
|
|
3186
|
+
for combo in combinations(nodes, k):
|
|
3187
|
+
if all(_marked_distinct(branch, a, b) for a, b in combinations(combo, 2)):
|
|
3188
|
+
return True
|
|
3189
|
+
return False
|
|
3190
|
+
|
|
3191
|
+
|
|
3192
|
+
def _find_atleast(branch: _Branch, rbox: _RBox):
|
|
3193
|
+
"""An unsatisfied, unblocked ``x : ≥n r.C`` needing fresh witnesses, or None
|
|
3194
|
+
(mirrors :func:`_find_exists`; the ≥-rule itself is applied in :func:`_solve`).
|
|
3195
|
+
"""
|
|
3196
|
+
for node in branch.order:
|
|
3197
|
+
if _blocked(branch, node):
|
|
3198
|
+
continue
|
|
3199
|
+
for c in branch.label[node]:
|
|
3200
|
+
if isinstance(c, AtLeast) and c.n > 0:
|
|
3201
|
+
witnesses = _concept_neighbours(branch, rbox, node, c.role, c.concept)
|
|
3202
|
+
if not _has_pairwise_distinct_subset(branch, witnesses, c.n):
|
|
3203
|
+
return node, c
|
|
3204
|
+
return None
|
|
3205
|
+
|
|
3206
|
+
|
|
3207
|
+
def _find_choose(branch: _Branch, rbox: _RBox):
|
|
3208
|
+
"""An ``r``-neighbour of a number-restricted node with neither ``C`` nor ``¬C``
|
|
3209
|
+
decided, or None — the choose-rule's trigger (see the module docstring; needed
|
|
3210
|
+
for the ≤-rule's completeness). Returns ``(neighbour, C, ¬C)``.
|
|
3211
|
+
"""
|
|
3212
|
+
for node in branch.order:
|
|
3213
|
+
if _blocked(branch, node):
|
|
3214
|
+
continue
|
|
3215
|
+
for c in branch.label[node]:
|
|
3216
|
+
if isinstance(c, (AtLeast, AtMost)) and not isinstance(c.concept, Top):
|
|
3217
|
+
neg_concept = nnf(Not(c.concept))
|
|
3218
|
+
for neighbour in _role_neighbours(branch, rbox, node, c.role):
|
|
3219
|
+
lbl = branch.label[neighbour]
|
|
3220
|
+
if c.concept not in lbl and neg_concept not in lbl:
|
|
3221
|
+
return neighbour, c.concept, neg_concept
|
|
3222
|
+
return None
|
|
3223
|
+
|
|
3224
|
+
|
|
3225
|
+
def _find_mergeable(branch: _Branch, rbox: _RBox):
|
|
3226
|
+
"""An ``x : ≤n r.C`` with more than ``n`` ``r``-neighbours in ``C``, together
|
|
3227
|
+
with every candidate NON-distinct pair among them, or None. By this point
|
|
3228
|
+
:func:`_clash` has already ruled out ``n+1`` of them being pairwise distinct, so
|
|
3229
|
+
(pigeonhole) at least one non-distinct pair is guaranteed to exist whenever this
|
|
3230
|
+
returns non-None — see the module docstring for why WHICH pair to merge must be
|
|
3231
|
+
tried as separate branches rather than picked once.
|
|
3232
|
+
"""
|
|
3233
|
+
for node in branch.order:
|
|
3234
|
+
if _blocked(branch, node):
|
|
3235
|
+
continue
|
|
3236
|
+
for c in branch.label[node]:
|
|
3237
|
+
if isinstance(c, AtMost):
|
|
3238
|
+
witnesses = _concept_neighbours(branch, rbox, node, c.role, c.concept)
|
|
3239
|
+
if len(witnesses) <= c.n:
|
|
3240
|
+
continue
|
|
3241
|
+
# ``a != b`` is defense in depth: ``_role_neighbours`` already
|
|
3242
|
+
# dedups by destination, so ``witnesses`` should never contain the
|
|
3243
|
+
# same node twice, but a future duplicate-producing path (here or
|
|
3244
|
+
# in a caller) must fail safe by skipping the degenerate self-pair
|
|
3245
|
+
# rather than handing it to ``_merge`` (see ``_role_neighbours``'s
|
|
3246
|
+
# docstring for what a self-merge does to the branch).
|
|
3247
|
+
pairs = [(a, b) for i, a in enumerate(witnesses)
|
|
3248
|
+
for b in witnesses[i + 1:]
|
|
3249
|
+
if a != b and not _marked_distinct(branch, a, b)]
|
|
3250
|
+
if pairs:
|
|
3251
|
+
return node, c, pairs
|
|
3252
|
+
return None
|
|
3253
|
+
|
|
3254
|
+
|
|
3255
|
+
def _merge_order(branch: _Branch, a: _Node, b: _Node) -> Tuple[_Node, _Node]:
|
|
3256
|
+
"""Return ``(keep, drop)`` for merging ``a``, ``b``: keep the NON-blockable
|
|
3257
|
+
(named/root) node when exactly one of the two is blockable, else keep whichever
|
|
3258
|
+
was added to the branch first (see the module docstring's ≤-rule description).
|
|
3259
|
+
"""
|
|
3260
|
+
a_blockable, b_blockable = isinstance(a, _Generated), isinstance(b, _Generated)
|
|
3261
|
+
if a_blockable != b_blockable:
|
|
3262
|
+
return (b, a) if a_blockable else (a, b)
|
|
3263
|
+
return (a, b) if branch.order.index(a) < branch.order.index(b) else (b, a)
|
|
3264
|
+
|
|
3265
|
+
|
|
3266
|
+
def _merge(branch: _Branch, keep: _Node, drop: _Node) -> None:
|
|
3267
|
+
"""Merge ``drop`` into ``keep`` in place: redirect every edge (positive and
|
|
3268
|
+
negative), union the labels, re-parent every distinctness pair, and remove
|
|
3269
|
+
``drop``.
|
|
3270
|
+
|
|
3271
|
+
A no-op when ``keep == drop`` — defense in depth against a degenerate
|
|
3272
|
+
self-pair reaching this far (see ``_role_neighbours``'s docstring): merging a
|
|
3273
|
+
node into itself has nothing to redirect and must NOT fall through to
|
|
3274
|
+
``del branch.label[drop]``, which would delete a node that ``keep`` (the same
|
|
3275
|
+
node) still needs.
|
|
3276
|
+
|
|
3277
|
+
A distinctness pair that COLLAPSES onto one node (both endpoints become
|
|
3278
|
+
``keep``) sets ``branch.self_distinct``, which :func:`_clash` already reads:
|
|
3279
|
+
the branch now says one element is distinct from itself, and ``a ≠ a`` has
|
|
3280
|
+
no model. Until 0.30.0 the pair was DROPPED instead. That was unreachable
|
|
3281
|
+
from the ≤-rule — which only ever merges a pair it has already checked is
|
|
3282
|
+
NOT marked distinct, and ``a2 == b2`` can then only happen for the merged
|
|
3283
|
+
pair itself — but :func:`abox_consistent`'s same-individual merging is a
|
|
3284
|
+
second caller that does not check, so ``assert_same(a, b)`` together with
|
|
3285
|
+
``assert_distinct(a, b)`` would otherwise be reported CONSISTENT while its
|
|
3286
|
+
own FOL image ``a = b ∧ a ≠ b`` is refutable. (Exactly the asymmetry
|
|
3287
|
+
:meth:`_Branch.mark_distinct` was given in 0.30.0, for the same reason.)
|
|
3288
|
+
``tests/test_dl_abox_identity.py`` has both halves: the new verdict, and a
|
|
3289
|
+
≤-rule control proving this branch is still not reachable from there.
|
|
3290
|
+
"""
|
|
3291
|
+
if keep == drop:
|
|
3292
|
+
return
|
|
3293
|
+
branch.label[keep] |= branch.label[drop]
|
|
3294
|
+
branch.edges = _OrderedSet((keep if s == drop else s, r, keep if d == drop else d)
|
|
3295
|
+
for (s, r, d) in branch.edges)
|
|
3296
|
+
# The FORBIDDEN edges travel with the node exactly as the real ones do, so
|
|
3297
|
+
# `assert_same(b, c)` + `r(a, c)` + `¬r(a, b)` clashes after the merge.
|
|
3298
|
+
branch.negative_edges = {
|
|
3299
|
+
(keep if s == drop else s, r, keep if d == drop else d)
|
|
3300
|
+
for (s, r, d) in branch.negative_edges}
|
|
3301
|
+
new_distinct = set()
|
|
3302
|
+
for pair in branch.distinct:
|
|
3303
|
+
a, b = tuple(pair)
|
|
3304
|
+
a2, b2 = (keep if a == drop else a), (keep if b == drop else b)
|
|
3305
|
+
if a2 != b2:
|
|
3306
|
+
new_distinct.add(frozenset((a2, b2)))
|
|
3307
|
+
else:
|
|
3308
|
+
branch.self_distinct = True
|
|
3309
|
+
branch.distinct = new_distinct
|
|
3310
|
+
del branch.label[drop]
|
|
3311
|
+
branch.order.remove(drop)
|
|
3312
|
+
|
|
3313
|
+
|
|
3314
|
+
def _solve(branch: _Branch, tbox_concepts: List[Concept], rbox: _RBox, ctx: _Ctx) -> bool:
|
|
3315
|
+
"""Return True iff the branch can be completed without a clash (i.e. is consistent)."""
|
|
3316
|
+
while True:
|
|
3317
|
+
ctx.tick()
|
|
3318
|
+
if _clash(branch, rbox):
|
|
3319
|
+
return False
|
|
3320
|
+
if _saturate(branch, rbox):
|
|
3321
|
+
continue
|
|
3322
|
+
if _clash(branch, rbox):
|
|
3323
|
+
return False
|
|
3324
|
+
|
|
3325
|
+
disjunction = _find_disjunction(branch)
|
|
3326
|
+
if disjunction is not None:
|
|
3327
|
+
node, c = disjunction
|
|
3328
|
+
for option in (c.left, c.right):
|
|
3329
|
+
child = branch.copy()
|
|
3330
|
+
child.label[node].add(option)
|
|
3331
|
+
if _solve(child, tbox_concepts, rbox, ctx):
|
|
3332
|
+
return True
|
|
3333
|
+
return False
|
|
3334
|
+
|
|
3335
|
+
choose = _find_choose(branch, rbox)
|
|
3336
|
+
if choose is not None:
|
|
3337
|
+
neighbour, concept, neg_concept = choose
|
|
3338
|
+
for option in (concept, neg_concept):
|
|
3339
|
+
child = branch.copy()
|
|
3340
|
+
child.label[neighbour].add(option)
|
|
3341
|
+
if _solve(child, tbox_concepts, rbox, ctx):
|
|
3342
|
+
return True
|
|
3343
|
+
return False
|
|
3344
|
+
|
|
3345
|
+
mergeable = _find_mergeable(branch, rbox)
|
|
3346
|
+
if mergeable is not None:
|
|
3347
|
+
_node, _c, pairs = mergeable
|
|
3348
|
+
for (a, b) in pairs:
|
|
3349
|
+
keep, drop = _merge_order(branch, a, b)
|
|
3350
|
+
child = branch.copy()
|
|
3351
|
+
_merge(child, keep, drop)
|
|
3352
|
+
if _solve(child, tbox_concepts, rbox, ctx):
|
|
3353
|
+
return True
|
|
3354
|
+
return False
|
|
3355
|
+
|
|
3356
|
+
existential = _find_exists(branch)
|
|
3357
|
+
if existential is not None:
|
|
3358
|
+
node, c = existential
|
|
3359
|
+
witness = branch.fresh()
|
|
3360
|
+
branch.edges.add((node, c.role, witness))
|
|
3361
|
+
branch.label[witness].update(tbox_concepts)
|
|
3362
|
+
branch.label[witness].add(c.concept)
|
|
3363
|
+
continue
|
|
3364
|
+
|
|
3365
|
+
atleast = _find_atleast(branch, rbox)
|
|
3366
|
+
if atleast is not None:
|
|
3367
|
+
node, c = atleast
|
|
3368
|
+
witnesses = [branch.fresh() for _ in range(c.n)]
|
|
3369
|
+
for w in witnesses:
|
|
3370
|
+
branch.edges.add((node, c.role, w))
|
|
3371
|
+
branch.label[w].update(tbox_concepts)
|
|
3372
|
+
branch.label[w].add(c.concept)
|
|
3373
|
+
for i in range(len(witnesses)):
|
|
3374
|
+
for j in range(i + 1, len(witnesses)):
|
|
3375
|
+
branch.mark_distinct(witnesses[i], witnesses[j])
|
|
3376
|
+
continue
|
|
3377
|
+
|
|
3378
|
+
return True # saturated and clash-free → consistent
|
|
3379
|
+
|
|
3380
|
+
|
|
3381
|
+
def _collect_number_restriction_roles(concept: Concept, roles: Set[str]) -> None:
|
|
3382
|
+
"""Recursively gather every role name used in an ``AtLeast``/``AtMost`` anywhere
|
|
3383
|
+
inside ``concept`` into ``roles`` (see :func:`_check_simple_roles`).
|
|
3384
|
+
"""
|
|
3385
|
+
if isinstance(concept, (Top, Bottom, Atomic)):
|
|
3386
|
+
return
|
|
3387
|
+
if isinstance(concept, Not):
|
|
3388
|
+
_collect_number_restriction_roles(concept.concept, roles)
|
|
3389
|
+
elif isinstance(concept, (And, Or)):
|
|
3390
|
+
_collect_number_restriction_roles(concept.left, roles)
|
|
3391
|
+
_collect_number_restriction_roles(concept.right, roles)
|
|
3392
|
+
elif isinstance(concept, (Exists, ForAll)):
|
|
3393
|
+
_collect_number_restriction_roles(concept.concept, roles)
|
|
3394
|
+
elif isinstance(concept, (AtLeast, AtMost)):
|
|
3395
|
+
roles.add(concept.role)
|
|
3396
|
+
_collect_number_restriction_roles(concept.concept, roles)
|
|
3397
|
+
else:
|
|
3398
|
+
raise TypeError(
|
|
3399
|
+
f"_collect_number_restriction_roles: unsupported concept {type(concept).__name__}")
|
|
3400
|
+
|
|
3401
|
+
|
|
3402
|
+
def _is_simple_role(role: str, rbox: _RBox) -> bool:
|
|
3403
|
+
"""A role is SIMPLE iff no COMPOSITE role (itself included) entails it via ⊑*.
|
|
3404
|
+
|
|
3405
|
+
COMPOSITE is OWL 2's own notion (Structural Specification §11): a role
|
|
3406
|
+
declared transitive, or the super-role of a property chain. The ⊑*
|
|
3407
|
+
direction is the one to get right — a composite SUB-role is what makes the
|
|
3408
|
+
super-role non-simple (``r' ⊑* role`` for composite ``r'``), and reading it
|
|
3409
|
+
the other way round silently readmits a combination that is undecidable
|
|
3410
|
+
(Horrocks, Sattler & Tobies 1999). See the module docstring.
|
|
3411
|
+
"""
|
|
3412
|
+
return not any(role in rbox.ancestors(composite) for composite in rbox.composite)
|
|
3413
|
+
|
|
3414
|
+
|
|
3415
|
+
def _check_simple_roles(concepts: List[Concept], rbox: _RBox) -> None:
|
|
3416
|
+
"""Raise :class:`NonSimpleRoleError` if any ``AtLeast``/``AtMost`` anywhere in
|
|
3417
|
+
``concepts`` restricts a NON-SIMPLE role (see "Qualified number restrictions" in
|
|
3418
|
+
the module docstring for why this is refused rather than silently accepted).
|
|
3419
|
+
"""
|
|
3420
|
+
roles: Set[str] = set()
|
|
3421
|
+
for concept in concepts:
|
|
3422
|
+
_collect_number_restriction_roles(concept, roles)
|
|
3423
|
+
for role in sorted(roles):
|
|
3424
|
+
if not _is_simple_role(role, rbox):
|
|
3425
|
+
raise NonSimpleRoleError(
|
|
3426
|
+
f"qualified number restriction on role {role!r} is not allowed: "
|
|
3427
|
+
f"{role!r} is NON-SIMPLE — it is transitive, or has a transitive "
|
|
3428
|
+
"sub-role via the RBox (a role inclusion into it from a declared-"
|
|
3429
|
+
"transitive role). Number restrictions on non-simple roles make the "
|
|
3430
|
+
"logic undecidable (Horrocks, Sattler & Tobies 1999/2000: SHQ/SHIQ "
|
|
3431
|
+
"restrict AtLeast/AtMost to SIMPLE roles for exactly this reason), so "
|
|
3432
|
+
"this kit refuses the combination outright rather than risk an "
|
|
3433
|
+
"unsound or non-terminating result.")
|
|
3434
|
+
|
|
3435
|
+
|
|
3436
|
+
def _check_simple_role_box(tbox: TBox, rbox: _RBox) -> None:
|
|
3437
|
+
"""Raise :class:`NonSimpleRoleError` if a role-box axiom OWL 2 restricts to
|
|
3438
|
+
SIMPLE roles targets a NON-SIMPLE one (see :data:`_SIMPLE_ROLE_AXIOMS` for
|
|
3439
|
+
the kinds and the module docstring for why simplicity is what makes the
|
|
3440
|
+
three edge-pattern clash conditions EXACT).
|
|
3441
|
+
|
|
3442
|
+
Runs BEFORE :func:`_check_simple_roles` so that, for instance,
|
|
3443
|
+
``FunctionalObjectProperty(P)`` on a transitive ``P`` is refused with a
|
|
3444
|
+
message naming ``FunctionalObjectProperty`` rather than with the generic
|
|
3445
|
+
"qualified number restriction on role 'P'" — the caller never wrote a
|
|
3446
|
+
number restriction, the internalisation in ``_new_branch`` did.
|
|
3447
|
+
"""
|
|
3448
|
+
for field_name, kind, shape in _SIMPLE_ROLE_AXIOMS:
|
|
3449
|
+
stored = getattr(tbox, field_name)
|
|
3450
|
+
roles = sorted({role for entry in stored for role in entry}
|
|
3451
|
+
if shape == "pair" else set(stored))
|
|
3452
|
+
for role in roles:
|
|
3453
|
+
if _is_simple_role(role, rbox):
|
|
3454
|
+
continue
|
|
3455
|
+
raise NonSimpleRoleError(
|
|
3456
|
+
f"dl.tableau: {kind}({role!r}) is not allowed: {role!r} is "
|
|
3457
|
+
f"NON-SIMPLE — it is COMPOSITE (declared transitive, or the "
|
|
3458
|
+
f"super-role of a property chain), or a composite role entails "
|
|
3459
|
+
f"it via the role hierarchy. OWL 2 (Structural Specification "
|
|
3460
|
+
f"§11) restricts {kind} — and every other axiom in "
|
|
3461
|
+
f"{', '.join(k for _, k, _ in _SIMPLE_ROLE_AXIOMS if k != kind)}, "
|
|
3462
|
+
f"and every qualified number restriction — to SIMPLE roles, "
|
|
3463
|
+
f"because the combination with transitivity is undecidable "
|
|
3464
|
+
f"(Horrocks, Sattler & Tobies 1999). This tableau's clash "
|
|
3465
|
+
f"conditions are also only EXACT on a simple role, where the "
|
|
3466
|
+
f"relation a saturated branch entails is the one-step "
|
|
3467
|
+
f"⊑*-closure of its own edges. Declare the characteristic on a "
|
|
3468
|
+
f"SIMPLE sub-role instead, or decide this knowledge base with "
|
|
3469
|
+
f"dl.owl_reasoner's external, HermiT-backed reasoner or with "
|
|
3470
|
+
f"dl.kb_to_fol(tbox, abox) + api.prove.")
|
|
3471
|
+
|
|
3472
|
+
|
|
3473
|
+
def _non_simple_edge_message(what: str, role: str) -> str:
|
|
3474
|
+
"""The refusal for the construct that needs to SEE a forbidden edge: a
|
|
3475
|
+
negative role assertion.
|
|
3476
|
+
|
|
3477
|
+
It is decided by a clash condition over the branch's own edges, and the
|
|
3478
|
+
tableau never MATERIALISES a transitive role's derived edges — the ∀+-rule
|
|
3479
|
+
propagates the restriction along the chain instead (see "Role hierarchies
|
|
3480
|
+
and transitive roles (RBox)"). So on a non-simple role a two-step path
|
|
3481
|
+
``x —r→ m —r→ y`` entails ``r(x, y)`` in every model while leaving the
|
|
3482
|
+
branch with no edge for the condition to find, and the verdict would be
|
|
3483
|
+
"consistent" for a knowledge base with no model. Refusing by name is the
|
|
3484
|
+
only honest answer.
|
|
3485
|
+
"""
|
|
3486
|
+
return (
|
|
3487
|
+
f"dl.tableau: {what} is not allowed: {role!r} is NON-SIMPLE — it is "
|
|
3488
|
+
f"COMPOSITE (declared transitive, or the super-role of a property "
|
|
3489
|
+
f"chain), or a composite role entails it via the role hierarchy. This "
|
|
3490
|
+
f"refusal is about SEEING a forbidden edge: the tableau never "
|
|
3491
|
+
f"materialises a transitive role's derived edges (its ∀+-rule "
|
|
3492
|
+
f"propagates the RESTRICTION along the chain instead — see 'Role "
|
|
3493
|
+
f"hierarchies and transitive roles (RBox)' in dl.tableau's module "
|
|
3494
|
+
f"docstring), so a two-step {role!r}-path x → m → y entails "
|
|
3495
|
+
f"{role!r}(x, y) in every model while leaving the branch with no edge "
|
|
3496
|
+
f"for the clash condition to find, and the verdict would be "
|
|
3497
|
+
f"'consistent' for a knowledge base with no model. An ordinary "
|
|
3498
|
+
f"(positive) role assertion stays supported on a transitive role — one "
|
|
3499
|
+
f"edge is all it needs. "
|
|
3500
|
+
f"Declare the axiom on a SIMPLE sub-role instead, or ask the question "
|
|
3501
|
+
f"of the FOL image with dl.kb_to_fol(tbox, abox) and api.prove, or "
|
|
3502
|
+
f"decide the knowledge base with dl.owl_reasoner's external, "
|
|
3503
|
+
f"HermiT-backed reasoner.")
|
|
3504
|
+
|
|
3505
|
+
|
|
3506
|
+
def _check_simple_negative_role_assertions(abox: Optional[ABox],
|
|
3507
|
+
rbox: _RBox) -> None:
|
|
3508
|
+
"""Raise :class:`NonSimpleRoleError` if a negative role assertion in ``abox``
|
|
3509
|
+
targets a non-simple role (see :func:`_non_simple_edge_message`).
|
|
3510
|
+
"""
|
|
3511
|
+
if abox is None:
|
|
3512
|
+
return
|
|
3513
|
+
for role in sorted({role for _, _, role in abox.negative_role_assertions}):
|
|
3514
|
+
if not _is_simple_role(role, rbox):
|
|
3515
|
+
raise NonSimpleRoleError(_non_simple_edge_message(
|
|
3516
|
+
f"NegativeObjectPropertyAssertion({role!r} a b)", role))
|
|
3517
|
+
|
|
3518
|
+
|
|
3519
|
+
def _reject_role_box(tbox: Optional[TBox], abox: Optional[ABox]) -> None:
|
|
3520
|
+
"""The axiom-level half of both entry points' preamble, in ONE place:
|
|
3521
|
+
refuse every axiom KIND no in-house rule decides, then refuse an inverse
|
|
3522
|
+
role inside a role inclusion (a condition on the stored VALUE, which no
|
|
3523
|
+
table row can express).
|
|
3524
|
+
|
|
3525
|
+
Called as the FIRST statement of :func:`concept_satisfiable`,
|
|
3526
|
+
:func:`abox_consistent` and ``classify``, and by :func:`_reject_inputs` for
|
|
3527
|
+
the entry points that can return without reaching either; the rest of the
|
|
3528
|
+
reasoning API reduces to those two and inherits it (see
|
|
3529
|
+
:func:`_reject_unsupported`).
|
|
3530
|
+
|
|
3531
|
+
Deliberately does NOT build the branch: ``_new_branch`` calls
|
|
3532
|
+
:meth:`TBox.internalized`, which calls ``nnf`` — and ``nnf`` refuses a
|
|
3533
|
+
bare :class:`~unicode_logic_kit.dl.concepts.Nominal` with its own
|
|
3534
|
+
``TypeError``. :func:`_reject_beyond_alc` has to run over the TBox
|
|
3535
|
+
inclusions first, so it is the one that produces the precise
|
|
3536
|
+
:class:`UnsupportedConceptError` (see ``tests/test_dl_alc.py``'s
|
|
3537
|
+
I/O-refusal battery, which covers a nominal nested in an inclusion).
|
|
3538
|
+
|
|
3539
|
+
It starts with the VALUE-level checks, so that a knowledge base that is not
|
|
3540
|
+
well-formed is refused as such before anything is said about its kinds:
|
|
3541
|
+
every stored role-box and data-box entry is a usable name
|
|
3542
|
+
(:func:`_validate_role_box` — the SAME validation
|
|
3543
|
+
:func:`~unicode_logic_kit.dl.translate.rbox_to_fol` runs, so a ``TBox`` built
|
|
3544
|
+
through the dataclass constructor or mutated in place is refused by both
|
|
3545
|
+
routes and not answered by one) and every ABox assertion's property is one
|
|
3546
|
+
(:func:`_reject_abox_roles`: an OWL 2 built-in name is NOT an ordinary
|
|
3547
|
+
role, and treating it as one — which every route did for an ABox
|
|
3548
|
+
assertion — answers a different question).
|
|
3549
|
+
"""
|
|
3550
|
+
if tbox is not None:
|
|
3551
|
+
_validate_role_box(tbox, where="dl.tableau")
|
|
3552
|
+
_validate_data_box(tbox, where="dl.tableau")
|
|
3553
|
+
_reject_abox_roles(abox, where="dl.tableau")
|
|
3554
|
+
_reject_unsupported(tbox, abox)
|
|
3555
|
+
if tbox is not None:
|
|
3556
|
+
_reject_inverse_role_inclusions(tbox)
|
|
3557
|
+
|
|
3558
|
+
|
|
3559
|
+
def _tbox_class_expressions(tbox: Optional[TBox]) -> List[Concept]:
|
|
3560
|
+
"""Every class expression ``tbox`` stores — both sides of each concept
|
|
3561
|
+
inclusion and the filler of each domain and range axiom — which is what
|
|
3562
|
+
:func:`_reject_beyond_alc` has to see for the tableau to be allowed to
|
|
3563
|
+
start (a data-property domain filler is left out: the data box is refused
|
|
3564
|
+
as a whole by :func:`_reject_unsupported`)."""
|
|
3565
|
+
if tbox is None:
|
|
3566
|
+
return []
|
|
3567
|
+
found: List[Concept] = []
|
|
3568
|
+
for sub, sup in tbox.inclusions:
|
|
3569
|
+
found += [sub, sup]
|
|
3570
|
+
for _role, filler in tbox.role_domains + tbox.role_ranges:
|
|
3571
|
+
found.append(filler)
|
|
3572
|
+
return found
|
|
3573
|
+
|
|
3574
|
+
|
|
3575
|
+
def _reject_inputs(tbox: Optional[TBox], abox: Optional[ABox],
|
|
3576
|
+
concepts: Iterable[Concept] = ()) -> None:
|
|
3577
|
+
"""The WHOLE shared guard, for an entry point that may never reach
|
|
3578
|
+
:func:`concept_satisfiable`/:func:`abox_consistent`: :func:`_reject_role_box`,
|
|
3579
|
+
then :func:`_reject_beyond_alc` over ``concepts`` and over every class
|
|
3580
|
+
expression ``tbox`` and ``abox`` store.
|
|
3581
|
+
|
|
3582
|
+
Two entry points reduce to the two guarded ones only WHEN THERE IS
|
|
3583
|
+
SOMETHING TO REDUCE: ``realize`` with an empty vocabulary, ``realize_all``
|
|
3584
|
+
and ``instance_retrieval`` on an empty ABox, ``classify`` with fewer than
|
|
3585
|
+
two names all return without a single ``instance_check``/``subsumes`` call,
|
|
3586
|
+
and so without the guard having run — a knowledge base carrying a refused
|
|
3587
|
+
kind got a quiet ``[]`` where every other entry point raised. Those call
|
|
3588
|
+
this at their top instead of relying on the reduction.
|
|
3589
|
+
"""
|
|
3590
|
+
_reject_role_box(tbox, abox)
|
|
3591
|
+
for concept in concepts:
|
|
3592
|
+
_reject_beyond_alc(concept)
|
|
3593
|
+
for concept in _tbox_class_expressions(tbox):
|
|
3594
|
+
_reject_beyond_alc(concept)
|
|
3595
|
+
if abox is not None:
|
|
3596
|
+
for _individual, concept in abox.concept_assertions:
|
|
3597
|
+
_reject_beyond_alc(concept)
|
|
3598
|
+
|
|
3599
|
+
|
|
3600
|
+
def _has_value_message(concept: HasValue) -> str:
|
|
3601
|
+
"""The refusal for a value restriction ``∃r.{a}`` (OWL's ``ObjectHasValue``),
|
|
3602
|
+
worded like the :class:`~unicode_logic_kit.dl.concepts.Nominal` refusal it
|
|
3603
|
+
shares :func:`_reject_beyond_alc` with: it names the construct, says why it
|
|
3604
|
+
is outside what subset blocking decides, and names the routes that DO decide
|
|
3605
|
+
it — only ones that work (the FOL image, and every ``dl.external_*`` entry
|
|
3606
|
+
point, which translates a value restriction as owlready2's
|
|
3607
|
+
``prop.value(individual)``).
|
|
3608
|
+
"""
|
|
3609
|
+
inverse = ""
|
|
3610
|
+
if isinstance(concept.role, InverseRole):
|
|
3611
|
+
inverse = (f" Its role is also an InverseRole ({concept.role.role}⁻) — "
|
|
3612
|
+
"the I of SHIQ, a second reason it is outside ALCHQ (see "
|
|
3613
|
+
"the module docstring's 'Inverse roles and nominals (I, O)' "
|
|
3614
|
+
"section).")
|
|
3615
|
+
return (
|
|
3616
|
+
f"dl.tableau: the value restriction HasValue ({concept.to_unicode()}, "
|
|
3617
|
+
f"OWL's ObjectHasValue) is outside ALCHQ (this kit's in-house DL "
|
|
3618
|
+
f"fragment) — no in-house tableau rule decides it. It is a NOMINAL: it "
|
|
3619
|
+
f"names the individual {concept.individual!r} inside a concept, and a "
|
|
3620
|
+
f"nominal gives a generated node an edge BACK to a named one, which the "
|
|
3621
|
+
f"subset blocking this tableau terminates by does not cover — a "
|
|
3622
|
+
f"blocked node never receives that edge, so a clash the edge would "
|
|
3623
|
+
f"complete (asymmetry, irreflexivity, …) is never seen and the "
|
|
3624
|
+
f"verdict could be 'satisfiable' for a knowledge base with no model "
|
|
3625
|
+
f"(see 'Value restrictions (ObjectHasValue)' in the module docstring "
|
|
3626
|
+
f"for the two-axiom counterexample). Decide it with the FOL image — "
|
|
3627
|
+
f"dl.kb_to_fol(tbox, abox) and api.prove — or with dl.owl_reasoner's "
|
|
3628
|
+
f"external, HermiT-backed reasoner (dl.external_*).{inverse}")
|
|
3629
|
+
|
|
3630
|
+
|
|
3631
|
+
def _reject_beyond_alc(concept: Concept) -> None:
|
|
3632
|
+
"""Raise :class:`UnsupportedConceptError` if ``concept`` (recursively) contains
|
|
3633
|
+
a :class:`~unicode_logic_kit.dl.concepts.Nominal`, a
|
|
3634
|
+
:class:`~unicode_logic_kit.dl.concepts.HasValue` (a nominal in disguise — see
|
|
3635
|
+
"Value restrictions (ObjectHasValue)" in the module docstring) or an
|
|
3636
|
+
:class:`~unicode_logic_kit.dl.concepts.InverseRole`-valued ``role`` field — the
|
|
3637
|
+
**I**/**O** beyond this tableau's **ALCHQ** fragment (see "Inverse roles and
|
|
3638
|
+
nominals (I, O)" in the module docstring). Called on the query concept plus
|
|
3639
|
+
every TBox inclusion by :func:`concept_satisfiable`, and on every ABox concept
|
|
3640
|
+
assertion plus every TBox inclusion by :func:`abox_consistent` — BEFORE either
|
|
3641
|
+
builds a branch, so the tableau never even starts on an unsupported concept.
|
|
3642
|
+
``instance_check``/``subsumes``/``equivalent`` need no call of their own:
|
|
3643
|
+
they reduce to these two, so they inherit this guard through whichever one
|
|
3644
|
+
they call. ``instance_retrieval``/``realize``/``realize_all``/``classify``
|
|
3645
|
+
reach it through :func:`_reject_inputs` instead, because with nothing to
|
|
3646
|
+
reduce (an empty ABox, vocabulary or name list) there is no call to inherit
|
|
3647
|
+
it from.
|
|
3648
|
+
"""
|
|
3649
|
+
if isinstance(concept, Nominal):
|
|
3650
|
+
raise UnsupportedConceptError(
|
|
3651
|
+
f"dl.tableau: Nominal concepts ({{{concept.individual}}}) are outside "
|
|
3652
|
+
"ALCHQ (this kit's in-house DL fragment) — no in-house tableau rule "
|
|
3653
|
+
"decides them. Use dl.owl_reasoner's external, HermiT-backed reasoner "
|
|
3654
|
+
"instead.")
|
|
3655
|
+
if not isinstance(concept, DATA_CONCEPTS):
|
|
3656
|
+
# An OWL 2 built-in property name (or an equality name, or no name at
|
|
3657
|
+
# all) as the role of THIS node. A data restriction is refused as a
|
|
3658
|
+
# whole below, with its own, more fundamental message.
|
|
3659
|
+
_reject_concept_role(concept, where="dl.tableau")
|
|
3660
|
+
if isinstance(concept, (Top, Bottom, Atomic)):
|
|
3661
|
+
return
|
|
3662
|
+
if isinstance(concept, Not):
|
|
3663
|
+
_reject_beyond_alc(concept.concept)
|
|
3664
|
+
elif isinstance(concept, HasValue):
|
|
3665
|
+
# A nominal in disguise: refused exactly as a bare Nominal is (see
|
|
3666
|
+
# "Value restrictions (ObjectHasValue)" in the module docstring for the
|
|
3667
|
+
# counterexample). Reached AFTER _reject_concept_role above, so a
|
|
3668
|
+
# value restriction over an OWL 2 built-in property keeps its more
|
|
3669
|
+
# specific refusal.
|
|
3670
|
+
raise UnsupportedConceptError(_has_value_message(concept))
|
|
3671
|
+
elif isinstance(concept, (And, Or)):
|
|
3672
|
+
_reject_beyond_alc(concept.left)
|
|
3673
|
+
_reject_beyond_alc(concept.right)
|
|
3674
|
+
elif isinstance(concept, (Exists, ForAll, AtLeast, AtMost)):
|
|
3675
|
+
_reject_inverse_role_field(concept.role)
|
|
3676
|
+
_reject_beyond_alc(concept.concept)
|
|
3677
|
+
elif isinstance(concept, DATA_CONCEPTS):
|
|
3678
|
+
raise UnsupportedConceptError(
|
|
3679
|
+
f"dl.tableau: the data restriction {type(concept).__name__} "
|
|
3680
|
+
f"({concept.to_unicode()}) is outside ALCHQ (this kit's in-house DL "
|
|
3681
|
+
f"fragment): {_DATA_NOTE}. Ask the FOL image — "
|
|
3682
|
+
f"kb = dl.kb_to_fol(tbox, abox, query=[concept]), then "
|
|
3683
|
+
f"api.prove(kb.unsatisfiability_goal(concept), kb.tbox_premises) "
|
|
3684
|
+
f"(kb.subsumption_goal and kb.instance_goal ask the other two "
|
|
3685
|
+
f"questions). 'proved' transfers to OWL 2 and 'refuted' does not "
|
|
3686
|
+
f"(see kb.refutation_is_decisive: the image is sound, not "
|
|
3687
|
+
f"complete). A facet entailment over the data ranges alone is "
|
|
3688
|
+
f"decided by atp.z3_arith.")
|
|
3689
|
+
else:
|
|
3690
|
+
raise TypeError(f"_reject_beyond_alc: unsupported concept {type(concept).__name__}")
|
|
3691
|
+
|
|
3692
|
+
|
|
3693
|
+
def _reject_inverse_role_field(role) -> None:
|
|
3694
|
+
"""Raise :class:`UnsupportedConceptError` if a concept's ``role`` field holds
|
|
3695
|
+
an :class:`~unicode_logic_kit.dl.concepts.InverseRole` — the **I** of SHIQ.
|
|
3696
|
+
|
|
3697
|
+
One function rather than the same four lines in each branch of
|
|
3698
|
+
:func:`_reject_beyond_alc`, so a concept kind that gains a ``role`` field
|
|
3699
|
+
later refuses it with the SAME message rather than a near-copy.
|
|
3700
|
+
"""
|
|
3701
|
+
if isinstance(role, InverseRole):
|
|
3702
|
+
raise UnsupportedConceptError(
|
|
3703
|
+
f"dl.tableau: an InverseRole ({role.role}⁻) is outside "
|
|
3704
|
+
"ALCHQ (this kit's in-house DL fragment) — no in-house tableau "
|
|
3705
|
+
"rule decides it (see the module docstring's 'Inverse roles and "
|
|
3706
|
+
"nominals (I, O)' section for why: it breaks subset blocking's "
|
|
3707
|
+
"soundness/completeness argument). Use dl.owl_reasoner's "
|
|
3708
|
+
"external, HermiT-backed reasoner instead.")
|
|
3709
|
+
|
|
3710
|
+
|
|
3711
|
+
def _new_branch(tbox: Optional[TBox]):
|
|
3712
|
+
"""Return ``(branch, tbox_concepts, rbox)`` for a fresh tableau under ``tbox``.
|
|
3713
|
+
|
|
3714
|
+
``rbox`` is precomputed once here (not per completion-rule application) because
|
|
3715
|
+
it derives entirely from the static role box and is invariant across the whole
|
|
3716
|
+
tableau expansion — see ``_RBox``.
|
|
3717
|
+
|
|
3718
|
+
``tbox_concepts`` is ``TBox.internalized()`` PLUS one ``≤1 P.⊤`` per
|
|
3719
|
+
functional role: ``FunctionalObjectProperty(P)`` is exactly the GCI
|
|
3720
|
+
``⊤ ⊑ ≤1 P.⊤``, and this is the single place both
|
|
3721
|
+
:func:`concept_satisfiable` and :func:`abox_consistent` take the
|
|
3722
|
+
internalised list from — and the one the ∃- and ≥-rules copy onto every
|
|
3723
|
+
freshly generated node, so functionality reaches generated individuals too
|
|
3724
|
+
with no further edit. Added HERE rather than in :meth:`TBox.internalized`,
|
|
3725
|
+
which stays exactly the image of ``inclusions`` (see its docstring).
|
|
3726
|
+
Sorted, so the list is deterministic despite ``functional_roles`` being a
|
|
3727
|
+
``set``.
|
|
3728
|
+
|
|
3729
|
+
The domain and range axioms join it here for the same reason, and are the
|
|
3730
|
+
same kind of thing: ``ObjectPropertyDomain(P C)`` IS the GCI ``∃P.⊤ ⊑ C``,
|
|
3731
|
+
whose internalisation ``nnf(¬∃P.⊤ ⊔ C)`` reduces to ``∀P.⊥ ⊔ C`` — "either
|
|
3732
|
+
no ``P``-successor at all, or in ``C``". ``ObjectPropertyRange(P C)`` is
|
|
3733
|
+
``⊤ ⊑ ∀P.C``, internalised as ``nnf(∀P.C)`` directly rather than as
|
|
3734
|
+
``nnf(¬⊤ ⊔ ∀P.C)``: the two are semantically identical (both force the
|
|
3735
|
+
concept on every element, which is what this list means), but the literal
|
|
3736
|
+
``Or`` form produces ``⊥ ⊔ ∀P.C``, and the ⊔-rule would then open a dead
|
|
3737
|
+
branch on the ``⊥`` disjunct at every node for every range axiom — 108
|
|
3738
|
+
pointless binary branchings per node on the OEO ontology. The DOMAIN form
|
|
3739
|
+
keeps its genuine disjunction, which is unavoidable.
|
|
3740
|
+
|
|
3741
|
+
Neither needs a completion rule, so subset blocking's termination argument
|
|
3742
|
+
is untouched, and neither introduces an ``AtLeast``/``AtMost``, so
|
|
3743
|
+
``_check_simple_role_box`` is unaffected and a domain or range axiom on a
|
|
3744
|
+
transitive role stays legal.
|
|
3745
|
+
"""
|
|
3746
|
+
if not tbox:
|
|
3747
|
+
return _Branch(), [], _EMPTY_RBOX
|
|
3748
|
+
tbox_concepts = tbox.internalized()
|
|
3749
|
+
tbox_concepts += [nnf(AtMost(1, role, Top()))
|
|
3750
|
+
for role in sorted(tbox.functional_roles)]
|
|
3751
|
+
tbox_concepts += [nnf(Or(Not(Exists(role, Top())), concept))
|
|
3752
|
+
for role, concept in tbox.role_domains]
|
|
3753
|
+
tbox_concepts += [nnf(ForAll(role, concept))
|
|
3754
|
+
for role, concept in tbox.role_ranges]
|
|
3755
|
+
return _Branch(), tbox_concepts, _RBox.from_tbox(tbox)
|
|
3756
|
+
|
|
3757
|
+
|
|
3758
|
+
def concept_satisfiable(concept: Concept, tbox: Optional[TBox] = None) -> bool:
|
|
3759
|
+
"""Return True iff ``concept`` is satisfiable with respect to ``tbox``.
|
|
3760
|
+
|
|
3761
|
+
Satisfiable means some model places an individual in the concept while obeying
|
|
3762
|
+
every TBox axiom (including its RBox — role hierarchy and transitivity
|
|
3763
|
+
declarations, see "Role hierarchies and transitive roles (RBox)" in the module
|
|
3764
|
+
docstring). ``tbox=None`` is the empty TBox (pure concept satisfiability).
|
|
3765
|
+
|
|
3766
|
+
Raises:
|
|
3767
|
+
UnsupportedAxiomError: ``tbox`` carries an axiom KIND no in-house rule
|
|
3768
|
+
decides — see "The axiom-kind table" in the module docstring. This
|
|
3769
|
+
guard runs first, before any concept is even looked at.
|
|
3770
|
+
UnsupportedConceptError: ``concept``, a TBox inclusion or a domain/range
|
|
3771
|
+
filler contains a Nominal, a HasValue (a nominal in disguise) or an
|
|
3772
|
+
InverseRole-valued role — see "Inverse roles and nominals (I, O)" and
|
|
3773
|
+
"Value restrictions (ObjectHasValue)" in the module docstring.
|
|
3774
|
+
NonSimpleRoleError: a role-box axiom OWL 2 restricts to simple roles, or a
|
|
3775
|
+
number restriction, targets a non-simple role.
|
|
3776
|
+
RoleExpressionError: a stored role-box entry is not a usable role (a
|
|
3777
|
+
``TBox`` assembled by hand), or ``concept`` / an inclusion uses an
|
|
3778
|
+
OWL 2 built-in property name (``owl:topObjectProperty`` …) or a role
|
|
3779
|
+
called ``=`` as the role of a restriction — an ordinary role of that
|
|
3780
|
+
name would be a different restriction, with a different verdict.
|
|
3781
|
+
|
|
3782
|
+
The anonymous root node is called ``"_root"``, not ``"a"``: it stands for
|
|
3783
|
+
"SOME element", existentially quantified, whereas an individual name denotes
|
|
3784
|
+
a FIXED one, so a root that carried such a name would add an equation the
|
|
3785
|
+
query never stated. (No concept of this fragment names an individual — a
|
|
3786
|
+
:class:`~unicode_logic_kit.dl.concepts.HasValue` is refused — so no collision
|
|
3787
|
+
is reachable here; the name is kept, and is not ``"a"``, so that none can
|
|
3788
|
+
become reachable by accident.) The root is a named node (a ``str``), not a
|
|
3789
|
+
:class:`_Generated` one, so :func:`_blocked` never blocks it and it never
|
|
3790
|
+
blocks, exactly as it never did under the old name.
|
|
3791
|
+
"""
|
|
3792
|
+
_reject_role_box(tbox, None)
|
|
3793
|
+
_reject_beyond_alc(concept)
|
|
3794
|
+
if tbox is not None:
|
|
3795
|
+
for sub, sup in tbox.inclusions:
|
|
3796
|
+
_reject_beyond_alc(sub)
|
|
3797
|
+
_reject_beyond_alc(sup)
|
|
3798
|
+
# A domain/range filler is an ordinary class expression, so a Nominal, a
|
|
3799
|
+
# HasValue or an InverseRole can hide in one exactly as in an inclusion
|
|
3800
|
+
# -- and it reaches the tableau through _new_branch's internalisation.
|
|
3801
|
+
for _role, filler in tbox.role_domains + tbox.role_ranges:
|
|
3802
|
+
_reject_beyond_alc(filler)
|
|
3803
|
+
branch, tbox_concepts, rbox = _new_branch(tbox)
|
|
3804
|
+
if tbox is not None:
|
|
3805
|
+
_check_simple_role_box(tbox, rbox)
|
|
3806
|
+
_check_simple_roles([concept] + tbox_concepts, rbox)
|
|
3807
|
+
branch.add_node(_ROOT_NAME)
|
|
3808
|
+
branch.label[_ROOT_NAME].update(tbox_concepts)
|
|
3809
|
+
branch.label[_ROOT_NAME].add(nnf(concept))
|
|
3810
|
+
return _solve(branch, tbox_concepts, rbox, _Ctx(MAX_STEPS))
|
|
3811
|
+
|
|
3812
|
+
|
|
3813
|
+
#: The anonymous root's name in :func:`concept_satisfiable`. A named node (a
|
|
3814
|
+
#: ``str``), so :func:`_blocked` leaves it alone, which is the behaviour the
|
|
3815
|
+
#: root has always had. No individual can share the name: the branch of
|
|
3816
|
+
#: :func:`concept_satisfiable` holds the root and its witnesses and nothing
|
|
3817
|
+
#: else, because a concept of this fragment names no individual (a
|
|
3818
|
+
#: :class:`~unicode_logic_kit.dl.concepts.HasValue` is refused), and a witness is
|
|
3819
|
+
#: a :class:`_Generated`, which equals no string.
|
|
3820
|
+
_ROOT_NAME = "_root"
|
|
3821
|
+
|
|
3822
|
+
|
|
3823
|
+
def concept_unsatisfiable(concept: Concept, tbox: Optional[TBox] = None) -> bool:
|
|
3824
|
+
"""Return True iff ``concept`` is unsatisfiable with respect to ``tbox``."""
|
|
3825
|
+
return not concept_satisfiable(concept, tbox)
|
|
3826
|
+
|
|
3827
|
+
|
|
3828
|
+
def subsumes(sub: Concept, sup: Concept, tbox: Optional[TBox] = None) -> bool:
|
|
3829
|
+
"""Return True iff ``tbox`` entails ``sub ⊑ sup`` (every model puts ``sub`` in ``sup``).
|
|
3830
|
+
|
|
3831
|
+
Decided by the standard reduction: ``sub ⊑ sup`` holds iff ``sub ⊓ ¬sup`` is
|
|
3832
|
+
unsatisfiable with respect to the TBox.
|
|
3833
|
+
"""
|
|
3834
|
+
return not concept_satisfiable(And(sub, Not(sup)), tbox)
|
|
3835
|
+
|
|
3836
|
+
|
|
3837
|
+
def equivalent(c: Concept, d: Concept, tbox: Optional[TBox] = None) -> bool:
|
|
3838
|
+
"""Return True iff ``tbox`` entails ``c ≡ d`` (mutual subsumption)."""
|
|
3839
|
+
return subsumes(c, d, tbox) and subsumes(d, c, tbox)
|
|
3840
|
+
|
|
3841
|
+
|
|
3842
|
+
def _individuals(abox: ABox) -> Set[str]:
|
|
3843
|
+
"""The individual names mentioned in ``abox`` (any assertion kind).
|
|
3844
|
+
|
|
3845
|
+
Read off :data:`_AXIOM_KINDS`' ``individual_positions`` via
|
|
3846
|
+
:func:`_abox_individual_names`, so an assertion kind added later is
|
|
3847
|
+
included here by its table row alone.
|
|
3848
|
+
|
|
3849
|
+
An ABox with no named individuals at all falls back to the single anonymous
|
|
3850
|
+
individual ``"a"``, matching :func:`abox_consistent`'s own convention that an
|
|
3851
|
+
empty ABox is trivially consistent (some individual exists, it just carries
|
|
3852
|
+
no assertions). That fallback is the ONE thing this differs in from
|
|
3853
|
+
:func:`~unicode_logic_kit.dl.translate._abox_individuals`, which must not
|
|
3854
|
+
invent a constant no assertion ever named.
|
|
3855
|
+
|
|
3856
|
+
It is for :func:`abox_consistent` ONLY — the node the TBox has to run on.
|
|
3857
|
+
The two sweeps over "the individuals of the knowledge base",
|
|
3858
|
+
:func:`instance_retrieval` and :func:`realize_all`, read
|
|
3859
|
+
:func:`_abox_individual_names` instead: the anonymous node is not one of
|
|
3860
|
+
them, and reporting it as a member (``{"a"}`` for ``TBox().add(Top(), A)``
|
|
3861
|
+
and an empty ABox) answered about an individual nobody named.
|
|
3862
|
+
"""
|
|
3863
|
+
return _abox_individual_names(abox) or {"a"}
|
|
3864
|
+
|
|
3865
|
+
|
|
3866
|
+
def abox_consistent(abox: ABox, tbox: Optional[TBox] = None) -> bool:
|
|
3867
|
+
"""Return True iff the knowledge base ``(tbox, abox)`` is consistent (has a model).
|
|
3868
|
+
|
|
3869
|
+
Raises:
|
|
3870
|
+
UnsupportedAxiomError: ``tbox`` or ``abox`` carries an axiom KIND no
|
|
3871
|
+
in-house rule decides — see "The axiom-kind table" in the module
|
|
3872
|
+
docstring. This guard runs first, before any concept is looked at.
|
|
3873
|
+
UnsupportedConceptError: an ABox concept assertion, a TBox inclusion or
|
|
3874
|
+
a domain/range filler contains a Nominal, a HasValue (a nominal in
|
|
3875
|
+
disguise) or an InverseRole-valued role — see "Inverse roles and
|
|
3876
|
+
nominals (I, O)" and "Value restrictions (ObjectHasValue)" in the
|
|
3877
|
+
module docstring.
|
|
3878
|
+
NonSimpleRoleError: a role-box axiom OWL 2 restricts to simple roles, a
|
|
3879
|
+
number restriction or a negative role assertion targets a non-simple
|
|
3880
|
+
role.
|
|
3881
|
+
RoleExpressionError: a stored role-box entry is not a usable role (a
|
|
3882
|
+
``TBox`` assembled by hand), or an ABox assertion / a class
|
|
3883
|
+
expression uses an OWL 2 built-in property name
|
|
3884
|
+
(``owl:bottomObjectProperty`` …) or a role called ``=`` — read as an
|
|
3885
|
+
ordinary role it answers a different question (a pair related by the
|
|
3886
|
+
EMPTY property is inconsistent; by an ordinary role it is not).
|
|
3887
|
+
|
|
3888
|
+
Setup order, which is what makes the identity assertions COMPLETE: add
|
|
3889
|
+
every node (the ABox's individuals), seed the labels
|
|
3890
|
+
with the internalised TBox, add the ABox concept labels, add the role
|
|
3891
|
+
edges, add the FORBIDDEN edges, mark the distinctness pairs — and only THEN
|
|
3892
|
+
apply the same-individual assertions, closed under the equivalence they
|
|
3893
|
+
generate, so ``a = b`` with ``b = c`` collapses all three onto one node.
|
|
3894
|
+
Merging before any completion rule runs is the point: there is no later
|
|
3895
|
+
moment at which a same-assertion could be discovered.
|
|
3896
|
+
"""
|
|
3897
|
+
_reject_role_box(tbox, abox)
|
|
3898
|
+
assertion_concepts = [c for _, c in abox.concept_assertions]
|
|
3899
|
+
for concept in assertion_concepts:
|
|
3900
|
+
_reject_beyond_alc(concept)
|
|
3901
|
+
if tbox is not None:
|
|
3902
|
+
for sub, sup in tbox.inclusions:
|
|
3903
|
+
_reject_beyond_alc(sub)
|
|
3904
|
+
_reject_beyond_alc(sup)
|
|
3905
|
+
for _role, filler in tbox.role_domains + tbox.role_ranges:
|
|
3906
|
+
_reject_beyond_alc(filler)
|
|
3907
|
+
branch, tbox_concepts, rbox = _new_branch(tbox)
|
|
3908
|
+
if tbox is not None:
|
|
3909
|
+
_check_simple_role_box(tbox, rbox)
|
|
3910
|
+
_check_simple_roles(tbox_concepts + assertion_concepts, rbox)
|
|
3911
|
+
_check_simple_negative_role_assertions(abox, rbox)
|
|
3912
|
+
for ind in sorted(_individuals(abox)):
|
|
3913
|
+
branch.add_node(ind)
|
|
3914
|
+
branch.label[ind].update(tbox_concepts)
|
|
3915
|
+
for ind, concept in abox.concept_assertions:
|
|
3916
|
+
branch.label[ind].add(nnf(concept))
|
|
3917
|
+
for a, b, role in abox.role_assertions:
|
|
3918
|
+
branch.edges.add((a, role, b))
|
|
3919
|
+
for a, b, role in abox.negative_role_assertions:
|
|
3920
|
+
branch.negative_edges.add((a, role, b))
|
|
3921
|
+
for a, b in abox.distinct_assertions:
|
|
3922
|
+
branch.mark_distinct(a, b)
|
|
3923
|
+
_apply_same_assertions(branch, abox)
|
|
3924
|
+
return _solve(branch, tbox_concepts, rbox, _Ctx(MAX_STEPS))
|
|
3925
|
+
|
|
3926
|
+
|
|
3927
|
+
def _apply_same_assertions(branch: _Branch, abox: ABox) -> None:
|
|
3928
|
+
"""Merge the endpoints of every ``SameIndividual`` assertion onto one node,
|
|
3929
|
+
closed under the equivalence relation the assertions generate.
|
|
3930
|
+
|
|
3931
|
+
``_merge`` is the ≤-rule's own merge, reused rather than reimplemented: it
|
|
3932
|
+
redirects every edge (positive and negative), unions the labels and
|
|
3933
|
+
re-parents the distinctness pairs, which IS the semantics of "these two
|
|
3934
|
+
names denote one element" — including the case where a distinctness pair
|
|
3935
|
+
collapses, which it now reports as ``self_distinct`` rather than dropping
|
|
3936
|
+
(see :func:`_merge`).
|
|
3937
|
+
|
|
3938
|
+
After a merge the dropped node is GONE from the branch, so a later pair
|
|
3939
|
+
naming it is resolved to the surviving node first, through ``alias``. The
|
|
3940
|
+
map is LOCAL to this function and not kept on the branch: nothing reads an
|
|
3941
|
+
individual name out of a concept any more (a value restriction, the one
|
|
3942
|
+
construct that did, is refused — see "Value restrictions
|
|
3943
|
+
(ObjectHasValue)" in the module docstring), and edges, forbidden edges and
|
|
3944
|
+
distinctness pairs are node-valued, so ``_merge`` rewrites them in place.
|
|
3945
|
+
Only this loop meets a name that may already have been merged away, and a
|
|
3946
|
+
chain — ``c`` merged into ``b``, then ``b`` into ``a`` — resolves ``c`` to
|
|
3947
|
+
``a``; it terminates because a removed name is never added back.
|
|
3948
|
+
``assert_same(a, a)`` resolves both ends to the same node and is then a
|
|
3949
|
+
no-op, as its docstring promises.
|
|
3950
|
+
"""
|
|
3951
|
+
alias: Dict[_Node, _Node] = {}
|
|
3952
|
+
|
|
3953
|
+
def resolve(name: _Node) -> _Node:
|
|
3954
|
+
while name in alias:
|
|
3955
|
+
name = alias[name]
|
|
3956
|
+
return name
|
|
3957
|
+
|
|
3958
|
+
for a, b in abox.same_assertions:
|
|
3959
|
+
left, right = resolve(a), resolve(b)
|
|
3960
|
+
if left == right:
|
|
3961
|
+
continue
|
|
3962
|
+
keep, drop = _merge_order(branch, left, right)
|
|
3963
|
+
_merge(branch, keep, drop)
|
|
3964
|
+
alias[drop] = keep
|
|
3965
|
+
|
|
3966
|
+
|
|
3967
|
+
def instance_check(abox: ABox, individual: str, concept: Concept,
|
|
3968
|
+
tbox: Optional[TBox] = None) -> bool:
|
|
3969
|
+
"""Return True iff the knowledge base ``(tbox, abox)`` entails ``individual : concept``.
|
|
3970
|
+
|
|
3971
|
+
Decided by the ABox-level mirror of :func:`subsumes`'s reduction: entailment
|
|
3972
|
+
holds iff asserting the *complement* ``individual : ¬concept`` alongside the
|
|
3973
|
+
existing assertions makes the knowledge base inconsistent (every model of the
|
|
3974
|
+
KB already puts ``individual`` in ``concept``, so adding ``¬concept`` cannot be
|
|
3975
|
+
satisfied). This is open-world: a False result means the KB does not entail
|
|
3976
|
+
membership, not that it entails non-membership.
|
|
3977
|
+
|
|
3978
|
+
The probe ABox comes from :meth:`ABox.copy`, not from a field-by-field
|
|
3979
|
+
reconstruction: a copy that enumerates fields goes stale the moment a field
|
|
3980
|
+
is added, and the failure is SILENT — this function (and
|
|
3981
|
+
``instance_retrieval``/``realize``/``realize_all``, which all reduce to it)
|
|
3982
|
+
would answer about a strictly WEAKER knowledge base than
|
|
3983
|
+
:func:`abox_consistent` sees on the same ABox. See :meth:`ABox.copy`.
|
|
3984
|
+
"""
|
|
3985
|
+
probe = abox.copy()
|
|
3986
|
+
probe.assert_concept(individual, Not(concept))
|
|
3987
|
+
return not abox_consistent(probe, tbox)
|
|
3988
|
+
|
|
3989
|
+
|
|
3990
|
+
def instance_retrieval(abox: ABox, concept: Concept, tbox: Optional[TBox] = None) -> Set[str]:
|
|
3991
|
+
"""Return every individual of ``abox`` that ``(tbox, abox)`` entails is a ``concept``.
|
|
3992
|
+
|
|
3993
|
+
Sweeps :func:`instance_check` over every individual named in ``abox`` — including
|
|
3994
|
+
one that appears only inside a role assertion, never a concept assertion.
|
|
3995
|
+
|
|
3996
|
+
An ABox that names NO individual has none to report: the answer is the empty
|
|
3997
|
+
set, whatever the TBox says. (This returned ``{"a"}`` for ``TBox().add(Top(),
|
|
3998
|
+
A)`` and an empty ABox: :func:`abox_consistent` seeds an anonymous node ``a``
|
|
3999
|
+
so that an empty ABox still has an element to run the TBox on, and the sweep
|
|
4000
|
+
read that node as if somebody had named it — the FOL route, whose
|
|
4001
|
+
``KnowledgeBaseFOL.individuals`` is ``()``, never did.)
|
|
4002
|
+
|
|
4003
|
+
The shared guard runs FIRST, before the sweep: with no individual to sweep
|
|
4004
|
+
there is no :func:`instance_check` to inherit it from (see
|
|
4005
|
+
:func:`_reject_inputs`).
|
|
4006
|
+
"""
|
|
4007
|
+
_reject_inputs(tbox, abox, [concept])
|
|
4008
|
+
return {ind for ind in sorted(_abox_individual_names(abox))
|
|
4009
|
+
if instance_check(abox, ind, concept, tbox)}
|
|
4010
|
+
|
|
4011
|
+
|
|
4012
|
+
def realize(abox: ABox, individual: str, vocabulary: List[Concept],
|
|
4013
|
+
tbox: Optional[TBox] = None) -> List[Concept]:
|
|
4014
|
+
"""Return ``individual``'s most-specific concepts from ``vocabulary``.
|
|
4015
|
+
|
|
4016
|
+
``vocabulary`` is the caller-supplied list of concepts to classify against —
|
|
4017
|
+
realization needs a fixed vocabulary since this reasoner has no persistent
|
|
4018
|
+
TBox-signature registry to draw one from automatically (see
|
|
4019
|
+
:func:`unicode_logic_kit.dl.classify` for that, over named TBox concepts).
|
|
4020
|
+
|
|
4021
|
+
First keeps only the ``C`` in ``vocabulary`` with ``instance_check(abox,
|
|
4022
|
+
individual, C, tbox)``, then drops any ``C`` for which some other kept ``D``
|
|
4023
|
+
strictly subsumes it (``D ⊑ C`` holds but ``C ⊑ D`` does not) — the remaining
|
|
4024
|
+
antichain is what every OWL reasoner reports as "the" (most specific) types.
|
|
4025
|
+
|
|
4026
|
+
The shared guard runs FIRST: an empty ``vocabulary`` makes the filter below
|
|
4027
|
+
call :func:`instance_check` zero times, and a knowledge base carrying a
|
|
4028
|
+
refused axiom kind used to get a quiet ``[]`` for it (see
|
|
4029
|
+
:func:`_reject_inputs`).
|
|
4030
|
+
"""
|
|
4031
|
+
_reject_inputs(tbox, abox, vocabulary)
|
|
4032
|
+
candidates = [c for c in vocabulary if instance_check(abox, individual, c, tbox)]
|
|
4033
|
+
return [c for c in candidates
|
|
4034
|
+
if not any(subsumes(d, c, tbox) and not subsumes(c, d, tbox) for d in candidates)]
|
|
4035
|
+
|
|
4036
|
+
|
|
4037
|
+
def realize_all(abox: ABox, vocabulary: List[Concept],
|
|
4038
|
+
tbox: Optional[TBox] = None) -> Dict[str, List[Concept]]:
|
|
4039
|
+
"""Return :func:`realize` for every individual named in ``abox``, keyed by name.
|
|
4040
|
+
|
|
4041
|
+
An ABox that names no individual gives ``{}`` — there is nobody to realize
|
|
4042
|
+
(see :func:`instance_retrieval`: the anonymous node :func:`abox_consistent`
|
|
4043
|
+
seeds is not an individual of the knowledge base). The shared guard runs
|
|
4044
|
+
first, because with nobody to realize :func:`realize` is never called.
|
|
4045
|
+
"""
|
|
4046
|
+
_reject_inputs(tbox, abox, vocabulary)
|
|
4047
|
+
return {ind: realize(abox, ind, vocabulary, tbox)
|
|
4048
|
+
for ind in sorted(_abox_individual_names(abox))}
|