unicode-logic-kit 0.31.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. unicode_logic_kit/__init__.py +385 -0
  2. unicode_logic_kit/__main__.py +520 -0
  3. unicode_logic_kit/_deadline.py +219 -0
  4. unicode_logic_kit/ace/__init__.py +126 -0
  5. unicode_logic_kit/ace/_align.py +135 -0
  6. unicode_logic_kit/ace/chem_lexicon.py +128 -0
  7. unicode_logic_kit/ace/drs_reader.py +570 -0
  8. unicode_logic_kit/ace/mapping.py +666 -0
  9. unicode_logic_kit/ace/reverse_modal.py +138 -0
  10. unicode_logic_kit/ace/runner.py +551 -0
  11. unicode_logic_kit/ace/translate.py +452 -0
  12. unicode_logic_kit/ace/verbalize.py +1070 -0
  13. unicode_logic_kit/api.py +1284 -0
  14. unicode_logic_kit/atp/__init__.py +177 -0
  15. unicode_logic_kit/atp/_ascii_names.py +113 -0
  16. unicode_logic_kit/atp/_html.py +72 -0
  17. unicode_logic_kit/atp/_substructural_input.py +228 -0
  18. unicode_logic_kit/atp/_tff_problem.py +715 -0
  19. unicode_logic_kit/atp/_tptp_problem.py +1111 -0
  20. unicode_logic_kit/atp/_writer_support.py +289 -0
  21. unicode_logic_kit/atp/clingo_backend.py +1180 -0
  22. unicode_logic_kit/atp/cvc5_backend.py +1385 -0
  23. unicode_logic_kit/atp/eprover_backend.py +732 -0
  24. unicode_logic_kit/atp/finite_domain.py +1055 -0
  25. unicode_logic_kit/atp/fitch.py +1547 -0
  26. unicode_logic_kit/atp/fitch_search.py +551 -0
  27. unicode_logic_kit/atp/hets_backend.py +339 -0
  28. unicode_logic_kit/atp/hybrid_down.py +120 -0
  29. unicode_logic_kit/atp/incremental.py +250 -0
  30. unicode_logic_kit/atp/kripke_enum.py +741 -0
  31. unicode_logic_kit/atp/lambek.py +436 -0
  32. unicode_logic_kit/atp/leo3_backend.py +332 -0
  33. unicode_logic_kit/atp/linear.py +738 -0
  34. unicode_logic_kit/atp/lj.py +705 -0
  35. unicode_logic_kit/atp/logic_backends.py +566 -0
  36. unicode_logic_kit/atp/ltl_tableau.py +1084 -0
  37. unicode_logic_kit/atp/minizinc_backend.py +1402 -0
  38. unicode_logic_kit/atp/modal_tableau.py +1382 -0
  39. unicode_logic_kit/atp/nanocop_backend.py +410 -0
  40. unicode_logic_kit/atp/portfolio.py +489 -0
  41. unicode_logic_kit/atp/protocol.py +1803 -0
  42. unicode_logic_kit/atp/prover9_entailment.py +1153 -0
  43. unicode_logic_kit/atp/resolution.py +1376 -0
  44. unicode_logic_kit/atp/resolution_check.py +1114 -0
  45. unicode_logic_kit/atp/sequent.py +1050 -0
  46. unicode_logic_kit/atp/tableau.py +921 -0
  47. unicode_logic_kit/atp/tableau_check.py +543 -0
  48. unicode_logic_kit/atp/tptp_ncl.py +811 -0
  49. unicode_logic_kit/atp/tptp_tff.py +1546 -0
  50. unicode_logic_kit/atp/tstp.py +1333 -0
  51. unicode_logic_kit/atp/tstp_check.py +1096 -0
  52. unicode_logic_kit/atp/twee_backend.py +236 -0
  53. unicode_logic_kit/atp/twee_check.py +711 -0
  54. unicode_logic_kit/atp/twee_entailment.py +953 -0
  55. unicode_logic_kit/atp/vampire_entailment.py +540 -0
  56. unicode_logic_kit/atp/z3_arith.py +470 -0
  57. unicode_logic_kit/atp/z3_equivalence.py +36 -0
  58. unicode_logic_kit/atp/z3_fuzzy.py +362 -0
  59. unicode_logic_kit/atp/z3_input.py +500 -0
  60. unicode_logic_kit/atp/z3_models.py +208 -0
  61. unicode_logic_kit/chem/__init__.py +88 -0
  62. unicode_logic_kit/chem/_naming.py +284 -0
  63. unicode_logic_kit/chem/cache.py +185 -0
  64. unicode_logic_kit/chem/interop.py +244 -0
  65. unicode_logic_kit/chem/mol.py +525 -0
  66. unicode_logic_kit/chem/signature.py +112 -0
  67. unicode_logic_kit/comorphism.py +497 -0
  68. unicode_logic_kit/dl/__init__.py +384 -0
  69. unicode_logic_kit/dl/classification.py +227 -0
  70. unicode_logic_kit/dl/concepts.py +632 -0
  71. unicode_logic_kit/dl/datatypes.py +818 -0
  72. unicode_logic_kit/dl/owl_functional.py +2433 -0
  73. unicode_logic_kit/dl/owl_manchester.py +1637 -0
  74. unicode_logic_kit/dl/owl_reasoner.py +790 -0
  75. unicode_logic_kit/dl/parser.py +391 -0
  76. unicode_logic_kit/dl/tableau.py +4048 -0
  77. unicode_logic_kit/dl/translate.py +2704 -0
  78. unicode_logic_kit/drt/__init__.py +94 -0
  79. unicode_logic_kit/drt/export.py +179 -0
  80. unicode_logic_kit/drt/nodes.py +506 -0
  81. unicode_logic_kit/drt/parser.py +965 -0
  82. unicode_logic_kit/drt/resolve.py +195 -0
  83. unicode_logic_kit/drt/reverse.py +175 -0
  84. unicode_logic_kit/eval/__init__.py +106 -0
  85. unicode_logic_kit/eval/batch.py +382 -0
  86. unicode_logic_kit/eval/canonical.py +663 -0
  87. unicode_logic_kit/eval/chem_batch.py +606 -0
  88. unicode_logic_kit/eval/converses.py +200 -0
  89. unicode_logic_kit/eval/datasets/__init__.py +136 -0
  90. unicode_logic_kit/eval/datasets/_base.py +263 -0
  91. unicode_logic_kit/eval/datasets/_proofwriter_proof.py +422 -0
  92. unicode_logic_kit/eval/datasets/c3po.py +678 -0
  93. unicode_logic_kit/eval/datasets/folio.py +158 -0
  94. unicode_logic_kit/eval/datasets/fracas.py +418 -0
  95. unicode_logic_kit/eval/datasets/groves.py +191 -0
  96. unicode_logic_kit/eval/datasets/logicbench.py +467 -0
  97. unicode_logic_kit/eval/datasets/logicnli.py +303 -0
  98. unicode_logic_kit/eval/datasets/malls.py +133 -0
  99. unicode_logic_kit/eval/datasets/pfolio.py +594 -0
  100. unicode_logic_kit/eval/datasets/pmb.py +242 -0
  101. unicode_logic_kit/eval/datasets/prontoqa.py +611 -0
  102. unicode_logic_kit/eval/datasets/proofwriter.py +1431 -0
  103. unicode_logic_kit/eval/datasets/proverqa.py +674 -0
  104. unicode_logic_kit/eval/datasets/willow.py +478 -0
  105. unicode_logic_kit/eval/equivalence.py +466 -0
  106. unicode_logic_kit/eval/exercise_gen.py +533 -0
  107. unicode_logic_kit/eval/explain.py +791 -0
  108. unicode_logic_kit/eval/generality.py +750 -0
  109. unicode_logic_kit/eval/metric_hf.py +458 -0
  110. unicode_logic_kit/eval/predicate_match.py +343 -0
  111. unicode_logic_kit/eval/theory_check.py +1170 -0
  112. unicode_logic_kit/eval/validate.py +306 -0
  113. unicode_logic_kit/fol/__init__.py +177 -0
  114. unicode_logic_kit/fol/_atom_keys.py +510 -0
  115. unicode_logic_kit/fol/_fol_nodes.py +3586 -0
  116. unicode_logic_kit/fol/_free_parameters.py +105 -0
  117. unicode_logic_kit/fol/_ho_nodes.py +448 -0
  118. unicode_logic_kit/fol/_hybrid_nodes.py +308 -0
  119. unicode_logic_kit/fol/_identifiers.py +1091 -0
  120. unicode_logic_kit/fol/_lambek_nodes.py +112 -0
  121. unicode_logic_kit/fol/_linear_nodes.py +352 -0
  122. unicode_logic_kit/fol/_modal_nodes.py +1467 -0
  123. unicode_logic_kit/fol/_msfl_nodes.py +2196 -0
  124. unicode_logic_kit/fol/_numeral_symbols.py +231 -0
  125. unicode_logic_kit/fol/_so_nodes.py +200 -0
  126. unicode_logic_kit/fol/_symbol_names.py +81 -0
  127. unicode_logic_kit/fol/_team_nodes.py +181 -0
  128. unicode_logic_kit/fol/_tptp_symbols.py +551 -0
  129. unicode_logic_kit/fol/_truth_constants.py +117 -0
  130. unicode_logic_kit/fol/casl_export.py +1135 -0
  131. unicode_logic_kit/fol/casl_import.py +929 -0
  132. unicode_logic_kit/fol/derivation.py +367 -0
  133. unicode_logic_kit/fol/dialect_detect.py +70 -0
  134. unicode_logic_kit/fol/dialect_repair.py +537 -0
  135. unicode_logic_kit/fol/frames.py +637 -0
  136. unicode_logic_kit/fol/grammars/terminals.lark +31 -0
  137. unicode_logic_kit/fol/lambda_tools.py +297 -0
  138. unicode_logic_kit/fol/latex_input.py +429 -0
  139. unicode_logic_kit/fol/modal_translation.py +944 -0
  140. unicode_logic_kit/fol/msflparser.py +1033 -0
  141. unicode_logic_kit/fol/naming.py +422 -0
  142. unicode_logic_kit/fol/nodes.py +241 -0
  143. unicode_logic_kit/fol/normalforms.py +492 -0
  144. unicode_logic_kit/fol/pal.py +287 -0
  145. unicode_logic_kit/fol/prolog_export.py +566 -0
  146. unicode_logic_kit/fol/prolog_input.py +505 -0
  147. unicode_logic_kit/fol/prover9_input.py +1325 -0
  148. unicode_logic_kit/fol/qml.py +1760 -0
  149. unicode_logic_kit/fol/qmltp_input.py +525 -0
  150. unicode_logic_kit/fol/sanitize.py +221 -0
  151. unicode_logic_kit/fol/serialize.py +79 -0
  152. unicode_logic_kit/fol/signature.py +1290 -0
  153. unicode_logic_kit/fol/simplify_check.py +544 -0
  154. unicode_logic_kit/fol/spans.py +594 -0
  155. unicode_logic_kit/fol/tptp_input.py +1503 -0
  156. unicode_logic_kit/fol/tptp_repair.py +941 -0
  157. unicode_logic_kit/fol/unification.py +157 -0
  158. unicode_logic_kit/fol/verbalize.py +263 -0
  159. unicode_logic_kit/hets/__init__.py +163 -0
  160. unicode_logic_kit/hets/bridge.py +142 -0
  161. unicode_logic_kit/hets/client.py +748 -0
  162. unicode_logic_kit/hets/docker.py +420 -0
  163. unicode_logic_kit/hets/dol.py +712 -0
  164. unicode_logic_kit/hets/haskell_json.py +355 -0
  165. unicode_logic_kit/hets/owl_backend.py +794 -0
  166. unicode_logic_kit/hets/owl_cli.py +598 -0
  167. unicode_logic_kit/hets/symbols.py +512 -0
  168. unicode_logic_kit/hol/__init__.py +140 -0
  169. unicode_logic_kit/hol/_ho_common.py +323 -0
  170. unicode_logic_kit/hol/_isabelle_binders.py +125 -0
  171. unicode_logic_kit/hol/classical.py +812 -0
  172. unicode_logic_kit/hol/deepshallow/__init__.py +45 -0
  173. unicode_logic_kit/hol/deepshallow/_common.py +177 -0
  174. unicode_logic_kit/hol/deepshallow/conditional.py +225 -0
  175. unicode_logic_kit/hol/deepshallow/intuitionistic.py +181 -0
  176. unicode_logic_kit/hol/deepshallow/modal.py +217 -0
  177. unicode_logic_kit/hol/deepshallow/qml.py +406 -0
  178. unicode_logic_kit/hol/deepshallow/relevant.py +206 -0
  179. unicode_logic_kit/hol/free.py +753 -0
  180. unicode_logic_kit/hol/goedel.py +336 -0
  181. unicode_logic_kit/hol/ho_modal.py +1743 -0
  182. unicode_logic_kit/hol/intuitionistic.py +403 -0
  183. unicode_logic_kit/hol/isabelle_conditional.py +593 -0
  184. unicode_logic_kit/hol/isabelle_modal.py +1908 -0
  185. unicode_logic_kit/hol/isabelle_relevant.py +412 -0
  186. unicode_logic_kit/hol/isabelle_runner.py +1147 -0
  187. unicode_logic_kit/hol/isabelle_substructural.py +884 -0
  188. unicode_logic_kit/hol/lean.py +1018 -0
  189. unicode_logic_kit/hol/manyvalued.py +921 -0
  190. unicode_logic_kit/hol/secondorder.py +687 -0
  191. unicode_logic_kit/hol/thf_modal.py +941 -0
  192. unicode_logic_kit/hol/thirdorder.py +397 -0
  193. unicode_logic_kit/ilp/__init__.py +89 -0
  194. unicode_logic_kit/ilp/readback.py +389 -0
  195. unicode_logic_kit/ilp/separation.py +153 -0
  196. unicode_logic_kit/ilp/task.py +730 -0
  197. unicode_logic_kit/logic.py +163 -0
  198. unicode_logic_kit/mcp/__init__.py +28 -0
  199. unicode_logic_kit/mcp/__main__.py +5 -0
  200. unicode_logic_kit/mcp/chem_tools.py +1031 -0
  201. unicode_logic_kit/mcp/server.py +2453 -0
  202. unicode_logic_kit/mcp/syntax_spec.py +681 -0
  203. unicode_logic_kit/prob/__init__.py +53 -0
  204. unicode_logic_kit/prob/_bdd.py +225 -0
  205. unicode_logic_kit/prob/_column_gen.py +668 -0
  206. unicode_logic_kit/prob/distribution.py +686 -0
  207. unicode_logic_kit/prob/nilsson.py +470 -0
  208. unicode_logic_kit/py.typed +0 -0
  209. unicode_logic_kit/semantics/__init__.py +137 -0
  210. unicode_logic_kit/semantics/_modal_reject.py +156 -0
  211. unicode_logic_kit/semantics/action_models.py +466 -0
  212. unicode_logic_kit/semantics/asp_models.py +1200 -0
  213. unicode_logic_kit/semantics/conditional.py +580 -0
  214. unicode_logic_kit/semantics/dynamic_epistemic.py +95 -0
  215. unicode_logic_kit/semantics/free_logic.py +913 -0
  216. unicode_logic_kit/semantics/fuzzy.py +384 -0
  217. unicode_logic_kit/semantics/fuzzy_kripke.py +442 -0
  218. unicode_logic_kit/semantics/intuitionistic.py +581 -0
  219. unicode_logic_kit/semantics/kripke.py +1139 -0
  220. unicode_logic_kit/semantics/manyvalued.py +580 -0
  221. unicode_logic_kit/semantics/matrix.py +342 -0
  222. unicode_logic_kit/semantics/model_eval.py +1135 -0
  223. unicode_logic_kit/semantics/modelfinder.py +1036 -0
  224. unicode_logic_kit/semantics/nonmonotonic.py +372 -0
  225. unicode_logic_kit/semantics/relevant.py +331 -0
  226. unicode_logic_kit/semantics/secondorder.py +657 -0
  227. unicode_logic_kit/semantics/structures.py +352 -0
  228. unicode_logic_kit/semantics/tarski.py +975 -0
  229. unicode_logic_kit/semantics/team.py +315 -0
  230. unicode_logic_kit/semantics/team_translation.py +416 -0
  231. unicode_logic_kit/semantics/thirdorder.py +358 -0
  232. unicode_logic_kit/semantics/tnorm.py +85 -0
  233. unicode_logic_kit/semantics/truthtable.py +201 -0
  234. unicode_logic_kit-0.31.0.dist-info/METADATA +333 -0
  235. unicode_logic_kit-0.31.0.dist-info/RECORD +237 -0
  236. unicode_logic_kit-0.31.0.dist-info/WHEEL +4 -0
  237. unicode_logic_kit-0.31.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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))}