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,2453 @@
1
+ """The MCP tool layer over :mod:`unicode_logic_kit.api`.
2
+
3
+ Design contract (see the package docstring for the why):
4
+
5
+ * every tool accepts formula TEXT and parses it with ``api.parse_any``
6
+ (dialect auto-detection; an optional ``dialect`` hint) — a parse failure
7
+ ALWAYS comes back as ``{"ok": False, "argument": <which input failed —
8
+ "text", "conclusion", "formula1", "premise[2]", …>, "errors": [...]}``
9
+ with every attempted dialect's diagnostics, exactly what a repair loop
10
+ needs, never a bare exception string. One uniform shape across every
11
+ tool and every argument position (review-hardened: a generic client
12
+ checks ``result.get("ok") is False``, full stop);
13
+ * results are the ``to_dict()`` payloads of the underlying API objects,
14
+ untouched — the MCP layer adds no vocabulary of its own beyond TEXT
15
+ renderings of what is already there (``unicode``, ``axioms_unicode``,
16
+ ``box``), because every tool takes text and a result is only usable as the
17
+ next call's input if it comes back as text;
18
+ * exceptions that ARE the API's documented contract surface as structured
19
+ ``{"error": {"type": ..., "message": ...}}`` dicts (``BackendUnavailable``
20
+ carries its actionable install/start instructions verbatim), so an agent
21
+ can react without parsing tracebacks.
22
+
23
+ The functions below are plain synchronous callables registered on an
24
+ :class:`mcp.server.MCPServer`; they are importable and testable without any
25
+ transport running.
26
+
27
+ STABILITY POLICY (the registered tool SURFACE — names and input schemas):
28
+ within a minor release line (0.N.x) a registered tool is never renamed or
29
+ removed, and its ``input_schema`` (auto-derived by the ``mcp`` SDK from the
30
+ function signature: property names, types, required-ness) only ever gains
31
+ new OPTIONAL parameters — an existing parameter's name, type and
32
+ required/optional flag are stable. Tool bodies are thin wrappers over
33
+ :mod:`unicode_logic_kit.api`, so the payload *contents* already inherit that
34
+ module's own STABILITY POLICY (see its docstring); this paragraph covers
35
+ the tool *surface* specifically, which a schema/name-based MCP client
36
+ depends on in a way a ``pip`` version pin cannot express for a JSON-RPC
37
+ session. ``tests/test_mcp_stability.py`` pins the current tool-schema
38
+ baseline (derived from a real ``list_tools()`` call) and fails with a
39
+ readable diff on any surface drift.
40
+ """
41
+
42
+ import functools
43
+ from typing import List, Optional
44
+
45
+ from .. import api
46
+ from ..atp.protocol import (
47
+ PROVED,
48
+ BackendUnavailable,
49
+ available_backends,
50
+ default_chain,
51
+ _REGISTRY,
52
+ )
53
+
54
+ __all__ = ["create_server", "main"]
55
+
56
+ _SERVER_NAME = "unicode-logic-kit"
57
+
58
+ _INSTRUCTIONS = """Logic toolbox for NL->FOL work: parse (any dialect:
59
+ unicode, TPTP, LaTeX, Prover9, SMT-LIB), well-formedness checks, graded
60
+ equivalence, proving/refuting over a multi-backend portfolio (list_backends
61
+ shows what is registered and available right now), self-explaining
62
+ countermodels, formula diagnosis for repair loops (you are the fixer:
63
+ diagnose -> apply the suggestion -> diagnose again), mechanical repair of
64
+ the failures that have one right answer (repair_formula: an illegal symbol
65
+ name renamed invertibly, a free variable closed on request -- but never a
66
+ bracket guessed into a mixed conjunction/disjunction), logic-to-logic
67
+ translation, and English verbalization. Error-analysis layer:
68
+ compare_formulas gives the full prediction-vs-gold breakdown (structural /
69
+ canonical / vocabulary-aligned match, solver equivalence, symbol diff),
70
+ score_batch aggregates it over a corpus, check_consistency decides whether
71
+ a premise SET is satisfiable (with a model witness), get_signature extracts
72
+ the vocabulary of a formula set, detect_dialect shows what the input looks
73
+ like, normalize/render convert between normal forms and concrete syntaxes,
74
+ truth_table decides propositional formulas by enumeration (classical/K3/LP),
75
+ drs_to_fol turns discourse boxes (donkey sentences, cross-sentence
76
+ anaphora) into provable FOL, and list_translations enumerates the
77
+ logic-to-logic edges translate can follow. A translation comes with side
78
+ axioms (frame conditions; for a many-sorted formula the non-emptiness of
79
+ every sort AND the membership of every sorted constant in its sort):
80
+ translate returns them next to the translated formula, and they go into
81
+ prove / find_countermodel as SEPARATE premises -- without them a valid
82
+ formula comes back refuted.
83
+ Probabilistic layer (exact,
84
+ no sampling): probability_bounds computes Nilsson-style entailed bounds
85
+ from probability-interval premises, probability_query answers
86
+ ProbLog-style queries under distribution semantics. Formulas are passed
87
+ as plain text; results are structured JSON. In the unicode syntax a
88
+ constant whose name is one letter, starts upper-case or holds a space or
89
+ punctuation is written in single quotes ('k2', 'Alice', 'John Doe'), and
90
+ every tool takes that text back as it gives it.
91
+
92
+ Self-correction loop: every parse failure comes back as {"ok": false,
93
+ "argument": ..., "errors": [...], "spec_topic": ...}. Call get_syntax_spec
94
+ with that topic to retrieve the exact rule (naming conventions, operator
95
+ precedence, quantifier scope, the counting quantifier, the chemical
96
+ signature, or the catalogue of known failure modes), then regenerate. The
97
+ grammar therefore does not need to live in your prompt."""
98
+
99
+
100
+ #: Lowercased substring of a parse-error message -> the syntax_spec topic
101
+ #: that explains it. Deliberately a small, honest heuristic: it tells the
102
+ #: caller where to LOOK, it does not claim to have diagnosed the formula —
103
+ #: which is why the fallback is "overview" (whose first entry is the
104
+ #: naming/dialect confusion behind most failures) rather than a guess.
105
+ #:
106
+ #: ORDER IS PRIORITY, most specific first: within ONE message a generic
107
+ #: needle such as "unexpected character" also matches the precise
108
+ #: mixed-connective diagnosis, so the earlier entry decides what that message
109
+ #: is about (see :func:`_spec_topic_for`).
110
+ _SPEC_HINTS = (
111
+ # Mixed same-level connectives: the kit's unicode grammar refuses
112
+ # 'A ∧ B ∨ C' outright instead of resolving it by precedence, so the fix
113
+ # is brackets and the topic is operators — never naming, however much the
114
+ # message mentions a predicate.
115
+ ("cannot mix", "operators"),
116
+ ("without parentheses", "operators"),
117
+ ("parenthesise", "operators"),
118
+ # "… after universal quantifier '∀'" — a quantifier that never got its
119
+ # bound variable, which is a scope question and not a name question.
120
+ ("after universal quantifier", "quantifiers"),
121
+ ("after existential quantifier", "quantifiers"),
122
+ ("invalid name", "naming"),
123
+ ("invalid variable", "naming"),
124
+ ("invalid name/constant", "naming"),
125
+ ("not bound", "quantifiers"),
126
+ ("free variable", "quantifiers"),
127
+ ("incomplete formula", "operators"),
128
+ ("unexpected token", "operators"),
129
+ ("unexpected character", "naming"),
130
+ )
131
+
132
+
133
+ #: How far into the input a dialect got before giving up — imported from the
134
+ #: core facade, which needs the same measure to pick the one error message it
135
+ #: turns into a repair suggestion. One implementation, two callers.
136
+ _message_progress = api._message_progress
137
+
138
+
139
+ def _spec_topic_for(errors) -> Optional[str]:
140
+ """The syntax_spec topic most likely to explain these parse errors.
141
+
142
+ ``errors`` holds one entry per candidate dialect that was tried, and the
143
+ two available signals disagree often enough that neither decides alone:
144
+
145
+ * how FAR a dialect got before giving up — the dialects without
146
+ quantifiers abandon '∀x (P(x) ∧ Q(x) ⊕ R(x))' at position 1 and call it
147
+ a naming problem, and they are the majority, but the ones that read as
148
+ far as the ⊕ named the real cause;
149
+ * how MANY dialects agree — in '∀ P(x)' six of them report a quantifier
150
+ left without its variable and a single second-order reading happens to
151
+ consume the whole string, so distance alone would hand the answer to
152
+ the outlier.
153
+
154
+ So each message votes with a weight given by the RANK of its distance
155
+ among the distinct distances seen (farthest wins, but a near-unanimous
156
+ verdict one step back still outweighs a lone outlier), and the topic with
157
+ the highest total wins. Ties — including the case where every dialect
158
+ stopped at the same place — go to the more specific needle
159
+ (``_SPEC_HINTS`` order). This is a routing hint, not a diagnosis: the
160
+ caller gets the rule most likely to explain the rejection, and the
161
+ messages themselves stay in the response.
162
+
163
+ Each message votes exactly once, for its first matching needle — a mixed
164
+ connective is reported as an unexpected character too, and counting that
165
+ message for both topics would let the vague reading dilute the precise
166
+ one. Matching is case-insensitive: the parsers capitalise their messages
167
+ inconsistently, and a hint that silently stops matching because of a
168
+ capital letter is worse than no hint at all.
169
+ """
170
+ votes = []
171
+ for entry in errors:
172
+ message = entry.get("message", "").lower()
173
+ for rank, (needle, topic) in enumerate(_SPEC_HINTS):
174
+ if needle in message:
175
+ votes.append((_message_progress(message), topic, rank))
176
+ break
177
+ if not votes:
178
+ return "overview"
179
+
180
+ weight_of = {distance: index for index, distance
181
+ in enumerate(sorted({v[0] for v in votes}))}
182
+ scores: dict = {}
183
+ for progress, topic, rank in votes:
184
+ score, best_rank = scores.get(topic, (0, rank))
185
+ scores[topic] = (score + weight_of[progress], min(best_rank, rank))
186
+ return min(scores, key=lambda topic: (-scores[topic][0], scores[topic][1]))
187
+
188
+
189
+ def _parse(text: str, dialect: Optional[str], argument: str = "text"):
190
+ """``(node, None)`` on success, ``(None, error_dict)`` on failure.
191
+
192
+ ``argument`` names WHICH tool input failed in the uniform error shape
193
+ (see the module docstring) so multi-argument tools stay distinguishable
194
+ without inventing per-tool nesting. The failure also carries
195
+ ``spec_topic``: the :func:`syntax_spec` topic to fetch before retrying —
196
+ what turns a bare rejection into a correction loop the caller can close
197
+ on its own.
198
+ """
199
+ parsed = api.parse_any(text, hint=dialect)
200
+ if not parsed.ok:
201
+ errors = parsed.to_dict()["errors"]
202
+ return None, {"ok": False, "argument": argument, "errors": errors,
203
+ "spec_topic": _spec_topic_for(errors)}
204
+ return parsed.formula, None
205
+
206
+
207
+ def _error(exc: Exception) -> dict:
208
+ return {"error": {"type": type(exc).__name__, "message": str(exc)}}
209
+
210
+
211
+ def _text_size(value) -> int:
212
+ """The length of the longest text inside ``value`` (a string, or lists and dicts of them)."""
213
+ longest, pending = 0, [value]
214
+ while pending:
215
+ item = pending.pop()
216
+ if isinstance(item, str):
217
+ longest = max(longest, len(item))
218
+ elif isinstance(item, dict):
219
+ pending.extend(item.values())
220
+ elif isinstance(item, (list, tuple)):
221
+ pending.extend(item)
222
+ return longest
223
+
224
+
225
+ def _nesting(value) -> int:
226
+ """How many containers (dicts, lists) lie on the longest path from ``value`` down."""
227
+ deepest, pending = 0, [(value, 1)]
228
+ while pending:
229
+ item, level = pending.pop()
230
+ if isinstance(item, dict):
231
+ deepest = max(deepest, level)
232
+ pending.extend((child, level + 1) for child in item.values())
233
+ elif isinstance(item, (list, tuple)):
234
+ deepest = max(deepest, level)
235
+ pending.extend((child, level + 1) for child in item)
236
+ return deepest
237
+
238
+
239
+ def _answers_deep_input(tool):
240
+ """``tool``, which never lets a ``RecursionError`` leave it.
241
+
242
+ A formula nested a few hundred levels deep is read by the parser and then walked by
243
+ recursive code (a normal form, a renderer, a node comparison), which runs out of the
244
+ interpreter's recursion limit. The call is then made again where ``api`` reads a deep
245
+ formula (:func:`unicode_logic_kit.api._call_deep`: a worker thread whose stack and recursion
246
+ limit are sized for it), with the nesting bounded by the length of the longest text of the
247
+ arguments (a text cannot be nested deeper than it is long). A call that still runs out is
248
+ answered as the structured ``{"error": ...}`` that every other refusal of this module is,
249
+ never as an exception that leaves the tool.
250
+ """
251
+ @functools.wraps(tool)
252
+ def guarded(*args, **kwargs):
253
+ try:
254
+ return tool(*args, **kwargs)
255
+ except RecursionError:
256
+ pass
257
+ size = max((_text_size(value) for value in (*args, *kwargs.values())), default=0)
258
+ try:
259
+ return api._call_deep(min(size, api._DEEP_MAX_LEVELS), lambda: tool(*args, **kwargs))
260
+ except RecursionError:
261
+ return _error(RecursionError(
262
+ f"{tool.__name__}: the input is nested more deeply than this tool can process "
263
+ f"(the interpreter's recursion limit ran out, also on the worker that reads deep "
264
+ f"formulas); no result was produced"))
265
+
266
+ guarded.answers_deep_input = True
267
+ return guarded
268
+
269
+
270
+ def _answers_serializable(tool):
271
+ """``tool``, whose answer is one the transport can write out, or else a refusal by name.
272
+
273
+ A tool answers with a dict, and the MCP SDK writes that dict as JSON with a limit of its own
274
+ on how deeply it may be nested. An answer nested deeper (the abstract syntax tree of a formula
275
+ a few hundred quantifiers deep) ends in the SDK's own ``ToolError`` whose text speaks of a
276
+ circular reference, which this answer is not. The answer is tried with the SDK's own JSON
277
+ writer; one it cannot write is replaced by the structured ``{"error": ...}`` that names the
278
+ nesting and says what to ask for instead. A call from Python, which needs no JSON, reaches
279
+ the tool itself and is not held to this limit (see :func:`_registered`).
280
+ """
281
+ @functools.wraps(tool)
282
+ def guarded(*args, **kwargs):
283
+ result = tool(*args, **kwargs)
284
+ try:
285
+ import pydantic_core
286
+ except ImportError: # no SDK, nothing is written out
287
+ return result
288
+ try:
289
+ pydantic_core.to_json(result, fallback=str)
290
+ except ValueError:
291
+ return _error(ValueError(
292
+ f"{tool.__name__}: the input was read, but its answer is nested {_nesting(result)} "
293
+ f"levels deep, more than the MCP transport can write as JSON. Ask for a text form "
294
+ f"of it instead (render, which answers a formula as text), or give a shallower "
295
+ f"input"))
296
+ return result
297
+
298
+ return guarded
299
+
300
+
301
+ def _registered(tool):
302
+ """``tool`` as the server registers it: guarded against a deep input and a deep answer."""
303
+ return _answers_serializable(
304
+ tool if getattr(tool, "answers_deep_input", False) else _answers_deep_input(tool))
305
+
306
+
307
+ def _signature_argument(signature):
308
+ """The ``signature`` a tool was handed, as ``api`` reads it.
309
+
310
+ What ``get_signature`` returns is ``{"ok": True, "signature": {...}}``; the tools that take a
311
+ signature accept that whole result, or just its ``signature`` value, or the loose form
312
+ ``{"predicates": ..., "functions": ..., "constants": ...}``. A dict that is exactly such a
313
+ result is replaced by its ``signature`` value; anything else is returned as it is, for
314
+ ``api`` to read or to refuse.
315
+ """
316
+ if (isinstance(signature, dict) and set(signature) == {"ok", "signature"}
317
+ and signature["ok"] is True and isinstance(signature["signature"], dict)):
318
+ return signature["signature"]
319
+ return signature
320
+
321
+
322
+ def _unicode_texts(*concepts):
323
+ """``([text, ...], None)`` or ``(None, {"error": ...})`` for description-logic concepts.
324
+
325
+ ``Concept.to_unicode`` refuses (``ValueError``) a concept one of whose names makes the glyph
326
+ text read back as ANOTHER concept (a class named ``<A⊓B>`` prints as the intersection of
327
+ ``<A`` and ``B>``). A tool answers that refusal like every other refusal of the ``dl``
328
+ package: as the structured error, never as an exception that leaves the tool.
329
+ """
330
+ try:
331
+ return [concept.to_unicode() for concept in concepts], None
332
+ except ValueError as exc:
333
+ return None, _error(exc)
334
+
335
+
336
+ def _normalize_converses(converses: List[dict]):
337
+ """JSON-dict converse declarations -> the internal tuple form.
338
+
339
+ ``converses`` (``compare_formulas``/``score_batch``'s own parameter) is
340
+ a list of ``{"a": [name, arity], "b": [name, arity], "permutation":
341
+ [...]}`` dicts — the JSON-friendly spelling of
342
+ :data:`unicode_logic_kit.eval.converses.ConverseDeclaration`. Raises
343
+ ``ValueError`` for a malformed entry (missing key, wrong shape) BEFORE
344
+ ``api.equivalent``/``eval.compute_fol_metrics`` ever see it; the
345
+ declaration's own semantic validity (arity match, permutation
346
+ bijectivity, no self-pair, …) is ``validate_converses``'s job, run
347
+ inside those calls — this function only bridges the wire shape.
348
+ """
349
+ normalized = []
350
+ for i, entry in enumerate(converses):
351
+ try:
352
+ a_name, a_arity = entry["a"]
353
+ b_name, b_arity = entry["b"]
354
+ permutation = entry["permutation"]
355
+ normalized.append((
356
+ (a_name, int(a_arity)), (b_name, int(b_arity)),
357
+ tuple(int(p) for p in permutation)))
358
+ except (KeyError, TypeError, ValueError) as exc:
359
+ raise ValueError(
360
+ f"converses[{i}]: expected "
361
+ '{"a": [name, arity], "b": [name, arity], "permutation": '
362
+ f'[...]}}, got {entry!r}') from exc
363
+ return normalized
364
+
365
+
366
+ # --------------------------------------------------------------------------
367
+ # Tool implementations (plain functions; registered in create_server)
368
+ # --------------------------------------------------------------------------
369
+
370
+ @_answers_deep_input
371
+ def parse_formula(text: str, dialect: Optional[str] = None) -> dict:
372
+ """Parse formula text (dialect auto-detected) to the kit's JSON AST."""
373
+ parsed = api.parse_any(text, hint=dialect)
374
+ result = parsed.to_dict()
375
+ if parsed.ok:
376
+ # The unicode rendering is what an LLM wants to read back.
377
+ result["unicode"] = parsed.formula.to_unicode_str()
378
+ else:
379
+ result["argument"] = "text"
380
+ return result
381
+
382
+
383
+ @_answers_deep_input
384
+ def check_formula(text: str, dialect: Optional[str] = None,
385
+ signature: Optional[dict] = None) -> dict:
386
+ """Well-formedness + optional signature conformance for formula text.
387
+
388
+ ``signature`` is what ``get_signature`` returns (its whole result, or just its
389
+ ``signature`` value), or
390
+ the loose form ``{"predicates": {"Human": 1}, "functions": {"father": 1},
391
+ "constants": ["socrates"]}``. The truth constants ``⊤`` / ``⊥`` are logical
392
+ constants, never an unknown predicate. A malformed ``signature`` comes back
393
+ as the structured ``{"error": ...}``.
394
+ """
395
+ node, err = _parse(text, dialect)
396
+ if err is not None:
397
+ return err
398
+ try:
399
+ return api.check(node, signature=_signature_argument(signature)).to_dict()
400
+ except (TypeError, ValueError) as exc:
401
+ return _error(exc)
402
+
403
+
404
+ @_answers_deep_input
405
+ def prove(conclusion: str, premises: Optional[List[str]] = None,
406
+ logic: str = "auto", backends: Optional[List[str]] = None,
407
+ timeout_ms: int = 10000, dialect: Optional[str] = None) -> dict:
408
+ """Decide premises |= conclusion; the Verdict dict carries provenance."""
409
+ node, err = _parse(conclusion, dialect, argument="conclusion")
410
+ if err is not None:
411
+ return err
412
+ parsed_premises = []
413
+ for i, p in enumerate(premises or []):
414
+ pnode, perr = _parse(p, dialect, argument=f"premise[{i}]")
415
+ if perr is not None:
416
+ return perr
417
+ parsed_premises.append(pnode)
418
+ try:
419
+ verdict = api.prove(node, parsed_premises, logic=logic,
420
+ backends=backends, timeout=timeout_ms)
421
+ except (BackendUnavailable, ValueError) as exc:
422
+ return _error(exc)
423
+ return verdict.to_dict()
424
+
425
+
426
+ @_answers_deep_input
427
+ def find_countermodel(formula: str, premises: Optional[List[str]] = None,
428
+ logic: str = "auto",
429
+ dialect: Optional[str] = None) -> dict:
430
+ """A countermodel to premises |= formula, with an English explanation."""
431
+ node, err = _parse(formula, dialect, argument="formula")
432
+ if err is not None:
433
+ return err
434
+ parsed_premises = []
435
+ for i, p in enumerate(premises or []):
436
+ pnode, perr = _parse(p, dialect, argument=f"premise[{i}]")
437
+ if perr is not None:
438
+ return perr
439
+ parsed_premises.append(pnode)
440
+ try:
441
+ return api.countermodel(node, parsed_premises, logic=logic).to_dict()
442
+ except (BackendUnavailable, ValueError) as exc:
443
+ return _error(exc)
444
+
445
+
446
+ @_answers_deep_input
447
+ def check_equivalence(formula1: str, formula2: str, method: str = "auto",
448
+ timeout_ms: int = 10000,
449
+ dialect: Optional[str] = None) -> dict:
450
+ """Graded equivalence (exact -> canonical -> aligned -> solver)."""
451
+ node1, err1 = _parse(formula1, dialect, argument="formula1")
452
+ if err1 is not None:
453
+ return err1
454
+ node2, err2 = _parse(formula2, dialect, argument="formula2")
455
+ if err2 is not None:
456
+ return err2
457
+ try:
458
+ return api.equivalent(node1, node2, method=method,
459
+ timeout=timeout_ms).to_dict()
460
+ except ValueError as exc:
461
+ return _error(exc)
462
+
463
+
464
+ @_answers_deep_input
465
+ def diagnose(text: str, dialect: Optional[str] = None,
466
+ signature: Optional[dict] = None) -> dict:
467
+ """One diagnose round of the repair loop; YOU are the fixer.
468
+
469
+ Returns ``{ok, diagnostics, suggestion, converged}`` for the given
470
+ text, plus ``spec_topic`` when it did not parse. Apply the suggestion to
471
+ the text yourself and call again; ``converged=True`` means the text
472
+ parses and checks clean. ``signature`` is read as ``check_formula`` reads
473
+ it (``get_signature``'s whole result, or its ``signature`` value); a malformed one comes
474
+ back as the structured ``{"error": ...}``.
475
+ """
476
+ try:
477
+ step = next(api.repair(text, dialect=dialect, signature=_signature_argument(signature)))
478
+ except (TypeError, ValueError) as exc:
479
+ return _error(exc)
480
+ result = step.to_dict()
481
+ # Same routing every other tool's failure carries: the diagnosis names
482
+ # WHAT broke, spec_topic names the rule to look up before retrying. A
483
+ # loop that has only the message has to guess which rule it violated.
484
+ parse_errors = result.get("diagnostics", {}).get("parse")
485
+ if parse_errors:
486
+ result["spec_topic"] = _spec_topic_for(parse_errors)
487
+ return result
488
+
489
+
490
+ @_answers_deep_input
491
+ def repair_formula(text: str, dialect: Optional[str] = None,
492
+ close_free_variables: bool = False,
493
+ sanitize_invalid_names: bool = True) -> dict:
494
+ """Mechanically repair what CAN be repaired; report the rest.
495
+
496
+ The counterpart to ``diagnose`` (where you are the fixer): this fixes the
497
+ two failure shapes that have one right answer, so they cost you no
498
+ attempt. A name no symbol class of this dialect accepts (a chemical name
499
+ with digits, commas or hyphens) is renamed to a legal predicate, with the
500
+ original kept in ``names`` — nothing is lost. A free variable is reported
501
+ and, with ``close_free_variables=True``, closed.
502
+
503
+ What it deliberately does NOT do is bracket a formula that mixes ∧ and ∨
504
+ at the same level: the readings differ and choosing one would be a guess.
505
+ That comes back ``ok=False`` with kind ``"mixed_connectives"`` — write the
506
+ brackets you mean and call again.
507
+
508
+ Returns ``{ok, formula, repaired_text, issues, changed, dialect, names}``,
509
+ plus ``spec_topic`` when nothing parsed. ``repaired_text`` is in the kit's
510
+ unicode syntax and re-parses to ``formula``.
511
+ """
512
+ from ..fol.dialect_repair import repair_formula as _repair
513
+
514
+ result = _repair(text, dialect=dialect,
515
+ close_free_variables=close_free_variables,
516
+ sanitize_invalid_names=sanitize_invalid_names).to_dict()
517
+ if not result["ok"]:
518
+ # Same routing every other tool's failure carries: the issue names
519
+ # WHAT broke, spec_topic names the rule to look up before retrying.
520
+ result["spec_topic"] = _spec_topic_for(result["issues"])
521
+ return result
522
+
523
+
524
+ #: The per-edge options ``translate`` forwards, named EXACTLY as the
525
+ #: comorphism edges declare them (``Comorphism.options``) — this layer invents
526
+ #: no option of its own. ``tests/test_mcp_server.py`` pins that this tuple is
527
+ #: the union of what the registry's edges declare, so an edge that grows an
528
+ #: option cannot become silently unreachable over MCP.
529
+ _TRANSLATE_OPTIONS = ("frame", "systems", "temporal_closure", "signature",
530
+ "mode", "bridges")
531
+
532
+ #: Source logics whose text must NOT go through ``parse_any``'s classical-first
533
+ #: mode ladder, because an earlier mode reads the same string as a DIFFERENT
534
+ #: formula: 'P ⊕ Q' is classical Xor in the ``fol`` mode and a strong
535
+ #: Łukasiewicz disjunction in the fuzzy ones, so a fuzzy term parsed by the
536
+ #: ladder would be translated as the classical formula it is not. The dialects
537
+ #: are tried in order and used only when the caller gave no ``dialect`` of
538
+ #: their own; the two fuzzy modes are disjoint (``msfl`` accepts only SORTED
539
+ #: quantifiers, ``fl`` only unsorted ones), hence both.
540
+ _SOURCE_DIALECTS = {"fuzzy": ("fl", "msfl")}
541
+
542
+
543
+ def _translate_options(frame, systems, temporal_closure, signature, mode,
544
+ bridges) -> dict:
545
+ """The per-edge options that were actually given, shaped for the registry.
546
+
547
+ ``None`` means "not given" and is dropped, so an edge's own default
548
+ (``frame="K"``, ``mode="constant"``, ``temporal_closure=True``) applies.
549
+ This checks only the SHAPE of each value — a ``temporal_closure="false"``
550
+ string would be truthy and silently mean ``True`` — and leaves membership
551
+ (is that a known frame / mode / bridge / modal family?) and "does any edge
552
+ on this path take that option?" to the edges and the registry, whose
553
+ refusals already name what is accepted and which the tool returns as
554
+ ``{"error": ...}``. ``ValueError`` on a malformed value.
555
+ """
556
+ options: dict = {}
557
+
558
+ def given(name, value, kind, what):
559
+ if value is None:
560
+ return False
561
+ # bool is an int subclass, but 'frame': true is not a frame name.
562
+ if not isinstance(value, kind) or (kind is not bool
563
+ and isinstance(value, bool)):
564
+ raise ValueError(
565
+ f"translate: {name} must be {what}, got {value!r}")
566
+ return True
567
+
568
+ if given("frame", frame, str,
569
+ "a string — a modal system name such as 'S4', or a "
570
+ "Scott–Lemmon spec like 'G(1,1,1,1)'"):
571
+ options["frame"] = frame
572
+ if given("mode", mode, str,
573
+ "a string — the quantified-modal domain regime, such as "
574
+ "'constant' or 'varying'"):
575
+ options["mode"] = mode
576
+ if given("temporal_closure", temporal_closure, bool, "true or false"):
577
+ options["temporal_closure"] = temporal_closure
578
+ if given("systems", systems, dict,
579
+ "an object mapping a modal family to a system name, e.g. "
580
+ '{"epistemic": "S5"}'):
581
+ if not all(isinstance(k, str) and isinstance(v, str)
582
+ for k, v in systems.items()):
583
+ raise ValueError(
584
+ f"translate: systems must map a family name to a system "
585
+ f"name (both strings), got {systems!r}")
586
+ options["systems"] = dict(systems)
587
+ if given("bridges", bridges, (list, tuple), "a list of bridge names"):
588
+ if not all(isinstance(b, str) for b in bridges):
589
+ raise ValueError(
590
+ f"translate: bridges must be a list of strings, got "
591
+ f"{bridges!r}")
592
+ options["bridges"] = list(bridges)
593
+ if given("signature", signature, dict,
594
+ 'a signature object, e.g. {"subsorts": {"Human": ["Animal"]}}'):
595
+ from ..fol.signature import Signature
596
+
597
+ # from_dict's own refusals (unknown key, wrong-typed section, a subsort
598
+ # cycle) name the offending entry; they surface through _error.
599
+ options["signature"] = Signature.from_dict(signature)
600
+ return options
601
+
602
+
603
+ def _text_of(node) -> str:
604
+ """The unicode rendering of ``node``, in a form the OTHER tools can read.
605
+
606
+ Every tool here takes formula TEXT, so a translated formula and its side
607
+ axioms are only usable as premises if their text parses again, and as the
608
+ same formula. A constant always does: the printer writes it in single
609
+ quotes whenever its bare name would read as something else (``'a'``,
610
+ ``'k2'``, ``'Alice'``, ``'John Doe'``), so a constant of any name that has
611
+ a text reads back as itself. A name that is not a constant's is printed as
612
+ it is, and the text grammar can still refuse or misread it: the bound
613
+ variables the translations mint are legal names since 0.30.0, but a
614
+ variable the CALLER names ``hasChild`` is a binder the grammar refuses (a
615
+ binder is one letter and digits), and a predicate that starts lower-case
616
+ (an OWL-style role ``hasChild(x, y)``) reads as a function application,
617
+ which is no formula. Rendered as it is, the result can look right and
618
+ ``prove`` rejects it. So the node is rendered as the kit prints it and,
619
+ failing that, with its bound variables alpha-renamed to ``q0``, ``q1``, …
620
+ — a renaming that changes no meaning, and the one thing that mends the
621
+ first case — and the first spelling that reads back as EXACTLY the same
622
+ formula wins. Failing both, the first that parses at all (a FREE variable
623
+ named like a constant, ``hasChild``, reads back as that constant, and
624
+ alpha-renaming leaves a free name alone), and failing that the plain
625
+ printing; the ``result`` / ``axioms`` ASTs next to it stay the authority.
626
+ A rendering that already reads back is left exactly as the kit prints it.
627
+
628
+ Raises:
629
+ ValueError: ``node`` holds a constant that has no text (an empty name,
630
+ or a name with a control character): the printer refuses it by
631
+ name, and ``translate`` answers that as a structured error.
632
+ """
633
+ from ..eval.canonical import _alpha_normalize
634
+
635
+ spellings = (node, _alpha_normalize(node))
636
+ texts = [spelling.to_unicode_str() for spelling in spellings]
637
+ readings: dict = {}
638
+
639
+ def reads(i: int, exact: bool) -> bool:
640
+ if i not in readings:
641
+ readings[i] = api.parse_any(texts[i])
642
+ reading = readings[i]
643
+ return reading.ok and (not exact or reading.formula == spellings[i])
644
+
645
+ for exact in (True, False):
646
+ for i in range(len(spellings)):
647
+ if reads(i, exact):
648
+ return texts[i]
649
+ return texts[0]
650
+
651
+
652
+ def _text_of_term(value) -> Optional[str]:
653
+ """``unicode`` text for a Node or a DL concept, else ``None``."""
654
+ if hasattr(value, "to_unicode_str"):
655
+ return _text_of(value)
656
+ if hasattr(value, "to_unicode"): # dl.Concept spelling
657
+ return value.to_unicode()
658
+ return None
659
+
660
+
661
+ def _parse_term(term: str, from_logic: str, dialect: Optional[str]):
662
+ """``(payload, None)`` or ``(None, error_dict)`` for the source logic's
663
+ own term type (see :func:`translate` for the list)."""
664
+ if from_logic == "casl":
665
+ return term, None
666
+ if from_logic == "alc":
667
+ from ..dl import ConceptSyntaxError, parse_concept
668
+
669
+ try:
670
+ return parse_concept(term), None
671
+ except ConceptSyntaxError as exc:
672
+ return None, {"ok": False, "argument": "term",
673
+ "errors": [{"dialect": "alc", "message": str(exc)}]}
674
+ if from_logic == "drs":
675
+ from .. import drt
676
+
677
+ try:
678
+ return drt.parse_drs(term), None
679
+ except (drt.DRSSyntaxError, ValueError) as exc:
680
+ return None, {"ok": False, "argument": "term",
681
+ "errors": [{"dialect": "drs_box",
682
+ "message": str(exc)}]}
683
+ hints = (dialect,) if dialect else _SOURCE_DIALECTS.get(from_logic, (None,))
684
+ failures: list = []
685
+ for hint in hints:
686
+ node, err = _parse(term, hint, argument="term")
687
+ if err is None:
688
+ return node, None
689
+ failures.append(err)
690
+ err = failures[0]
691
+ if len(failures) > 1: # keep every attempted dialect's diagnosis
692
+ errors = [e for failure in failures for e in failure["errors"]]
693
+ err = {"ok": False, "argument": "term", "errors": errors,
694
+ "spec_topic": _spec_topic_for(errors)}
695
+ if from_logic == "qml" and dialect is None:
696
+ # Quantified modal logic over SORTED quantifiers ('□∀x:Human …') is
697
+ # something the qml edge translates, but no single parse_any mode
698
+ # reads modal operators and sorts together — so only after the whole
699
+ # ladder has failed, and only for this source logic, try that one
700
+ # combination. A failure keeps the ladder's diagnostics.
701
+ from ..fol.msflparser import MSFLParser
702
+
703
+ try:
704
+ return MSFLParser(many_sorted=True, modal=True).parse(term), None
705
+ except Exception: # noqa: BLE001 - parser errors
706
+ pass
707
+ return None, err
708
+
709
+
710
+ @_answers_deep_input
711
+ def translate(term: str, from_logic: str, to_logic: str,
712
+ dialect: Optional[str] = None,
713
+ frame: Optional[str] = None,
714
+ systems: Optional[dict] = None,
715
+ temporal_closure: Optional[bool] = None,
716
+ signature: Optional[dict] = None,
717
+ mode: Optional[str] = None,
718
+ bridges: Optional[List[str]] = None) -> dict:
719
+ """Translate between logics over the comorphism registry — and pass every entry of the returned ``axioms`` as a SEPARATE premise next to ``result`` (never conjoined onto it, never dropped), or the translated formula answers a different question and a valid formula comes back refuted.
720
+
721
+ The result is ``{result, unicode, axioms, axioms_unicode, guarantee,
722
+ source, target, path, lossy, note}``. ``result`` is the translated term
723
+ (JSON AST) and ``unicode`` its text; ``axioms`` are the side conditions of
724
+ the translation, already in the TARGET logic (``axioms_unicode`` is the
725
+ same list as text, entry for entry), e.g. the frame conditions of a modal
726
+ system, or for a many-sorted formula BOTH the non-emptiness of every sort
727
+ (an ``∃`` sentence about the sort ``Human``) AND the membership atom of
728
+ every sorted constant (``Human(socrates)``: a constant written
729
+ ``socrates:Human`` is an element of ``Human``) -- a caller who passes only
730
+ the first answers a weaker question. To
731
+ decide a question about the translated formula, call ``prove`` with the
732
+ ``unicode`` as the conclusion and ``axioms_unicode`` among the
733
+ ``premises``; ``find_countermodel`` and ``check_consistency`` take them
734
+ the same way. ``guarantee`` is what the translation preserves ONCE those
735
+ axioms are added — ``faithful`` (every question transfers),
736
+ ``validity`` (validity and entailment transfer), ``satisfiability`` (only
737
+ satisfiability: a validity answer through it means nothing), ``lossy``
738
+ (neither; ``note`` says what is dropped) — or ``null`` when an edge on the
739
+ path declares none, which is NOT the same as faithful. ``note`` carries
740
+ the conventions (e.g. the free world variable ``w`` a modal image is
741
+ anchored at). ``list_translations`` shows the logic labels, the edges and
742
+ the options each edge takes.
743
+
744
+ The term's PARSER follows the source logic's own term type: ``"casl"``
745
+ terms are CASL spec TEXT passed through verbatim (the dynamic
746
+ ``hets:<Name>`` edges); ``"alc"`` terms are description-logic concept
747
+ text (``Human ⊓ ∃hasChild.Doctor``) parsed by the DL grammar —
748
+ review-confirmed: the registered alc→modal/alc→fol edges take
749
+ ``Concept`` objects that no FOL-family grammar can produce; ``"drs"``
750
+ terms are discourse-representation boxes in the compact box notation
751
+ (``[x | Farmer(x), Runs(x)]``, as ``drs_to_fol`` takes); ``"fuzzy"`` terms
752
+ are read in the Łukasiewicz dialect (so ``⊕`` is the strong disjunction,
753
+ not Xor); every other source logic parses the term as a formula via
754
+ ``parse_any`` (``"qml"`` additionally reads sorted quantifiers under
755
+ modal operators). Node/Concept results gain a ``"unicode"`` rendering; a
756
+ DRS result gains ``"box"``, its box notation. Bound variables the
757
+ translations name in a way the text grammar rejects are renamed ``q0``,
758
+ ``q1``, … in the text renderings only, so every text here can be passed
759
+ back to the other tools.
760
+
761
+ Options are forwarded to the edges on the path that declare them and
762
+ omitted ones keep the edge's default: ``frame`` (modal system, default
763
+ ``K``), ``systems`` (``{"epistemic": "S5"}``-style, per agent family) and
764
+ ``temporal_closure`` for ``modal``/``qml`` sources; ``mode`` (domain
765
+ regime) and ``bridges`` (cross-family frame conditions) for ``qml``;
766
+ ``signature`` (``{"subsorts": {"Human": ["Animal"]}}``) for ``msfol``,
767
+ where it adds one axiom per declared subsort edge. An option no edge on the
768
+ path takes, or an unknown frame / mode / bridge / family, is a structured
769
+ error naming what is accepted.
770
+ """
771
+ try:
772
+ options = _translate_options(frame, systems, temporal_closure,
773
+ signature, mode, bridges)
774
+ except (ValueError, TypeError) as exc:
775
+ return _error(exc)
776
+ payload, err = _parse_term(term, from_logic, dialect)
777
+ if err is not None:
778
+ return err
779
+ from ..comorphism import DEFAULT_REGISTRY
780
+
781
+ try:
782
+ # The registry, not api.translate: that facade takes no options, and
783
+ # a translation that silently ignores frame= is the wrong question.
784
+ result = DEFAULT_REGISTRY.translate(payload, from_logic, to_logic,
785
+ **options)
786
+ except (ValueError, TypeError, NotImplementedError) as exc:
787
+ return _error(exc)
788
+ try:
789
+ rendered = result.to_dict()
790
+ text = _text_of_term(result.result)
791
+ if text is not None:
792
+ rendered["unicode"] = text
793
+ elif hasattr(result.result, "to_box_notation"): # drt.DRS
794
+ rendered["box"] = result.result.to_box_notation()
795
+ # Parallel to ``axioms`` (same length, same order); an axiom with no text
796
+ # form falls back to the repr that ``to_dict`` already gave it.
797
+ rendered["axioms_unicode"] = [_text_of_term(a) or repr(a)
798
+ for a in result.axioms]
799
+ except (ValueError, NotImplementedError) as exc:
800
+ # A result with no faithful text (a concept whose name reads back as another
801
+ # concept, see ``Concept.to_unicode``) is a refusal, not a crash.
802
+ return _error(exc)
803
+ return rendered
804
+
805
+
806
+ @_answers_deep_input
807
+ def verbalize(text: str, dialect: Optional[str] = None) -> dict:
808
+ """Render a formula as deterministic English (fol.to_english)."""
809
+ from ..fol import to_english
810
+
811
+ node, err = _parse(text, dialect)
812
+ if err is not None:
813
+ return err
814
+ try:
815
+ return {"ok": True, "english": to_english(node)}
816
+ except (ValueError, NotImplementedError) as exc:
817
+ return _error(exc)
818
+
819
+
820
+ @_answers_deep_input
821
+ def list_backends() -> dict:
822
+ """Registry introspection: what can decide, and what runs by default."""
823
+ return {
824
+ "registered": sorted(_REGISTRY),
825
+ "available": list(available_backends()),
826
+ "default_chains": {"fol": list(default_chain("fol")),
827
+ "modal": list(default_chain("modal"))},
828
+ }
829
+
830
+
831
+ # --------------------------------------------------------------------------
832
+ # Error-analysis / conversion layer (second tool wave)
833
+ # --------------------------------------------------------------------------
834
+
835
+ # form name -> (callable path, what the result means relative to the input).
836
+ # tseitin_cnf and skolemize deliberately do NOT claim equivalence — an agent
837
+ # that feeds the result back into prove() must know the difference.
838
+ _NORMALIZE_SEMANTICS = {
839
+ "nnf": "equivalent", "pnf": "equivalent", "cnf": "equivalent",
840
+ "dnf": "equivalent", "canonical": "equivalent",
841
+ "tseitin_cnf": "equisatisfiable",
842
+ "skolemize": "satisfiability-preserving",
843
+ }
844
+
845
+
846
+ @_answers_deep_input
847
+ def normalize(text: str, form: str = "nnf",
848
+ dialect: Optional[str] = None) -> dict:
849
+ """Rewrite a formula into a normal form.
850
+
851
+ ``form``: ``nnf`` / ``pnf`` / ``cnf`` / ``dnf`` (equivalence-preserving),
852
+ ``canonical`` (the eval layer's comparison normal form: alpha-renaming,
853
+ commutativity/associativity, duplication, double negation quotiented
854
+ out), ``tseitin_cnf`` (EQUISATISFIABLE only — fresh definitional atoms),
855
+ ``skolemize`` (satisfiability-preserving — existentials become Skolem
856
+ terms). The ``semantics`` key states which of those relations the result
857
+ bears to the input, and ``is_horn`` reports whether the input's clausal
858
+ form is Horn (``None`` where that computation is not applicable).
859
+ """
860
+ from ..fol import normalforms
861
+ from ..eval import canonicalize as _canonicalize
862
+
863
+ node, err = _parse(text, dialect)
864
+ if err is not None:
865
+ return err
866
+ transforms = {
867
+ "nnf": normalforms.to_nnf, "pnf": normalforms.to_pnf,
868
+ "cnf": normalforms.to_cnf, "dnf": normalforms.to_dnf,
869
+ "tseitin_cnf": normalforms.to_tseitin_cnf,
870
+ "skolemize": normalforms.skolemize,
871
+ "canonical": _canonicalize,
872
+ }
873
+ if form not in transforms:
874
+ return _error(ValueError(
875
+ f"normalize: unknown form {form!r} (one of {sorted(transforms)})"))
876
+ try:
877
+ result = transforms[form](node)
878
+ except (ValueError, TypeError, NotImplementedError) as exc:
879
+ return _error(exc)
880
+ try:
881
+ horn = normalforms.is_horn(node)
882
+ except Exception:
883
+ horn = None # presentational extra, never fatal
884
+ return {"ok": True, "form": form,
885
+ "semantics": _NORMALIZE_SEMANTICS[form],
886
+ "unicode": result.to_unicode_str(),
887
+ "formula": result.to_dict(),
888
+ "is_horn": horn}
889
+
890
+
891
+ @_answers_deep_input
892
+ def render(text: str, to: str = "tptp",
893
+ dialect: Optional[str] = None) -> dict:
894
+ """Render a formula in another concrete syntax.
895
+
896
+ ``to``: ``unicode`` / ``tptp`` / ``prover9`` / ``latex`` / ``smtlib``
897
+ (a standalone SMT-LIB2 problem: ``(set-logic ...)``, the declaration
898
+ preamble, and one ``(assert ...)`` — no premises through this tool; use
899
+ ``unicode_logic_kit.atp.z3_input.to_smtlib`` directly for an entailment
900
+ with premises) / ``casl`` (a bare CASL formula via ``formula_to_casl``)
901
+ / ``json`` (the versioned ``serialize`` envelope — the only target whose
902
+ ``rendered`` is a dict, not a string) / ``english`` (deterministic
903
+ verbalization). A family without the requested rendering surfaces its
904
+ own ``NotImplementedError``/``ValueError`` as a structured error — for
905
+ ``smtlib`` this is ``to_z3``'s own refusal (second/third-order,
906
+ modal/hybrid/linear/Lambek/team constructs have no first-order SMT-LIB2
907
+ encoding), named by construct, reused rather than reimplemented.
908
+
909
+ A constant whose name is not a bare word is written in single quotes in
910
+ ``unicode`` (``P('k2')``, ``Q('John Doe')``), and that text goes back into
911
+ every tool. ``latex`` writes a constant by its name, never in quotes, so
912
+ the LaTeX text of such a formula does not read back as that constant
913
+ (``P(k2)`` is the variable ``k2``), and the LaTeX reader refuses a quote:
914
+ pass the ``unicode`` text on. A target that cannot spell a name (TPTP and
915
+ Prover9 for ``John Doe``) refuses it as a structured error.
916
+
917
+ ``tptp`` also refuses a formula in which two DISTINCT names of one kind
918
+ would be written as the same TPTP word (the constants ``θ`` and ``theta``,
919
+ or ``gaseous`` and ``Gaseous`` read from TPTP text: both fold to one
920
+ identifier, which would turn ``P(a) <-> P(b)`` into a tautology). The error
921
+ names both symbols and the shared word; rename one of them and render
922
+ again. It checks the one formula it renders — to build a problem from
923
+ several formulas use ``unicode_logic_kit.atp.generate_tptp_problem_with_mapping``,
924
+ which checks them together.
925
+ """
926
+ node, err = _parse(text, dialect)
927
+ if err is not None:
928
+ return err
929
+ try:
930
+ if to == "unicode":
931
+ rendered = node.to_unicode_str()
932
+ elif to == "tptp":
933
+ rendered = node.to_tptp()
934
+ elif to == "prover9":
935
+ rendered = node.to_prover9()
936
+ elif to == "latex":
937
+ rendered = node.to_latex()
938
+ elif to == "smtlib":
939
+ rendered = node.to_smtlib()
940
+ elif to == "casl":
941
+ from ..fol.casl_export import formula_to_casl
942
+ rendered = formula_to_casl(node)
943
+ elif to == "json":
944
+ from ..fol.serialize import serialize
945
+ rendered = serialize(node)
946
+ elif to == "english":
947
+ from ..fol import to_english
948
+ rendered = to_english(node)
949
+ else:
950
+ return _error(ValueError(
951
+ f"render: unknown target {to!r} (one of ['casl', 'english', "
952
+ f"'json', 'latex', 'prover9', 'smtlib', 'tptp', 'unicode'])"))
953
+ except (ValueError, TypeError, NotImplementedError) as exc:
954
+ return _error(exc)
955
+ return {"ok": True, "to": to, "rendered": rendered}
956
+
957
+
958
+ @_answers_deep_input
959
+ def detect_dialect(text: str) -> dict:
960
+ """What syntax does this text look like, and what does it parse as?
961
+
962
+ ``candidates`` is the detector's ordered nomination list (always ending
963
+ in ``"unicode"``, the mode-ladder catch-all); ``parsed_as`` is the
964
+ dialect that actually accepted the text (``None`` if nothing did, with
965
+ every attempt's diagnostic in ``errors``).
966
+ """
967
+ from ..fol.dialect_detect import detect_dialects
968
+
969
+ parsed = api.parse_any(text)
970
+ return {"ok": parsed.ok,
971
+ "candidates": list(detect_dialects(text)),
972
+ "parsed_as": parsed.dialect if parsed.ok else None,
973
+ "errors": [] if parsed.ok else parsed.to_dict()["errors"]}
974
+
975
+
976
+ def _vocabulary_diff(report_a, report_b) -> dict:
977
+ """Per-namespace symbol diff between two ValidationReports."""
978
+ diff = {}
979
+ for section in ("predicates", "functions", "constants"):
980
+ a = set(getattr(report_a, section))
981
+ b = set(getattr(report_b, section))
982
+ diff[section] = {"only_in_predicted": sorted(a - b),
983
+ "only_in_gold": sorted(b - a),
984
+ "shared": sorted(a & b)}
985
+ return diff
986
+
987
+
988
+ @_answers_deep_input
989
+ def compare_formulas(predicted: str, gold: str, timeout_ms: int = 10000,
990
+ dialect: Optional[str] = None,
991
+ converses: Optional[List[dict]] = None) -> dict:
992
+ """The full prediction-vs-gold error-analysis breakdown for one pair.
993
+
994
+ Layers, strictest first: ``structural_equal`` (raw AST equality),
995
+ ``canonical_exact_match`` (alpha-renaming, commutativity/associativity,
996
+ duplication, double negation quotiented out),
997
+ ``aligned_exact_match`` (canonical match after Levenshtein-guided,
998
+ namespace- and arity-aware symbol renaming; ``aligned_predicted`` shows
999
+ the renamed prediction), ``equivalence`` (the graded ladder's full
1000
+ verdict dict, solver level included). ``vocabulary`` lists the symbols
1001
+ (``Name/arity`` for predicates/functions) each side uses and the other
1002
+ does not — the usual first stop when a match fails.
1003
+
1004
+ ``converses`` OPTIONALLY declares argument-permutation bridging axioms
1005
+ — e.g. ``LovedBy(x, y) ↔ Loves(y, x)`` — honoured ONLY by the solver
1006
+ level inside ``equivalence`` (see
1007
+ :func:`unicode_logic_kit.eval.equivalence.equivalent`'s ``converses``
1008
+ parameter and :mod:`unicode_logic_kit.eval.converses`); each entry is
1009
+ ``{"a": [name, arity], "b": [name, arity], "permutation": [...]}``, e.g.
1010
+ ``{"a": ["LovedBy", 2], "b": ["Loves", 2], "permutation": [1, 0]}``. A
1011
+ malformed entry (missing key, wrong shape) comes back as the top-level
1012
+ ``{"error": {...}}`` shape; a structurally invalid declaration (bad
1013
+ arity/permutation, self-pair, …) instead lands inside
1014
+ ``equivalence.error`` — the same place any other ``ValueError`` from the
1015
+ equivalence call surfaces — since it is only caught once the axioms are
1016
+ actually built. A modal ``predicted``/``gold`` pair with non-empty
1017
+ ``converses`` also lands in ``equivalence.error`` (``equivalent()``
1018
+ raises ``NotImplementedError`` there — no modal bridging route exists —
1019
+ which this tool catches alongside ``ValueError``, never lets escape as a
1020
+ raw exception). ``converse_axioms_applied`` lists the axioms that were
1021
+ actually built (unicode-rendered, e.g.
1022
+ ``"∀v0 ∀v1 (LovedBy(v0, v1) ↔ Loves(v1, v0))"``), or is ``None`` when no
1023
+ ``converses`` were given.
1024
+ """
1025
+ from ..eval import (exact_match, align_symbols, aligned_exact_match)
1026
+ from ..eval.validate import validate
1027
+
1028
+ pred, err = _parse(predicted, dialect, argument="predicted")
1029
+ if err is not None:
1030
+ return err
1031
+ ref, err = _parse(gold, dialect, argument="gold")
1032
+ if err is not None:
1033
+ return err
1034
+
1035
+ converses_tuples = None
1036
+ if converses:
1037
+ try:
1038
+ converses_tuples = _normalize_converses(converses)
1039
+ except ValueError as exc:
1040
+ return _error(exc)
1041
+
1042
+ try:
1043
+ aligned = align_symbols(pred, ref)
1044
+ aligned_unicode = aligned.to_unicode_str()
1045
+ aligned_ok = aligned_exact_match(pred, ref)
1046
+ except (ValueError, NotImplementedError):
1047
+ aligned_unicode = None # family without alignment support
1048
+ aligned_ok = None
1049
+
1050
+ axioms_applied = None
1051
+ try:
1052
+ if converses_tuples:
1053
+ from ..eval.converses import converse_axioms
1054
+ axioms_applied = [ax.to_unicode_str()
1055
+ for ax in converse_axioms(converses_tuples)]
1056
+ equivalence = api.equivalent(pred, ref, timeout=timeout_ms,
1057
+ converses=converses_tuples).to_dict()
1058
+ except (ValueError, NotImplementedError) as exc:
1059
+ # NotImplementedError: a modal pair with a non-empty `converses` --
1060
+ # equivalent() raises that deliberately (no modal bridging route
1061
+ # exists), and it must land in the same structured `equivalence.error`
1062
+ # shape as a ValueError, not escape as a raw exception over the wire.
1063
+ equivalence = {"error": str(exc)}
1064
+ return {
1065
+ "ok": True,
1066
+ "structural_equal": pred == ref,
1067
+ "canonical_exact_match": exact_match(pred, ref),
1068
+ "aligned_exact_match": aligned_ok,
1069
+ "aligned_predicted": aligned_unicode,
1070
+ "equivalence": equivalence,
1071
+ "vocabulary": _vocabulary_diff(validate(pred), validate(ref)),
1072
+ "converse_axioms_applied": axioms_applied,
1073
+ }
1074
+
1075
+
1076
+ @_answers_deep_input
1077
+ def score_batch(predictions: List[str], references: List[str],
1078
+ method: str = "auto", timeout_ms: int = 10000,
1079
+ converses: Optional[List[dict]] = None) -> dict:
1080
+ """Corpus-level NL->FOL metrics over aligned prediction/reference lists.
1081
+
1082
+ The six-key dict of ``eval.compute_fol_metrics``: ``exact_match``,
1083
+ ``equivalence_accuracy`` (honest — undecided pairs are NOT counted as
1084
+ refuted), ``mean_partial_credit``, ``parse_failure_rate``,
1085
+ ``solver_unknown_rate``, ``n``. ``converses`` (same JSON shape as
1086
+ ``compare_formulas``'s own parameter — see its docstring) is forwarded
1087
+ to every pair; when non-empty the dict gains a seventh key,
1088
+ ``converse_matched_rate`` — the fraction of pairs the solver proved
1089
+ equivalent USING the declared axioms, separately visible from (and
1090
+ subtractable out of) ``equivalence_accuracy``. A malformed or
1091
+ structurally invalid ``converses`` declaration, or a modal pair in the
1092
+ batch hit with non-empty ``converses`` (``NotImplementedError`` — no
1093
+ modal bridging route exists), surfaces as the top-level ``{"error":
1094
+ {...}}`` shape rather than escaping as a raw exception.
1095
+ """
1096
+ from ..eval import compute_fol_metrics
1097
+
1098
+ try:
1099
+ converses_tuples = _normalize_converses(converses) if converses else None
1100
+ return {"ok": True,
1101
+ **compute_fol_metrics(predictions, references,
1102
+ method=method, timeout_ms=timeout_ms,
1103
+ converses=converses_tuples)}
1104
+ except (ValueError, NotImplementedError) as exc:
1105
+ # NotImplementedError: same modal+converses case compare_formulas
1106
+ # guards against above -- must return the tool's structured error
1107
+ # shape (_error), never escape as a raw exception over the wire.
1108
+ return _error(exc)
1109
+
1110
+
1111
+ @_answers_deep_input
1112
+ def check_consistency(formulas: List[str], logic: str = "auto",
1113
+ timeout_ms: int = 10000,
1114
+ dialect: Optional[str] = None) -> dict:
1115
+ """Is this SET of formulas jointly satisfiable?
1116
+
1117
+ Encoding: a fresh nullary atom ``q`` (guaranteed absent from the input
1118
+ vocabulary) gives the contradiction ``q ∧ ¬q``; classically (and under
1119
+ the kit's local-consequence modal reading) the set is unsatisfiable iff
1120
+ it entails that contradiction, and a countermodel to that entailment IS
1121
+ a model of the set. Verdicts: ``consistent=True`` carries the model
1122
+ witness + English gloss (``method="model"``), ``consistent=False``
1123
+ carries the refutation verdict (``method="refutation"``),
1124
+ ``consistent=None`` means both searches came back empty within the
1125
+ budgets (``method="inconclusive"`` — never a claim either way).
1126
+ """
1127
+ from ..fol.nodes import Atom, Not, And as _And
1128
+ from ..fol._identifiers import symbol_names
1129
+
1130
+ parsed = []
1131
+ for i, f in enumerate(formulas or []):
1132
+ node, err = _parse(f, dialect, argument=f"formula[{i}]")
1133
+ if err is not None:
1134
+ return err
1135
+ parsed.append(node)
1136
+
1137
+ # Fresh against EVERY name of the problem, of every kind (a predicate, a
1138
+ # constant, a sort, ...): a backend may keep them in one namespace.
1139
+ used = symbol_names(*parsed)
1140
+ fresh = "ufk_absurd"
1141
+ while fresh in used:
1142
+ fresh += "_"
1143
+ contradiction = _And(Atom(fresh, ()), Not(Atom(fresh, ())))
1144
+
1145
+ try:
1146
+ witness = api.countermodel(contradiction, parsed, logic=logic,
1147
+ timeout=timeout_ms)
1148
+ if witness.found:
1149
+ return {"ok": True, "consistent": True, "method": "model",
1150
+ "model": witness.model, "backend": witness.backend,
1151
+ "explanation_nl": witness.explanation_nl}
1152
+ verdict = api.prove(contradiction, parsed, logic=logic,
1153
+ timeout=timeout_ms)
1154
+ except (BackendUnavailable, ValueError) as exc:
1155
+ return _error(exc)
1156
+ if verdict.status == PROVED:
1157
+ return {"ok": True, "consistent": False, "method": "refutation",
1158
+ "verdict": verdict.to_dict()}
1159
+ return {"ok": True, "consistent": None, "method": "inconclusive",
1160
+ "verdict": verdict.to_dict()}
1161
+
1162
+
1163
+ @_answers_deep_input
1164
+ def get_signature(formulas: List[str],
1165
+ dialect: Optional[str] = None) -> dict:
1166
+ """Extract the inferred vocabulary (Signature) of a formula set.
1167
+
1168
+ The result dict is ``fol.Signature.from_formulas(...)``'s rich form —
1169
+ predicates/functions with arities and inferred sorts, constants, sort
1170
+ names — ready to pass back as ``check_formula``'s / ``diagnose``'s
1171
+ ``signature`` argument to hold FURTHER generations to this vocabulary:
1172
+ the whole result, ``{"ok": True, "signature": {...}}``, or just its
1173
+ ``signature`` value are both accepted.
1174
+ """
1175
+ from ..fol.signature import Signature
1176
+
1177
+ parsed = []
1178
+ for i, f in enumerate(formulas or []):
1179
+ node, err = _parse(f, dialect, argument=f"formula[{i}]")
1180
+ if err is not None:
1181
+ return err
1182
+ parsed.append(node)
1183
+ try:
1184
+ return {"ok": True,
1185
+ "signature": Signature.from_formulas(parsed).to_dict()}
1186
+ except (ValueError, NotImplementedError) as exc:
1187
+ return _error(exc)
1188
+
1189
+
1190
+ # Enumerating value_count**atom_count rows must not melt the transport: the
1191
+ # cap bounds the ROW COUNT (4096 = 12 classical or ~7 three-valued atoms).
1192
+ _TRUTH_TABLE_MAX_ROWS = 4096
1193
+
1194
+
1195
+ @_answers_deep_input
1196
+ def truth_table(text: str, logic: str = "classical",
1197
+ dialect: Optional[str] = None) -> dict:
1198
+ """Decide a propositional formula by full enumeration.
1199
+
1200
+ ``logic``: ``classical`` (values {0,1}), ``K3`` (strong Kleene) or
1201
+ ``LP`` (Priest, paraconsistent designation). Quantified formulas and
1202
+ tables beyond 4096 rows are refused as structured errors. ``rows``
1203
+ aligns each assignment with ``atoms``; ``markdown`` is the rendered
1204
+ table for direct display.
1205
+ """
1206
+ from ..semantics.truthtable import (
1207
+ truth_table as _truth_table, _collect_atoms, _VALUES)
1208
+
1209
+ node, err = _parse(text, dialect)
1210
+ if err is not None:
1211
+ return err
1212
+ if logic not in _VALUES:
1213
+ return _error(ValueError(
1214
+ f"truth_table: unknown logic {logic!r} "
1215
+ f"(one of {sorted(_VALUES)})"))
1216
+ try:
1217
+ atoms = _collect_atoms(node) # rejects quantified formulas
1218
+ except ValueError as exc:
1219
+ return _error(exc)
1220
+ n_rows = len(_VALUES[logic]) ** len(atoms)
1221
+ if n_rows > _TRUTH_TABLE_MAX_ROWS:
1222
+ return _error(ValueError(
1223
+ f"truth_table: {len(atoms)} atoms give {n_rows} rows under "
1224
+ f"{logic} (cap {_TRUTH_TABLE_MAX_ROWS}); use prove or "
1225
+ f"find_countermodel instead"))
1226
+ try:
1227
+ # _collect_atoms only rejects QUANTIFIERS; a modal/temporal/fuzzy/
1228
+ # lambda node walks through it and only the evaluator refuses it
1229
+ # (NotImplementedError/TypeError from the Kleene tables) — that
1230
+ # refusal is part of the documented contract and must surface as a
1231
+ # structured error, not a traceback (review-hardened).
1232
+ tt = _truth_table(node, logic=logic)
1233
+ except (ValueError, TypeError, NotImplementedError) as exc:
1234
+ return _error(exc)
1235
+ return {"ok": True, "logic": tt.logic, "atoms": list(tt.atoms),
1236
+ "rows": [{"assignment": list(assignment), "value": value,
1237
+ "designated": designated}
1238
+ for assignment, value, designated in tt.rows],
1239
+ "is_tautology": tt.is_tautology,
1240
+ "is_contradiction": tt.is_contradiction,
1241
+ "is_satisfiable": tt.is_satisfiable,
1242
+ "markdown": tt.render()}
1243
+
1244
+
1245
+ @_answers_deep_input
1246
+ def drs_to_fol(text: str, format: str = "box",
1247
+ resolve_pronouns: bool = False) -> dict:
1248
+ """Translate a discourse representation structure into provable FOL.
1249
+
1250
+ ``format="box"`` parses the compact box notation
1251
+ (``[x, y | Farmer(x), Donkey(y), Owns(x, y)] -> [ | Beats(x, y)]``),
1252
+ ``format="sbn"`` the documented Parallel-Meaning-Bank SBN subset.
1253
+ ``resolve_pronouns=True`` runs accessibility-respecting anaphora
1254
+ resolution first (PRONOUN-marked referents; ambiguity/no-candidate
1255
+ failures surface as structured errors) and reports each resolution.
1256
+ The result is the standard translation — donkey-sentence universals
1257
+ come out right — as a formula ``prove``/``check_formula`` accept.
1258
+ """
1259
+ from .. import drt
1260
+
1261
+ if format == "box":
1262
+ parse, error_type = drt.parse_drs, drt.DRSSyntaxError
1263
+ elif format == "sbn":
1264
+ parse, error_type = drt.parse_sbn, drt.SBNSyntaxError
1265
+ else:
1266
+ return _error(ValueError(
1267
+ f"drs_to_fol: unknown format {format!r} (one of ['box', 'sbn'])"))
1268
+ try:
1269
+ box = parse(text)
1270
+ except (error_type, ValueError) as exc:
1271
+ return {"ok": False, "argument": "text",
1272
+ "errors": [{"dialect": f"drs_{format}", "message": str(exc)}]}
1273
+
1274
+ resolutions = None
1275
+ if resolve_pronouns:
1276
+ try:
1277
+ report = drt.resolve_anaphora(box)
1278
+ except ValueError as exc:
1279
+ return _error(exc)
1280
+ box = report.drs
1281
+ resolutions = [r.to_dict() for r in report.resolutions]
1282
+ try:
1283
+ node = drt.drs_to_fol(box)
1284
+ except (ValueError, NotImplementedError) as exc:
1285
+ return _error(exc)
1286
+ result = {"ok": True, "unicode": node.to_unicode_str(),
1287
+ "formula": node.to_dict()}
1288
+ if resolutions is not None:
1289
+ result["resolutions"] = resolutions
1290
+ return result
1291
+
1292
+
1293
+ def _exact_fraction(value, where: str):
1294
+ """Coerce a JSON-transported probability to an exact Fraction.
1295
+
1296
+ ints and strings go straight to ``Fraction`` (``"7/10"`` and ``"0.7"``
1297
+ are both exact); a float is read through its shortest-repr DECIMAL
1298
+ (``0.7`` → ``Fraction("0.7")`` = 7/10 — what the JSON author wrote, not
1299
+ the binary artefact ``Fraction(0.7)`` would preserve). The prob layer
1300
+ itself refuses floats outright; this adapter exists because JSON has no
1301
+ rational type. Booleans are refused (bool is an int subclass in Python,
1302
+ but a JSON ``true`` is not a probability — silently reading it as 1
1303
+ would be the quiet coercion this kit never does). Raises ValueError
1304
+ with ``where`` on everything else.
1305
+ """
1306
+ from fractions import Fraction
1307
+
1308
+ try:
1309
+ if isinstance(value, float):
1310
+ return Fraction(repr(value))
1311
+ if isinstance(value, (int, str)) and not isinstance(value, bool):
1312
+ return Fraction(value)
1313
+ except (ValueError, ZeroDivisionError) as exc:
1314
+ raise ValueError(f"{where}: not a probability: {value!r} ({exc})")
1315
+ raise ValueError(f"{where}: expected int, string or number, got "
1316
+ f"{type(value).__name__}")
1317
+
1318
+
1319
+ @_answers_deep_input
1320
+ def probability_bounds(conclusion: str, constraints: List[dict],
1321
+ max_atoms: int = 12,
1322
+ dialect: Optional[str] = None,
1323
+ strategy: str = "direct",
1324
+ max_columns: int = 500) -> dict:
1325
+ """Nilsson-style probabilistic entailment: tightest bounds on P(conclusion).
1326
+
1327
+ Each constraint dict: ``{"formula": <text>}`` plus either
1328
+ ``"probability"`` (pins the value exactly) or ``"lower"``/``"upper"``
1329
+ (interval; missing ends default to 0/1), plus optional ``"given"``
1330
+ (formula text — makes it the conditional ``P(formula | given)``).
1331
+ Probabilities travel as ``"7/10"`` / ``"0.7"`` strings, ints, or JSON
1332
+ numbers (read decimally). Propositional only; the answer is the EXACT
1333
+ ``[lower, upper]`` interval (fraction strings, with float shadows for
1334
+ convenience) entailed by the constraint polytope — ``lower == upper ==
1335
+ 1`` is classical entailment as a corner case.
1336
+
1337
+ ``strategy`` picks the solving ALGORITHM, never the semantics (see
1338
+ :func:`unicode_logic_kit.prob.nilsson.entailment_bounds`): ``"direct"``
1339
+ (the default, unchanged) enumerates all ``2^n`` worlds and is capped
1340
+ by ``max_atoms`` (12 by default); ``"column_generation"`` never
1341
+ materialises that many worlds, so it can go past ``max_atoms`` —
1342
+ its own brake is ``max_columns`` (500 by default, raising a
1343
+ structured error rather than ever returning an unproven bound).
1344
+ Both strategies solve the identical linear program and agree
1345
+ exactly (never a tolerance) wherever both can answer.
1346
+ """
1347
+ from ..prob import ProbConstraint, entailment_bounds
1348
+
1349
+ if strategy not in ("direct", "column_generation"):
1350
+ return _error(ValueError(
1351
+ f"probability_bounds: unknown strategy {strategy!r} "
1352
+ "(one of ['direct', 'column_generation'])"))
1353
+
1354
+ node, err = _parse(conclusion, dialect, argument="conclusion")
1355
+ if err is not None:
1356
+ return err
1357
+ parsed = []
1358
+ try:
1359
+ for i, c in enumerate(constraints or []):
1360
+ if "formula" not in c:
1361
+ return _error(ValueError(
1362
+ f"constraints[{i}]: missing the 'formula' key"))
1363
+ fnode, ferr = _parse(c["formula"], dialect,
1364
+ argument=f"constraints[{i}].formula")
1365
+ if ferr is not None:
1366
+ return ferr
1367
+ given = None
1368
+ if c.get("given") is not None:
1369
+ given, gerr = _parse(c["given"], dialect,
1370
+ argument=f"constraints[{i}].given")
1371
+ if gerr is not None:
1372
+ return gerr
1373
+ if "probability" in c:
1374
+ lo = hi = _exact_fraction(c["probability"],
1375
+ f"constraints[{i}].probability")
1376
+ else:
1377
+ lo = _exact_fraction(c.get("lower", 0),
1378
+ f"constraints[{i}].lower")
1379
+ hi = _exact_fraction(c.get("upper", 1),
1380
+ f"constraints[{i}].upper")
1381
+ parsed.append(ProbConstraint(fnode, lo, hi, given))
1382
+ bounds = entailment_bounds(parsed, node, max_atoms=max_atoms,
1383
+ strategy=strategy,
1384
+ max_columns=max_columns)
1385
+ except (ValueError, TypeError) as exc:
1386
+ return _error(exc)
1387
+ return {"ok": True, **bounds.to_dict(),
1388
+ "lower_float": float(bounds.lower),
1389
+ "upper_float": float(bounds.upper)}
1390
+
1391
+
1392
+ @_answers_deep_input
1393
+ def probability_query(goal: str, facts: List[dict],
1394
+ rules: Optional[List[str]] = None,
1395
+ hard_facts: Optional[List[str]] = None,
1396
+ max_choice_facts: int = 16,
1397
+ dialect: Optional[str] = None) -> dict:
1398
+ """Exact ProbLog-style query under Sato's distribution semantics.
1399
+
1400
+ ``facts``: ``{"atom": <ground atom text>, "prob": <"3/10" | 0.3 | …>}``
1401
+ — independent Bernoulli facts. ``rules``/``hard_facts``: definite
1402
+ clauses (a ground atom, or ``∀``-quantified ``body → head`` with a
1403
+ positive conjunctive body and a single positive head atom — anything
1404
+ else is refused loudly). The goal may use ∧/∨/¬ and ∀/∃ over the
1405
+ program's finite constants; negation reads closed-world against each
1406
+ total choice's least model (the ProbLog convention). The result is the
1407
+ exact probability as a fraction string (float shadow included).
1408
+ """
1409
+ from ..prob import ProbFact, ProbProgram, query as _prob_query
1410
+
1411
+ gnode, err = _parse(goal, dialect, argument="goal")
1412
+ if err is not None:
1413
+ return err
1414
+ try:
1415
+ prob_facts = []
1416
+ for i, f in enumerate(facts or []):
1417
+ if "atom" not in f or "prob" not in f:
1418
+ return _error(ValueError(
1419
+ f"facts[{i}]: needs both 'atom' and 'prob' keys"))
1420
+ anode, aerr = _parse(f["atom"], dialect,
1421
+ argument=f"facts[{i}].atom")
1422
+ if aerr is not None:
1423
+ return aerr
1424
+ prob_facts.append(ProbFact(
1425
+ anode, _exact_fraction(f["prob"], f"facts[{i}].prob")))
1426
+ rule_nodes = []
1427
+ for i, r in enumerate(rules or []):
1428
+ rnode, rerr = _parse(r, dialect, argument=f"rules[{i}]")
1429
+ if rerr is not None:
1430
+ return rerr
1431
+ rule_nodes.append(rnode)
1432
+ hard_nodes = []
1433
+ for i, h in enumerate(hard_facts or []):
1434
+ hnode, herr = _parse(h, dialect, argument=f"hard_facts[{i}]")
1435
+ if herr is not None:
1436
+ return herr
1437
+ hard_nodes.append(hnode)
1438
+ program = ProbProgram(prob_facts, rule_nodes, hard_nodes)
1439
+ p = _prob_query(program, gnode, max_choice_facts=max_choice_facts)
1440
+ except (ValueError, TypeError) as exc:
1441
+ return _error(exc)
1442
+ return {"ok": True, "probability": str(p), "probability_float": float(p)}
1443
+
1444
+
1445
+ @_answers_deep_input
1446
+ def get_syntax_spec(topic: str = "overview",
1447
+ dialect: Optional[str] = None) -> dict:
1448
+ """Retrieve the kit's syntax specification — look up the rule you broke.
1449
+
1450
+ Topics: ``overview`` (dialects and the mistake everyone makes), ``naming``
1451
+ (what makes a symbol a variable / constant / predicate / function, and how
1452
+ TPTP inverts the convention), ``dialects``, ``operators`` (precedence
1453
+ table), ``quantifiers`` (scope rules), ``counting`` (the cardinality
1454
+ quantifier and why it replaces long existential chains), ``chemistry``
1455
+ (molecule-as-structure signature), ``description-logic`` (the ALC glyph
1456
+ and OWL Manchester concept syntaxes the ``dl_*`` tools accept, plus their
1457
+ TBox/ABox JSON row shapes), ``errors`` (measured LLM failure modes,
1458
+ each with a fix and the topic that explains it).
1459
+
1460
+ Every parse failure this server returns carries a ``spec_topic`` naming
1461
+ the topic to fetch before retrying, so a generate → fail → look up → fix
1462
+ loop needs no grammar in the prompt. Every example served here is parsed
1463
+ and rendering-checked by the kit's own test suite, so the spec cannot
1464
+ drift from the parser.
1465
+ """
1466
+ from .syntax_spec import syntax_spec
1467
+
1468
+ try:
1469
+ return {"ok": True, **syntax_spec(topic, dialect)}
1470
+ except ValueError as exc:
1471
+ return _error(exc)
1472
+
1473
+
1474
+ @_answers_deep_input
1475
+ def list_translations() -> dict:
1476
+ """Enumerate the logic-to-logic edges the translate tool can follow.
1477
+
1478
+ The kit's own comorphism registry (BFS-composable); after a Hets bridge
1479
+ refresh the dynamic ``hets:<Name>`` edges appear here too. ``logics`` is
1480
+ every label ``translate`` accepts as ``from_logic`` / ``to_logic``.
1481
+ ``lossy`` edges do not preserve the full source semantics; ``note``
1482
+ carries the conventions a consumer must know. ``guarantee`` is what the
1483
+ edge preserves (``faithful`` / ``validity`` / ``satisfiability`` /
1484
+ ``lossy``, or ``null`` when the edge declares none — not the same as
1485
+ faithful). ``options`` are the ``translate`` parameters the edge reads
1486
+ (``frame``, ``systems``, ``temporal_closure``, ``signature``, ``mode``,
1487
+ ``bridges``), and ``side_axioms`` says whether the edge can return
1488
+ ``axioms`` that must be passed as separate premises.
1489
+ """
1490
+ from ..comorphism import DEFAULT_REGISTRY
1491
+
1492
+ edges = DEFAULT_REGISTRY.edges()
1493
+ return {"logics": sorted({label for e in edges
1494
+ for label in (e.source, e.target)}),
1495
+ "edges": [{"name": e.name, "source": e.source,
1496
+ "target": e.target, "lossy": e.lossy, "note": e.note,
1497
+ "guarantee": e.guarantee,
1498
+ "options": sorted(e.options),
1499
+ "side_axioms": e.axioms is not None}
1500
+ for e in edges]}
1501
+
1502
+
1503
+ # --------------------------------------------------------------------------
1504
+ # Description-logic (ALCHQ) reasoning tools — pure wiring over dl.tableau /
1505
+ # dl.classification, with input parsed by dl.parser (the ALC glyph syntax,
1506
+ # ``syntax="alc"``, default) or dl.owl_manchester (OWL 2 Manchester Syntax,
1507
+ # ``syntax="manchester"``). Conventions match every tool above: a bad
1508
+ # CONCEPT/AXIOM text is the uniform ``{"ok": False, "argument": ...,
1509
+ # "errors": [...], "spec_topic": "description-logic"}`` shape (whether the
1510
+ # grammar rejected it as :class:`~unicode_logic_kit.dl.ConceptSyntaxError` or
1511
+ # :class:`~unicode_logic_kit.dl.ManchesterSyntaxError` — including a
1512
+ # Manchester construct outside ALCHQ, e.g. ``Self``/``inverse``/a
1513
+ # nominal, which that parser rejects by NAME rather than a bare syntax
1514
+ # error; a ``value`` restriction (``hasChild value Doctor``) is READ, and is
1515
+ # then the tableau's refusal below, ``UnsupportedConceptError``, because it is
1516
+ # a nominal in disguise); a documented, non-text exception — every refusal class the ``dl``
1517
+ # package raises on purpose (:func:`_dl_errors`: a qualified number
1518
+ # restriction on a non-simple role, an axiom KIND / concept / datatype the
1519
+ # tableau does not decide, a malformed role) or the tableau's step-budget
1520
+ # ``RuntimeError`` — is ``{"error": {"type": ..., "message": ...}}``. A
1521
+ # :class:`Concept` has no ``to_dict()`` (see ``dl.concepts``), so results
1522
+ # carry it as ``*_unicode`` text (``Concept.to_unicode()``) rather than a
1523
+ # JSON AST, exactly like ``translate()``'s own ``alc`` branch above. A concept
1524
+ # one of whose names would make that text read back as ANOTHER concept (a class
1525
+ # named ``<A⊓B>``) has no faithful text: ``to_unicode()`` refuses it with a
1526
+ # ``ValueError``, and the tool answers with the same structured error
1527
+ # (:func:`_unicode_texts`) before it reasons, never with the exception.
1528
+ # --------------------------------------------------------------------------
1529
+
1530
+ def _check_dl_syntax(syntax: str):
1531
+ """Validate ``syntax`` against the two dialects DL tools understand.
1532
+
1533
+ ``None`` on success. Otherwise the structured ``{"error": {...}}`` shape
1534
+ (a caller/config mistake, not a bad concept TEXT). Callers whose
1535
+ row-building loops may run zero iterations (an empty/absent
1536
+ ``tbox``/``abox``, so :func:`_parse_dl` is never reached) MUST call this
1537
+ unconditionally up front, or an invalid ``syntax`` is silently accepted
1538
+ instead of refused.
1539
+ """
1540
+ if syntax in ("alc", "manchester"):
1541
+ return None
1542
+ return _error(ValueError(
1543
+ f"dl: unknown syntax {syntax!r} (one of ['alc', 'manchester'])"))
1544
+
1545
+
1546
+ def _dl_errors():
1547
+ """The exceptions a description-logic tool turns into an ``{"error": ...}``
1548
+ payload instead of letting them escape: the tableau's decidability refusal
1549
+ (a number restriction on a non-simple role), the NAMED refusals of a
1550
+ fragment it does not decide (an axiom kind, a concept, a datatype), the
1551
+ refusal of a malformed role (:class:`~unicode_logic_kit.dl.RoleExpressionError`,
1552
+ which the role builders raise and which a query-time check of a role name
1553
+ raises too) and the ``RuntimeError`` of an exhausted resource budget.
1554
+
1555
+ ONE place, shared by every ``dl_*`` reasoning tool: a refusal class the
1556
+ ``dl`` package gains is added HERE and is then reported by all of them.
1557
+ ``tests/test_mcp_server.py`` classifies every exception class the package
1558
+ defines against this tuple, so a class added there and forgotten here fails
1559
+ that test instead of reaching a caller as a bare exception.
1560
+
1561
+ The syntax errors of the two readers (``ConceptSyntaxError``,
1562
+ ``ManchesterSyntaxError``) are deliberately NOT here: they are text
1563
+ mistakes, reported by :func:`_parse_dl` in the uniform ``ok=False`` shape.
1564
+ ``dl`` is imported here, not at module level, like everywhere else in this
1565
+ file."""
1566
+ from .. import dl
1567
+
1568
+ return (dl.NonSimpleRoleError, dl.UnsupportedAxiomError,
1569
+ dl.UnsupportedConceptError, dl.UnsupportedDatatypeError,
1570
+ dl.RoleExpressionError, RuntimeError)
1571
+
1572
+
1573
+ def _dl_datatype_names(rows) -> List[str]:
1574
+ """The names a ``tbox`` row list DEFINES as datatypes (its ``{"datatype":
1575
+ name, "definition": ...}`` rows), in order.
1576
+
1577
+ A user-defined datatype is an ordinary name, so the Manchester reader can
1578
+ tell ``d some Digit`` (data) from ``r some Dog`` (object) only if it is
1579
+ told ``Digit`` is a datatype; every tool that reads concept text next to a
1580
+ ``tbox`` passes these names on. A built-in datatype (``xsd:integer``) needs
1581
+ no listing."""
1582
+ return [row["datatype"] for row in rows or []
1583
+ if isinstance(row, dict) and isinstance(row.get("datatype"), str)]
1584
+
1585
+
1586
+ def _parse_dl(text: str, syntax: str, argument: str, datatypes=()):
1587
+ """Parse concept TEXT into a :class:`~unicode_logic_kit.dl.Concept`.
1588
+
1589
+ ``syntax="alc"`` (default) reads the glyph syntax via ``dl.parse_concept``;
1590
+ ``syntax="manchester"`` reads OWL 2 Manchester Syntax via
1591
+ ``dl.parse_manchester``. ``(concept, None)`` on success, ``(None,
1592
+ error_dict)`` on failure — see this section's own header comment for the
1593
+ two error shapes. ``datatypes``: the user-defined datatype names (see
1594
+ :func:`_dl_datatype_names`), used by the Manchester reader only — the glyph
1595
+ syntax has no data layer, so it cannot say a data restriction at all.
1596
+ """
1597
+ from .. import dl
1598
+
1599
+ if not isinstance(text, str):
1600
+ # A malformed row shape, like every other non-str below: reported, not
1601
+ # raised (a JSON number in a concept slot used to escape as a TypeError
1602
+ # from inside the reader).
1603
+ return None, _error(ValueError(
1604
+ f"{argument}: expected concept text (str), got {type(text).__name__}"))
1605
+ if syntax == "alc":
1606
+ try:
1607
+ return dl.parse_concept(text), None
1608
+ except dl.ConceptSyntaxError as exc:
1609
+ return None, {"ok": False, "argument": argument,
1610
+ "errors": [{"dialect": "alc", "message": str(exc)}],
1611
+ "spec_topic": "description-logic"}
1612
+ if syntax == "manchester":
1613
+ try:
1614
+ return dl.parse_manchester(text, datatypes=datatypes), None
1615
+ except dl.ManchesterSyntaxError as exc:
1616
+ return None, {"ok": False, "argument": argument,
1617
+ "errors": [{"dialect": "manchester", "message": str(exc)}],
1618
+ "spec_topic": "description-logic"}
1619
+ return None, _check_dl_syntax(syntax)
1620
+
1621
+
1622
+ def _parse_dl_data_range(text, argument: str, datatypes=()):
1623
+ """Parse DATA RANGE text (always Manchester: the glyph syntax has no data
1624
+ layer) into a :class:`~unicode_logic_kit.dl.datatypes.DataRange`.
1625
+ ``(range, None)`` / ``(None, error_dict)`` like :func:`_parse_dl`."""
1626
+ from .. import dl
1627
+
1628
+ if not isinstance(text, str):
1629
+ return None, _error(ValueError(
1630
+ f"{argument}: expected data range text (str), got {type(text).__name__}"))
1631
+ try:
1632
+ return dl.parse_manchester_data_range(text, datatypes=datatypes), None
1633
+ except dl.ManchesterSyntaxError as exc:
1634
+ return None, {"ok": False, "argument": argument,
1635
+ "errors": [{"dialect": "manchester", "message": str(exc)}],
1636
+ "spec_topic": "description-logic"}
1637
+
1638
+
1639
+ def _parse_dl_literal(text, argument: str):
1640
+ """Parse one LITERAL text (``"400"^^xsd:integer``, ``"abc"@en``, a bare
1641
+ numeral) into a :class:`~unicode_logic_kit.dl.datatypes.Literal`.
1642
+ ``(literal, None)`` / ``(None, error_dict)`` like :func:`_parse_dl`."""
1643
+ from .. import dl
1644
+
1645
+ if not isinstance(text, str):
1646
+ return None, _error(ValueError(
1647
+ f"{argument}: expected literal text (str), got {type(text).__name__}"))
1648
+ try:
1649
+ return dl.parse_manchester_literal(text), None
1650
+ except dl.ManchesterSyntaxError as exc:
1651
+ return None, {"ok": False, "argument": argument,
1652
+ "errors": [{"dialect": "manchester", "message": str(exc)}],
1653
+ "spec_topic": "description-logic"}
1654
+
1655
+
1656
+ #: ``row key -> (TBox builder, how many roles the value holds)`` for the
1657
+ #: role-box row shapes that take ONE key. ``"one"`` is a single role name
1658
+ #: (every characteristic axiom), ``"many"`` a list of role names.
1659
+ _DL_ROLE_ROW_SHAPES = {
1660
+ "transitive": ("add_transitive_role", "one"),
1661
+ "symmetric": ("add_symmetric_role", "one"),
1662
+ "asymmetric": ("add_asymmetric_role", "one"),
1663
+ "reflexive": ("add_reflexive_role", "one"),
1664
+ "irreflexive": ("add_irreflexive_role", "one"),
1665
+ "functional": ("add_functional_role", "one"),
1666
+ "inversefunctional": ("add_inverse_functional_role", "one"),
1667
+ "inverseroles": ("add_inverse_roles", "many"),
1668
+ "disjointroles": ("add_disjoint_roles", "many"),
1669
+ "equivroles": ("add_equivalent_roles", "many"),
1670
+ }
1671
+
1672
+ #: ``row key -> (TBox builder, OWL keyword)`` for the two role-box axioms
1673
+ #: whose value is a ROLE plus a CLASS EXPRESSION: ``{"domainrole": r,
1674
+ #: "domain": <concept text>}`` and its ``range`` twin. A table of their own
1675
+ #: because the concept text has to go through :func:`_parse_dl` under the
1676
+ #: caller's ``syntax``, which no role-name row needs.
1677
+ _DL_FILLER_ROLE_ROW_SHAPES = {
1678
+ "domain": ("add_role_domain", "domainrole", "ObjectPropertyDomain"),
1679
+ "range": ("add_role_range", "rangerole", "ObjectPropertyRange"),
1680
+ }
1681
+
1682
+
1683
+ #: ``row key -> (TBox builder, how many property names the value holds)`` for
1684
+ #: the data-box row shapes that take ONE key of property names, the data twin of
1685
+ #: :data:`_DL_ROLE_ROW_SHAPES`.
1686
+ _DL_DATA_NAME_ROW_SHAPES = {
1687
+ "equivdata": ("add_equivalent_data_properties", "many"),
1688
+ "disjointdata": ("add_disjoint_data_properties", "many"),
1689
+ "functionaldata": ("add_functional_data_property", "one"),
1690
+ }
1691
+
1692
+ #: Every key that marks a row as a DATA-box row. Checked BEFORE the role rows:
1693
+ #: ``{"domaindata": d, "domain": text}`` carries the key ``"domain"``, which the
1694
+ #: role-domain branch would otherwise claim and reject for lacking ``domainrole``.
1695
+ _DL_DATA_ROW_KEYS = ("subdata", "domaindata", "rangedata", "datatype",
1696
+ *_DL_DATA_NAME_ROW_SHAPES)
1697
+
1698
+
1699
+ def _add_dl_data_row(tbox, row, i: int, syntax: str, argument: str, datatypes):
1700
+ """Add the data-box row ``row`` (the ``i``-th) to ``tbox``; ``None`` on
1701
+ success, an error dict on the first failure (same two shapes as
1702
+ :func:`_build_dl_tbox`)."""
1703
+ from .. import dl
1704
+
1705
+ where = f"{argument}[{i}]"
1706
+ names = [key for key in _DL_DATA_ROW_KEYS if key in row]
1707
+ if len(names) != 1:
1708
+ return _error(ValueError(
1709
+ f"{where}: a data-box row carries exactly one of {sorted(_DL_DATA_ROW_KEYS)}, "
1710
+ f"got {names}"))
1711
+ key = names[0]
1712
+ try:
1713
+ if key == "subdata":
1714
+ if "supdata" not in row:
1715
+ return _error(ValueError(
1716
+ f"{where}: a 'subdata' row needs 'supdata', got {sorted(row)}"))
1717
+ tbox.add_data_property_inclusion(row["subdata"], row["supdata"])
1718
+ elif key == "domaindata":
1719
+ if "domain" not in row:
1720
+ return _error(ValueError(
1721
+ f"{where}: a 'domaindata' row needs 'domain' (the class text), "
1722
+ f"got {sorted(row)}"))
1723
+ concept, err = _parse_dl(row["domain"], syntax, f"{where}.domain", datatypes)
1724
+ if err is not None:
1725
+ return err
1726
+ tbox.add_data_property_domain(row["domaindata"], concept)
1727
+ elif key == "rangedata":
1728
+ if "range" not in row:
1729
+ return _error(ValueError(
1730
+ f"{where}: a 'rangedata' row needs 'range' (the data range text), "
1731
+ f"got {sorted(row)}"))
1732
+ datarange, err = _parse_dl_data_range(row["range"], f"{where}.range", datatypes)
1733
+ if err is not None:
1734
+ return err
1735
+ tbox.add_data_property_range(row["rangedata"], datarange)
1736
+ elif key == "datatype":
1737
+ if "definition" not in row:
1738
+ return _error(ValueError(
1739
+ f"{where}: a 'datatype' row needs 'definition' (the data range "
1740
+ f"text), got {sorted(row)}"))
1741
+ if not isinstance(row["datatype"], str):
1742
+ return _error(ValueError(
1743
+ f"{where}.datatype: expected a datatype name (str), got "
1744
+ f"{row['datatype']!r}"))
1745
+ datarange, err = _parse_dl_data_range(
1746
+ row["definition"], f"{where}.definition", datatypes)
1747
+ if err is not None:
1748
+ return err
1749
+ tbox.add_datatype_definition(row["datatype"], datarange)
1750
+ else:
1751
+ builder, arity = _DL_DATA_NAME_ROW_SHAPES[key]
1752
+ value = row[key]
1753
+ if arity == "one":
1754
+ if not isinstance(value, str):
1755
+ return _error(ValueError(
1756
+ f"{where}.{key}: expected a data property name (str), "
1757
+ f"got {value!r}"))
1758
+ names_ = [value]
1759
+ else:
1760
+ if not (isinstance(value, list) and len(value) >= 2
1761
+ and all(isinstance(name, str) for name in value)):
1762
+ return _error(ValueError(
1763
+ f"{where}.{key}: expected a list of at least 2 data "
1764
+ f"property names, got {value!r}"))
1765
+ names_ = value
1766
+ getattr(tbox, builder)(*names_)
1767
+ except (dl.RoleExpressionError, dl.UnsupportedDatatypeError) as exc:
1768
+ return _error(exc)
1769
+ return None
1770
+
1771
+
1772
+ def _build_dl_tbox(rows, syntax: str, argument: str = "tbox"):
1773
+ """Build a :class:`~unicode_logic_kit.dl.TBox` from JSON row dicts.
1774
+
1775
+ Each row is one of ``TBox``'s axiom shapes. The concept-level two:
1776
+
1777
+ * ``{"sub": <text>, "sup": <text>}`` — a general concept inclusion
1778
+ (``TBox.add``);
1779
+ * ``{"equiv": [<text>, <text>]}`` — an equivalence (``TBox.add_equivalence``).
1780
+
1781
+ The role box (see "Role hierarchies and transitive roles (RBox)" and "The
1782
+ rest of the OWL 2 role box" in :mod:`unicode_logic_kit.dl.tableau`'s module
1783
+ docstring; the role values are NAMES, never concept text, so ``syntax``
1784
+ does not apply to them):
1785
+
1786
+ * ``{"subrole": <role>, "suprole": <role>}`` — a role inclusion;
1787
+ * ``{"chain": [<role>, …], "suprole": <role>}`` — a property chain
1788
+ (``TBox.add_role_chain``), checked BEFORE ``subrole`` so the two
1789
+ ``suprole`` shapes cannot be confused;
1790
+ * ``{"inverseroles": [p, q]}``, ``{"disjointroles": [p, q, …]}``,
1791
+ ``{"equivroles": [p, q, …]}`` — the n-ary role axioms;
1792
+ * ``{"transitive": <role>}`` and its six siblings ``symmetric``,
1793
+ ``asymmetric``, ``reflexive``, ``irreflexive``, ``functional``,
1794
+ ``inversefunctional`` — the characteristic axioms;
1795
+ * ``{"domainrole": <role>, "domain": <text>}`` and
1796
+ ``{"rangerole": <role>, "range": <text>}`` — the two role-box axioms
1797
+ whose right-hand side is a CLASS EXPRESSION (``TBox.add_role_domain`` /
1798
+ ``add_role_range``), so that text IS parsed under ``syntax``.
1799
+
1800
+ The data box (see "The data layer" in :mod:`unicode_logic_kit.dl.translate`'s
1801
+ module docstring; property and datatype values are NAMES, and the data range
1802
+ text is ALWAYS OWL 2 Manchester syntax, since the glyph syntax has no data
1803
+ layer):
1804
+
1805
+ * ``{"subdata": <prop>, "supdata": <prop>}`` — ``SubDataPropertyOf``;
1806
+ * ``{"equivdata": [p, q, …]}`` and ``{"disjointdata": [p, q, …]}`` — the
1807
+ n-ary data property axioms;
1808
+ * ``{"functionaldata": <prop>}`` — ``FunctionalDataProperty``;
1809
+ * ``{"domaindata": <prop>, "domain": <class text>}`` — ``DataPropertyDomain``
1810
+ (the class text is parsed under ``syntax``);
1811
+ * ``{"rangedata": <prop>, "range": <data range text>}`` —
1812
+ ``DataPropertyRange``;
1813
+ * ``{"datatype": <name>, "definition": <data range text>}`` —
1814
+ ``DatatypeDefinition``. The name is then a datatype in every other row's
1815
+ text, which is how ``d some Digit`` is told from ``r some Dog``.
1816
+
1817
+ A row is accepted even when the in-house tableau refuses to REASON over
1818
+ that kind: which kinds it decides is recorded in ``dl.tableau._AXIOM_KINDS``
1819
+ and enforced at query time, and the tool then reports that refusal rather
1820
+ than silently answering about a weaker knowledge base.
1821
+
1822
+ ``rows`` ``None``/``[]`` is the empty TBox. ``(tbox, None)`` on success,
1823
+ ``(None, error_dict)`` on the first failure: concept TEXT inside a row is
1824
+ parsed with :func:`_parse_dl` under the same ``syntax`` (the uniform
1825
+ ``ok=False`` shape); a malformed row SHAPE (missing/unrecognised keys, a
1826
+ non-2-element ``equiv``, a role name that is not a string, an OWL 2
1827
+ built-in role name) is a caller/config mistake, reported as
1828
+ ``{"error": {...}}``.
1829
+ """
1830
+ from .. import dl
1831
+
1832
+ tbox = dl.TBox()
1833
+ datatypes = _dl_datatype_names(rows)
1834
+ for i, row in enumerate(rows or []):
1835
+ if not isinstance(row, dict):
1836
+ return None, _error(ValueError(
1837
+ f"{argument}[{i}]: expected an object, got {type(row).__name__}"))
1838
+ if "sub" in row and "sup" in row:
1839
+ sub, err = _parse_dl(row["sub"], syntax, f"{argument}[{i}].sub", datatypes)
1840
+ if err is not None:
1841
+ return None, err
1842
+ sup, err = _parse_dl(row["sup"], syntax, f"{argument}[{i}].sup", datatypes)
1843
+ if err is not None:
1844
+ return None, err
1845
+ tbox.add(sub, sup)
1846
+ elif "equiv" in row:
1847
+ pair = row["equiv"]
1848
+ if not (isinstance(pair, list) and len(pair) == 2):
1849
+ return None, _error(ValueError(
1850
+ f"{argument}[{i}].equiv: expected a 2-element list, got {pair!r}"))
1851
+ c, err = _parse_dl(pair[0], syntax, f"{argument}[{i}].equiv[0]", datatypes)
1852
+ if err is not None:
1853
+ return None, err
1854
+ d, err = _parse_dl(pair[1], syntax, f"{argument}[{i}].equiv[1]", datatypes)
1855
+ if err is not None:
1856
+ return None, err
1857
+ tbox.add_equivalence(c, d)
1858
+ elif any(key in row for key in _DL_DATA_ROW_KEYS):
1859
+ err = _add_dl_data_row(tbox, row, i, syntax, argument, datatypes)
1860
+ if err is not None:
1861
+ return None, err
1862
+ elif "chain" in row and "suprole" in row:
1863
+ chain = row["chain"]
1864
+ if not (isinstance(chain, list) and len(chain) >= 2
1865
+ and all(isinstance(role, str) for role in chain)):
1866
+ return None, _error(ValueError(
1867
+ f"{argument}[{i}].chain: expected a list of at least 2 role "
1868
+ f"names, got {chain!r}"))
1869
+ try:
1870
+ tbox.add_role_chain(chain, row["suprole"])
1871
+ except dl.RoleExpressionError as exc:
1872
+ return None, _error(exc)
1873
+ elif "subrole" in row and "suprole" in row:
1874
+ try:
1875
+ tbox.add_role_inclusion(row["subrole"], row["suprole"])
1876
+ except dl.RoleExpressionError as exc:
1877
+ return None, _error(exc)
1878
+ elif any(key in row for key in _DL_FILLER_ROLE_ROW_SHAPES):
1879
+ key = next(k for k in _DL_FILLER_ROLE_ROW_SHAPES if k in row)
1880
+ builder, role_key, _keyword = _DL_FILLER_ROLE_ROW_SHAPES[key]
1881
+ if role_key not in row:
1882
+ return None, _error(ValueError(
1883
+ f"{argument}[{i}]: a {key!r} row needs {role_key!r} "
1884
+ f"(the role the axiom is about), got {sorted(row)}"))
1885
+ filler, err = _parse_dl(row[key], syntax, f"{argument}[{i}].{key}",
1886
+ datatypes)
1887
+ if err is not None:
1888
+ return None, err
1889
+ try:
1890
+ getattr(tbox, builder)(row[role_key], filler)
1891
+ except dl.RoleExpressionError as exc:
1892
+ return None, _error(exc)
1893
+ else:
1894
+ key = next((k for k in _DL_ROLE_ROW_SHAPES if k in row), None)
1895
+ if key is None:
1896
+ return None, _error(ValueError(
1897
+ f"{argument}[{i}]: expected keys 'sub'+'sup', 'equiv', "
1898
+ f"'subrole'+'suprole', 'chain'+'suprole', "
1899
+ f"'domainrole'+'domain', 'rangerole'+'range', "
1900
+ f"'subdata'+'supdata', 'domaindata'+'domain', "
1901
+ f"'rangedata'+'range', 'datatype'+'definition', or one of "
1902
+ f"{sorted(_DL_ROLE_ROW_SHAPES)} / "
1903
+ f"{sorted(_DL_DATA_NAME_ROW_SHAPES)}, got {sorted(row)}"))
1904
+ builder, arity = _DL_ROLE_ROW_SHAPES[key]
1905
+ value = row[key]
1906
+ if arity == "one":
1907
+ if not isinstance(value, str):
1908
+ return None, _error(ValueError(
1909
+ f"{argument}[{i}].{key}: expected a role name (str), "
1910
+ f"got {value!r}"))
1911
+ roles = [value]
1912
+ else:
1913
+ if not (isinstance(value, list) and len(value) >= 2
1914
+ and all(isinstance(role, str) for role in value)):
1915
+ return None, _error(ValueError(
1916
+ f"{argument}[{i}].{key}: expected a list of at least 2 "
1917
+ f"role names, got {value!r}"))
1918
+ roles = value
1919
+ try:
1920
+ getattr(tbox, builder)(*roles)
1921
+ except dl.RoleExpressionError as exc:
1922
+ return None, _error(exc)
1923
+ return tbox, None
1924
+
1925
+
1926
+ def _build_dl_abox(concepts, roles, distinct, syntax: str,
1927
+ same=None, negative_roles=None, data=None,
1928
+ negative_data=None, datatypes=()):
1929
+ """Build a :class:`~unicode_logic_kit.dl.ABox` from JSON rows.
1930
+
1931
+ ``concepts``: ``[individual, concept_text]`` pairs (``ABox.assert_concept``).
1932
+ ``roles``: ``[a, b, role]`` triples (``ABox.assert_role``). ``distinct``:
1933
+ ``[a, b]`` pairs (``ABox.assert_distinct`` — the only thing that forces two
1934
+ individuals apart, since this reasoner has no unique name assumption; see
1935
+ :mod:`unicode_logic_kit.dl.tableau`'s "Qualified number restrictions"
1936
+ section). ``same``: ``[a, b]`` pairs (``ABox.assert_same`` — the mirror of
1937
+ ``distinct``, decided by node merging). ``negative_roles``: ``[a, b, role]``
1938
+ triples (``ABox.assert_negative_role`` — ``¬role(a, b)``). The last two are
1939
+ optional; without them the MCP description-logic tools could express a
1940
+ strictly smaller class of knowledge bases than the Python API. The same
1941
+ holds for ``data`` and ``negative_data``: ``[individual, property, literal]``
1942
+ triples (``ABox.assert_data`` / ``assert_negative_data``), the literal being
1943
+ Manchester literal text (``"400"^^xsd:integer``, ``"abc"@en``, a bare
1944
+ numeral). ``datatypes``: the user-defined datatype names the concept texts
1945
+ may mention (see :func:`_dl_datatype_names`).
1946
+ ``(abox, None)`` on success, ``(None, error_dict)`` on the first
1947
+ failure: a concept TEXT failure is the uniform ``ok=False`` shape
1948
+ (argument ``"concepts[i][1]"``); a malformed row shape (a row of the wrong
1949
+ length, or an individual, role or property name that is not a string) is
1950
+ ``{"error": {...}}``, and so is a role or data property name an ABox
1951
+ builder refuses by name (:class:`~unicode_logic_kit.dl.RoleExpressionError`,
1952
+ :class:`~unicode_logic_kit.dl.UnsupportedDatatypeError`).
1953
+ """
1954
+ from .. import dl
1955
+
1956
+ try:
1957
+ return _dl_abox_from_rows(concepts, roles, distinct, syntax, same,
1958
+ negative_roles, data, negative_data, datatypes)
1959
+ except (dl.RoleExpressionError, dl.UnsupportedDatatypeError) as exc:
1960
+ return None, _error(exc)
1961
+
1962
+
1963
+ def _dl_abox_from_rows(concepts, roles, distinct, syntax, same, negative_roles,
1964
+ data, negative_data, datatypes):
1965
+ """The body of :func:`_build_dl_abox`, which adds the one ``try`` around it."""
1966
+ from .. import dl
1967
+
1968
+ abox = dl.ABox()
1969
+ for i, pair in enumerate(concepts or []):
1970
+ if not (isinstance(pair, list) and len(pair) == 2):
1971
+ return None, _error(ValueError(
1972
+ f"concepts[{i}]: expected [individual, concept_text], got {pair!r}"))
1973
+ individual, text = pair
1974
+ if not isinstance(individual, str):
1975
+ return None, _error(ValueError(
1976
+ f"concepts[{i}][0]: individual name must be a str, got "
1977
+ f"{type(individual).__name__}"))
1978
+ concept, err = _parse_dl(text, syntax, f"concepts[{i}][1]", datatypes)
1979
+ if err is not None:
1980
+ return None, err
1981
+ abox.assert_concept(individual, concept)
1982
+ for i, triple in enumerate(roles or []):
1983
+ if not (isinstance(triple, list) and len(triple) == 3
1984
+ and all(isinstance(name, str) for name in triple)):
1985
+ return None, _error(ValueError(
1986
+ f"roles[{i}]: expected [a, b, role] (three strings), got {triple!r}"))
1987
+ a, b, role = triple
1988
+ abox.assert_role(a, b, role)
1989
+ for i, pair in enumerate(distinct or []):
1990
+ if not (isinstance(pair, list) and len(pair) == 2
1991
+ and all(isinstance(name, str) for name in pair)):
1992
+ return None, _error(ValueError(
1993
+ f"distinct[{i}]: expected [a, b] (two strings), got {pair!r}"))
1994
+ a, b = pair
1995
+ abox.assert_distinct(a, b)
1996
+ for i, pair in enumerate(same or []):
1997
+ if not (isinstance(pair, list) and len(pair) == 2
1998
+ and all(isinstance(name, str) for name in pair)):
1999
+ return None, _error(ValueError(
2000
+ f"same[{i}]: expected [a, b] (two strings), got {pair!r}"))
2001
+ a, b = pair
2002
+ abox.assert_same(a, b)
2003
+ for i, triple in enumerate(negative_roles or []):
2004
+ if not (isinstance(triple, list) and len(triple) == 3
2005
+ and all(isinstance(name, str) for name in triple)):
2006
+ return None, _error(ValueError(
2007
+ f"negative_roles[{i}]: expected [a, b, role] (three strings), "
2008
+ f"got {triple!r}"))
2009
+ a, b, role = triple
2010
+ abox.assert_negative_role(a, b, role)
2011
+ for field, rows, assert_ in (("data", data, abox.assert_data),
2012
+ ("negative_data", negative_data,
2013
+ abox.assert_negative_data)):
2014
+ for i, triple in enumerate(rows or []):
2015
+ if not (isinstance(triple, list) and len(triple) == 3
2016
+ and isinstance(triple[0], str) and isinstance(triple[1], str)):
2017
+ return None, _error(ValueError(
2018
+ f"{field}[{i}]: expected [individual, property, literal], "
2019
+ f"got {triple!r}"))
2020
+ individual, prop, text = triple
2021
+ value, err = _parse_dl_literal(text, f"{field}[{i}][2]")
2022
+ if err is not None:
2023
+ return None, err
2024
+ assert_(individual, prop, value)
2025
+ return abox, None
2026
+
2027
+
2028
+ @_answers_deep_input
2029
+ def dl_concept_satisfiable(concept: str, tbox: Optional[List[dict]] = None,
2030
+ syntax: str = "alc") -> dict:
2031
+ """Is ``concept`` satisfiable with respect to ``tbox`` (the ALCHQ tableau)?
2032
+
2033
+ ``concept``/``tbox`` text and ``syntax`` follow this section's own header
2034
+ comment; ``tbox`` rows follow :func:`_build_dl_tbox`.
2035
+
2036
+ Returns ``{"ok": True, "satisfiable": bool, "concept_unicode": str}``.
2037
+ """
2038
+ from .. import dl
2039
+
2040
+ c, err = _parse_dl(concept, syntax, "concept", _dl_datatype_names(tbox))
2041
+ if err is not None:
2042
+ return err
2043
+ tb, err = _build_dl_tbox(tbox, syntax)
2044
+ if err is not None:
2045
+ return err
2046
+ texts, err = _unicode_texts(c)
2047
+ if err is not None:
2048
+ return err
2049
+ try:
2050
+ satisfiable = dl.concept_satisfiable(c, tb)
2051
+ except _dl_errors() as exc:
2052
+ return _error(exc)
2053
+ return {"ok": True, "satisfiable": satisfiable, "concept_unicode": texts[0]}
2054
+
2055
+
2056
+ @_answers_deep_input
2057
+ def dl_subsumes(sub: str, sup: str, tbox: Optional[List[dict]] = None,
2058
+ syntax: str = "alc") -> dict:
2059
+ """Does ``tbox`` entail ``sub ⊑ sup`` (every model puts ``sub`` in ``sup``)?
2060
+
2061
+ Returns ``{"ok": True, "subsumes": bool, "sub_unicode": str, "sup_unicode": str}``.
2062
+ """
2063
+ from .. import dl
2064
+
2065
+ datatypes = _dl_datatype_names(tbox)
2066
+ sub_c, err = _parse_dl(sub, syntax, "sub", datatypes)
2067
+ if err is not None:
2068
+ return err
2069
+ sup_c, err = _parse_dl(sup, syntax, "sup", datatypes)
2070
+ if err is not None:
2071
+ return err
2072
+ tb, err = _build_dl_tbox(tbox, syntax)
2073
+ if err is not None:
2074
+ return err
2075
+ texts, err = _unicode_texts(sub_c, sup_c)
2076
+ if err is not None:
2077
+ return err
2078
+ try:
2079
+ holds = dl.subsumes(sub_c, sup_c, tb)
2080
+ except _dl_errors() as exc:
2081
+ return _error(exc)
2082
+ return {"ok": True, "subsumes": holds,
2083
+ "sub_unicode": texts[0], "sup_unicode": texts[1]}
2084
+
2085
+
2086
+ @_answers_deep_input
2087
+ def dl_equivalent(c: str, d: str, tbox: Optional[List[dict]] = None,
2088
+ syntax: str = "alc") -> dict:
2089
+ """Does ``tbox`` entail ``c ≡ d`` (mutual subsumption)?
2090
+
2091
+ Returns ``{"ok": True, "equivalent": bool, "c_unicode": str, "d_unicode": str}``.
2092
+ """
2093
+ from .. import dl
2094
+
2095
+ datatypes = _dl_datatype_names(tbox)
2096
+ c_concept, err = _parse_dl(c, syntax, "c", datatypes)
2097
+ if err is not None:
2098
+ return err
2099
+ d_concept, err = _parse_dl(d, syntax, "d", datatypes)
2100
+ if err is not None:
2101
+ return err
2102
+ tb, err = _build_dl_tbox(tbox, syntax)
2103
+ if err is not None:
2104
+ return err
2105
+ texts, err = _unicode_texts(c_concept, d_concept)
2106
+ if err is not None:
2107
+ return err
2108
+ try:
2109
+ holds = dl.equivalent(c_concept, d_concept, tb)
2110
+ except _dl_errors() as exc:
2111
+ return _error(exc)
2112
+ return {"ok": True, "equivalent": holds,
2113
+ "c_unicode": texts[0], "d_unicode": texts[1]}
2114
+
2115
+
2116
+ @_answers_deep_input
2117
+ def dl_abox_consistent(concepts: List[List[str]],
2118
+ roles: Optional[List[List[str]]] = None,
2119
+ distinct: Optional[List[List[str]]] = None,
2120
+ tbox: Optional[List[dict]] = None,
2121
+ syntax: str = "alc",
2122
+ same: Optional[List[List[str]]] = None,
2123
+ negative_roles: Optional[List[List[str]]] = None,
2124
+ data: Optional[List[List[str]]] = None,
2125
+ negative_data: Optional[List[List[str]]] = None) -> dict:
2126
+ """Is the knowledge base ``(tbox, abox)`` consistent (does it have a model)?
2127
+
2128
+ ``concepts``/``roles``/``distinct``/``same``/``negative_roles``/``data``/
2129
+ ``negative_data`` build the ABox — see :func:`_build_dl_abox`; ``tbox``
2130
+ follows :func:`_build_dl_tbox`. A knowledge base with DATA assertions or a
2131
+ data box is expressible here, but the in-house tableau REFUSES it by name
2132
+ (it has no data domain): the reply is then an ``{"error": ...}`` naming the
2133
+ refused kinds, never an answer about a weaker knowledge base.
2134
+
2135
+ Returns ``{"ok": True, "consistent": bool}``.
2136
+ """
2137
+ from .. import dl
2138
+
2139
+ err = _check_dl_syntax(syntax)
2140
+ if err is not None:
2141
+ return err
2142
+ abox, err = _build_dl_abox(concepts, roles, distinct, syntax,
2143
+ same, negative_roles, data, negative_data,
2144
+ _dl_datatype_names(tbox))
2145
+ if err is not None:
2146
+ return err
2147
+ tb, err = _build_dl_tbox(tbox, syntax)
2148
+ if err is not None:
2149
+ return err
2150
+ try:
2151
+ consistent = dl.abox_consistent(abox, tb)
2152
+ except _dl_errors() as exc:
2153
+ return _error(exc)
2154
+ return {"ok": True, "consistent": consistent}
2155
+
2156
+
2157
+ @_answers_deep_input
2158
+ def dl_instance_check(individual: str, concept: str,
2159
+ concepts: List[List[str]],
2160
+ roles: Optional[List[List[str]]] = None,
2161
+ distinct: Optional[List[List[str]]] = None,
2162
+ tbox: Optional[List[dict]] = None,
2163
+ syntax: str = "alc",
2164
+ same: Optional[List[List[str]]] = None,
2165
+ negative_roles: Optional[List[List[str]]] = None,
2166
+ data: Optional[List[List[str]]] = None,
2167
+ negative_data: Optional[List[List[str]]] = None) -> dict:
2168
+ """Does the knowledge base entail ``individual : concept``?
2169
+
2170
+ Open-world (:func:`~unicode_logic_kit.dl.instance_check`'s own contract):
2171
+ ``entailed=False`` means "not entailed", never "entailed to be false".
2172
+ ``concepts``/``roles``/``distinct``/``tbox`` build the KB exactly like
2173
+ :func:`dl_abox_consistent`; ``concept`` is the query, parsed the same way.
2174
+
2175
+ Returns ``{"ok": True, "entailed": bool, "individual": str,
2176
+ "concept_unicode": str}``.
2177
+ """
2178
+ from .. import dl
2179
+
2180
+ datatypes = _dl_datatype_names(tbox)
2181
+ query, err = _parse_dl(concept, syntax, "concept", datatypes)
2182
+ if err is not None:
2183
+ return err
2184
+ abox, err = _build_dl_abox(concepts, roles, distinct, syntax,
2185
+ same, negative_roles, data, negative_data,
2186
+ datatypes)
2187
+ if err is not None:
2188
+ return err
2189
+ tb, err = _build_dl_tbox(tbox, syntax)
2190
+ if err is not None:
2191
+ return err
2192
+ texts, err = _unicode_texts(query)
2193
+ if err is not None:
2194
+ return err
2195
+ try:
2196
+ entailed = dl.instance_check(abox, individual, query, tb)
2197
+ except _dl_errors() as exc:
2198
+ return _error(exc)
2199
+ return {"ok": True, "entailed": entailed, "individual": individual,
2200
+ "concept_unicode": texts[0]}
2201
+
2202
+
2203
+ @_answers_deep_input
2204
+ def dl_instance_retrieval(concept: str, concepts: List[List[str]],
2205
+ roles: Optional[List[List[str]]] = None,
2206
+ distinct: Optional[List[List[str]]] = None,
2207
+ tbox: Optional[List[dict]] = None,
2208
+ syntax: str = "alc",
2209
+ same: Optional[List[List[str]]] = None,
2210
+ negative_roles: Optional[List[List[str]]] = None,
2211
+ data: Optional[List[List[str]]] = None,
2212
+ negative_data: Optional[List[List[str]]] = None) -> dict:
2213
+ """Every ABox individual the knowledge base entails is a ``concept``.
2214
+
2215
+ Sweeps :func:`~unicode_logic_kit.dl.instance_check` over every individual
2216
+ named in the ABox — including one that appears only in a role assertion.
2217
+ Arguments as :func:`dl_instance_check`.
2218
+
2219
+ Returns ``{"ok": True, "individuals": [str, ...], "concept_unicode": str}``
2220
+ (``individuals`` sorted, for a deterministic payload).
2221
+ """
2222
+ from .. import dl
2223
+
2224
+ datatypes = _dl_datatype_names(tbox)
2225
+ query, err = _parse_dl(concept, syntax, "concept", datatypes)
2226
+ if err is not None:
2227
+ return err
2228
+ abox, err = _build_dl_abox(concepts, roles, distinct, syntax,
2229
+ same, negative_roles, data, negative_data,
2230
+ datatypes)
2231
+ if err is not None:
2232
+ return err
2233
+ tb, err = _build_dl_tbox(tbox, syntax)
2234
+ if err is not None:
2235
+ return err
2236
+ texts, err = _unicode_texts(query)
2237
+ if err is not None:
2238
+ return err
2239
+ try:
2240
+ individuals = dl.instance_retrieval(abox, query, tb)
2241
+ except _dl_errors() as exc:
2242
+ return _error(exc)
2243
+ return {"ok": True, "individuals": sorted(individuals),
2244
+ "concept_unicode": texts[0]}
2245
+
2246
+
2247
+ @_answers_deep_input
2248
+ def dl_classify(tbox: Optional[List[dict]] = None,
2249
+ concepts: Optional[List[str]] = None,
2250
+ syntax: str = "alc") -> dict:
2251
+ """Classify every named concept of ``tbox`` into a subsumption hierarchy.
2252
+
2253
+ ``tbox`` follows :func:`_build_dl_tbox` (``None``/``[]`` classifies the
2254
+ empty TBox — every concept is then its own isolated node). ``concepts``
2255
+ is extra concept TEXT to bring names of interest into the vocabulary even
2256
+ when they never occur in a ``tbox`` axiom (see
2257
+ :func:`~unicode_logic_kit.dl.classify`'s own ``concepts`` parameter) —
2258
+ parsed under the same ``syntax``.
2259
+
2260
+ Returns ``{"ok": True, "equivalents": {name: [str, ...]}, "parents":
2261
+ {name: [str, ...]}, "children": {name: [str, ...]}, "ancestors": {name:
2262
+ [str, ...]}}`` — :class:`~unicode_logic_kit.dl.Classification`'s frozensets
2263
+ rendered as sorted lists for a deterministic JSON payload. ``equivalents``
2264
+ is keyed by, and includes, the lexicographically smallest name in each
2265
+ mutual-subsumption synonym class; ``parents``/``children`` are the
2266
+ transitively-reduced Hasse diagram (direct super-/sub-concepts only);
2267
+ ``ancestors`` is the full transitive closure.
2268
+ """
2269
+ from .. import dl
2270
+
2271
+ err = _check_dl_syntax(syntax)
2272
+ if err is not None:
2273
+ return err
2274
+ tb, err = _build_dl_tbox(tbox, syntax)
2275
+ if err is not None:
2276
+ return err
2277
+ extra = []
2278
+ datatypes = _dl_datatype_names(tbox)
2279
+ for i, text in enumerate(concepts or []):
2280
+ concept, err = _parse_dl(text, syntax, f"concepts[{i}]", datatypes)
2281
+ if err is not None:
2282
+ return err
2283
+ extra.append(concept)
2284
+ try:
2285
+ result = dl.classify(tb, extra or None)
2286
+ except _dl_errors() as exc:
2287
+ return _error(exc)
2288
+ return {"ok": True,
2289
+ "equivalents": {k: sorted(v) for k, v in result.equivalents.items()},
2290
+ "parents": {k: sorted(v) for k, v in result.parents.items()},
2291
+ "children": {k: sorted(v) for k, v in result.children.items()},
2292
+ "ancestors": {k: sorted(v) for k, v in result.ancestors.items()}}
2293
+
2294
+
2295
+ def _role_axiom_payload(axiom) -> dict:
2296
+ """``dl.parse_manchester_role_axiom``'s tuple as a JSON payload.
2297
+
2298
+ One function over its THREE tuple shapes, keyed by the TAG and read off the
2299
+ reader's own tables (``_BINARY_ROLE_FRAMES`` / ``_FILLER_ROLE_FRAMES`` /
2300
+ ``_CHARACTERISTIC_TAGS``), so a shape the reader gains cannot be named
2301
+ differently here.
2302
+
2303
+ Until 0.30.0 this branch ended in ``_, role = axiom``, so every shape other
2304
+ than ``("subproperty", sub, sup)`` and a characteristic crashed the tool
2305
+ with ``ValueError: too many values to unpack`` — an ``InverseOf``,
2306
+ ``DisjointWith`` or ``EquivalentTo`` axiom the reader already read
2307
+ perfectly well. Deriving the tags means a reader shape with no payload here
2308
+ is reported as an error naming itself, not as a crash.
2309
+ """
2310
+ from ..dl import owl_manchester as _manchester
2311
+
2312
+ pairs = {tag for tag, _spelling in _manchester._BINARY_ROLE_FRAMES.values()}
2313
+ fillers = {tag for tag, _spelling in _manchester._FILLER_ROLE_FRAMES.values()}
2314
+ characteristics = set(_manchester._CHARACTERISTIC_TAGS.values())
2315
+ tag = axiom[0]
2316
+ if tag in pairs and len(axiom) == 3:
2317
+ return {"ok": True, "kind": tag,
2318
+ "sub_role": axiom[1], "super_role": axiom[2]}
2319
+ if tag in fillers and len(axiom) == 3:
2320
+ texts, err = _unicode_texts(axiom[2])
2321
+ if err is not None:
2322
+ return err
2323
+ return {"ok": True, "kind": tag, "role": axiom[1],
2324
+ "concept_unicode": texts[0]}
2325
+ if tag in characteristics and len(axiom) == 2:
2326
+ return {"ok": True, "kind": tag, "role": axiom[1]}
2327
+ return _error(ValueError(
2328
+ f"dl_parse_manchester: dl.parse_manchester_role_axiom returned the "
2329
+ f"shape {axiom!r}, which this tool has no payload for — add one"))
2330
+
2331
+
2332
+ @_answers_deep_input
2333
+ def dl_parse_manchester(text: str, kind: str = "concept") -> dict:
2334
+ """Parse OWL 2 Manchester Syntax text, three ways.
2335
+
2336
+ ``kind="concept"`` (default): a class expression via ``dl.parse_manchester``
2337
+ — returns the parsed concept's unicode rendering plus a ``to_manchester``
2338
+ round-trip (``dl.to_manchester(dl.parse_manchester(text))``), so a caller
2339
+ can confirm ``text`` normalises the way it expects.
2340
+ ``kind="axiom"``: a ``SubClassOf``/``EquivalentTo`` axiom via
2341
+ ``dl.parse_manchester_axiom``.
2342
+ ``kind="role_axiom"``: a ``SubPropertyOf``/``Characteristics: Transitive``
2343
+ RBox axiom via ``dl.parse_manchester_role_axiom`` (see
2344
+ :mod:`unicode_logic_kit.dl.tableau`'s "Role hierarchies and transitive
2345
+ roles (RBox)").
2346
+
2347
+ Returns, on success: ``{"ok": True, "concept_unicode": str, "manchester":
2348
+ str}`` (``kind="concept"``); ``{"ok": True, "kind": "subclass"|
2349
+ "equivalent", "sub_unicode": str, "sup_unicode": str}`` (``kind="axiom"``);
2350
+ and for ``kind="role_axiom"`` one of three shapes, by the axiom read (see
2351
+ :func:`_role_axiom_payload`): ``{"ok": True, "kind": "subproperty"|
2352
+ "equivalentproperty"|"inverse"|"disjoint", "sub_role": str, "super_role":
2353
+ str}``, ``{"ok": True, "kind": "domain"|"range", "role": str,
2354
+ "concept_unicode": str}``, or ``{"ok": True, "kind": "transitive"|…,
2355
+ "role": str}`` for a ``Characteristics:`` declaration.
2356
+ Malformed/unsupported ``text`` is the uniform ``ok=False`` shape (see this
2357
+ section's own header comment); an unknown ``kind`` is
2358
+ ``{"error": {"type": "ValueError", ...}}``.
2359
+ """
2360
+ from .. import dl
2361
+
2362
+ if kind == "concept":
2363
+ try:
2364
+ concept = dl.parse_manchester(text)
2365
+ except dl.ManchesterSyntaxError as exc:
2366
+ return {"ok": False, "argument": "text",
2367
+ "errors": [{"dialect": "manchester", "message": str(exc)}],
2368
+ "spec_topic": "description-logic"}
2369
+ texts, err = _unicode_texts(concept)
2370
+ if err is not None:
2371
+ return err
2372
+ try:
2373
+ manchester = dl.to_manchester(concept)
2374
+ except ValueError as exc: # a name parse_manchester could not read back
2375
+ return _error(exc)
2376
+ return {"ok": True, "concept_unicode": texts[0], "manchester": manchester}
2377
+ if kind == "axiom":
2378
+ try:
2379
+ label, sub, sup = dl.parse_manchester_axiom(text)
2380
+ except dl.ManchesterSyntaxError as exc:
2381
+ return {"ok": False, "argument": "text",
2382
+ "errors": [{"dialect": "manchester", "message": str(exc)}],
2383
+ "spec_topic": "description-logic"}
2384
+ texts, err = _unicode_texts(sub, sup)
2385
+ if err is not None:
2386
+ return err
2387
+ return {"ok": True, "kind": label,
2388
+ "sub_unicode": texts[0], "sup_unicode": texts[1]}
2389
+ if kind == "role_axiom":
2390
+ try:
2391
+ axiom = dl.parse_manchester_role_axiom(text)
2392
+ except dl.ManchesterSyntaxError as exc:
2393
+ return {"ok": False, "argument": "text",
2394
+ "errors": [{"dialect": "manchester", "message": str(exc)}],
2395
+ "spec_topic": "description-logic"}
2396
+ return _role_axiom_payload(axiom)
2397
+ return _error(ValueError(
2398
+ f"dl_parse_manchester: unknown kind {kind!r} "
2399
+ "(one of ['concept', 'axiom', 'role_axiom'])"))
2400
+
2401
+
2402
+ # --------------------------------------------------------------------------
2403
+ # Server assembly
2404
+ # --------------------------------------------------------------------------
2405
+
2406
+ def create_server():
2407
+ """Build the :class:`mcp.server.MCPServer` with every tool registered.
2408
+
2409
+ Imported lazily so the whole subpackage stays importable-in-theory even
2410
+ without the optional SDK — but this function needs it: a missing SDK
2411
+ raises ImportError with the install hint.
2412
+ """
2413
+ try:
2414
+ from mcp.server import MCPServer
2415
+ except ImportError as exc:
2416
+ raise ImportError(
2417
+ "unicode_logic_kit.mcp needs the MCP SDK: "
2418
+ "pip install 'unicode-logic-kit[mcp]' (or: pip install 'mcp>=2.0')"
2419
+ ) from exc
2420
+
2421
+ from .chem_tools import (
2422
+ molecule_to_structure, check_molecule, check_molecules,
2423
+ explain_molecule_failure, simplify_definition, chemical_signature,
2424
+ )
2425
+
2426
+ server = MCPServer(_SERVER_NAME, instructions=_INSTRUCTIONS)
2427
+ for fn in (parse_formula, check_formula, prove, find_countermodel,
2428
+ check_equivalence, diagnose, repair_formula, translate,
2429
+ verbalize, list_backends,
2430
+ normalize, render, detect_dialect, compare_formulas,
2431
+ score_batch, check_consistency, get_signature, truth_table,
2432
+ drs_to_fol, list_translations,
2433
+ probability_bounds, probability_query, get_syntax_spec,
2434
+ # Chemistry: molecules as structures, definitions checked
2435
+ # against them, failures explained (mcp.chem_tools).
2436
+ molecule_to_structure, check_molecule, check_molecules,
2437
+ explain_molecule_failure, simplify_definition,
2438
+ chemical_signature,
2439
+ # Description logic: ALCHQ tableau reasoning (concept
2440
+ # satisfiability, subsumption, ABox consistency, instance/
2441
+ # realization queries, TBox classification) plus OWL
2442
+ # Manchester Syntax parsing — ALC glyph or Manchester input,
2443
+ # selected by each tool's own `syntax` parameter.
2444
+ dl_concept_satisfiable, dl_subsumes, dl_equivalent,
2445
+ dl_abox_consistent, dl_instance_check, dl_instance_retrieval,
2446
+ dl_classify, dl_parse_manchester):
2447
+ server.tool()(_registered(fn))
2448
+ return server
2449
+
2450
+
2451
+ def main() -> None:
2452
+ """Run the server on stdio (the transport MCP clients spawn)."""
2453
+ create_server().run("stdio")