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,1031 @@
1
+ """Chemistry MCP tools — model-checking feedback for an LLM writing ChEBI FOL.
2
+
3
+ The setting (see ``unicode_logic_kit.chem``'s own module docstring for the
4
+ full brief): an LLM translates a ChEBI class definition to FOL;
5
+ classification is MODEL CHECKING that formula against a molecule represented
6
+ as a finite structure. The failure modes that loop runs into are what these
7
+ six tools exist to close, one each:
8
+
9
+ * the LLM never SEES what its formula is being checked against
10
+ (:func:`molecule_to_structure` — the structure as JSON, plus a headline
11
+ domain size / nonempty-predicate summary a caller can read without parsing
12
+ the whole extension table);
13
+ * a single positive example failing gives no diagnosis, only pass/fail
14
+ (:func:`check_molecule` — ``holds`` plus, on failure, the
15
+ :func:`~unicode_logic_kit.semantics.model_eval.evaluate_detailed` blame trail);
16
+ * a whole labelled corpus needs to be re-checked after every definition edit,
17
+ and a definition that is too general (matching far too much, at a precision
18
+ cost invisible until it is measured against a corpus) is exactly the kind
19
+ of thing only visible in aggregate (:func:`check_molecules` — per-molecule
20
+ results plus a holds/fails/unknown/errors summary);
21
+ * a failing conjunct alone still does not say what the molecule looks like —
22
+ a counterexample needs explaining, not just reporting
23
+ (:func:`explain_molecule_failure` — the failing conjunct PLUS the
24
+ molecule's atoms-by-type and bond list, so a model can see both halves of
25
+ the mismatch at once);
26
+ * redundant pairwise-inequality chains (``hasAtLeast40Carbons`` written as 40
27
+ nested existentials + C(40,2)=780 ``≠`` literals) are a routine timeout
28
+ cause (:func:`simplify_definition`
29
+ — applies :func:`~unicode_logic_kit.fol.simplify_check.simplify_for_checking`
30
+ and reports what shrank, by how much);
31
+ * pure-SYNTAX failures, hallucinated predicate names among them, burn a whole
32
+ generation attempt each (:func:`chemical_signature` — the exact
33
+ 40-predicate ChemLog vocabulary so a generator never has to guess what it
34
+ is allowed to write).
35
+
36
+ Conventions (matching :mod:`unicode_logic_kit.mcp.server`, followed exactly so
37
+ a client that already talks to that server needs no special-casing for these
38
+ six tools)
39
+ ------------------------------------------------------------------------------
40
+ * every tool returns a JSON-compatible dict;
41
+ * a bad TEXT argument (an unparseable formula, or a SMILES string that does
42
+ not describe a valid molecule) comes back as
43
+ ``{"ok": False, "argument": <which input — "formula"/"smiles"/
44
+ "smiles[<i>]">, "errors": [...], "spec_topic": <topic>}`` — the SAME shape
45
+ for both, deliberately: from the calling pipeline's perspective a bad
46
+ formula and a bad SMILES are the identical case, "this argument did not
47
+ work", and a generic client checks ``result.get("ok") is False``, full
48
+ stop;
49
+ * a documented exception that is not about a bad argument (RDKit not
50
+ installed; a formula that mentions a predicate the structure does not
51
+ interpret; a node type the structural evaluator does not support) comes
52
+ back as ``{"error": {"type": ..., "message": ...}}``, never a traceback.
53
+
54
+ Chemical formulas MUST be written in TPTP
55
+ ------------------------------------------
56
+ ChemLog's vocabulary is lower-case-led (``c``, ``bDOUBLE``, ...); in the
57
+ kit's own unicode surface syntax a lower-case leading letter is a VARIABLE or
58
+ FUNCTION, never a predicate, so a chemical class definition can only be
59
+ parsed as TPTP. ``dialect="tptp_bare"`` (the default on every tool that takes
60
+ a formula) therefore routes through
61
+ :func:`unicode_logic_kit.chem.parse_chemlog_tptp`, which additionally repairs
62
+ the three recurring LLM syntax failure modes (unbracketed
63
+ biimplication, an unquoted invalid predicate name, a free left-hand
64
+ variable — see :mod:`unicode_logic_kit.fol.tptp_repair`) BEFORE renaming the
65
+ chemical vocabulary back from the TPTP importer's forced-capitalised kit
66
+ spelling to ChemLog's own lower-case spelling — without that rename step no
67
+ TPTP-derived formula would ever line up with a
68
+ :func:`~unicode_logic_kit.chem.mol_to_structure` structure (see
69
+ ``unicode_logic_kit.chem.interop``'s module docstring for exactly why). Passing
70
+ any other ``dialect`` is still honoured (useful for a formula generated
71
+ directly in the kit's own unicode syntax with kit-capitalised chemical
72
+ predicate names, e.g. ``C(x)`` for carbon) — it is parsed via
73
+ :func:`unicode_logic_kit.api.parse_any` and then the SAME chemical-vocabulary
74
+ rename is applied, so the resulting :class:`~unicode_logic_kit.fol.nodes.Node`
75
+ always ends up chemlog-spelled either way. It is NOT repaired in that branch
76
+ (only the ``tptp_bare`` path calls the repair layer — a formula in the kit's
77
+ own syntax is repaired by calling
78
+ :func:`unicode_logic_kit.fol.repair_formula` explicitly first, which is
79
+ deliberate: a rename that happened silently inside a CHECKING tool would
80
+ change which predicate was checked without the caller ever seeing it), and
81
+ auxiliary/class
82
+ predicates outside ChemLog's 40-symbol vocabulary (``carboxylicAcid``,
83
+ ``organicMolecularEntity``, ...) are left exactly as parsed either way — they
84
+ have no ChemLog spelling to rename to.
85
+
86
+ Why ``all_different`` defaults to ``True`` here (unlike the underlying
87
+ evaluator)
88
+ ------------------------------------------------------------------------------
89
+ :func:`unicode_logic_kit.semantics.model_eval.evaluate_detailed` defaults
90
+ ``all_different=False`` (plain classical semantics: two separately
91
+ existentially-bound variables MAY denote the same individual). Every ChemLog
92
+ class definition this tool layer exists to check is written under the
93
+ OPPOSITE, ChemLog-specific convention — "separately introduced existential
94
+ variables are already pairwise distinct" — so every tool below that runs
95
+ model checking defaults ``all_different=True`` instead. Pass ``False``
96
+ explicitly to check a formula under plain FOL semantics instead.
97
+
98
+ Never a guessed False
99
+ -----------------------
100
+ Every model-checking tool exposes an optional ``budget`` (default ``None`` —
101
+ unbounded). If a budget is given and exhausted before a definitive answer,
102
+ ``holds`` comes back ``None`` (never ``False``) — see
103
+ :mod:`unicode_logic_kit.semantics.model_eval`'s own "three-valued contract".
104
+
105
+ Spans (opt-in, ``with_spans=True`` on :func:`check_molecule`,
106
+ :func:`check_molecules`, :func:`explain_molecule_failure`)
107
+ ------------------------------------------------------------------------------
108
+ ``failing_conjunct`` names the piece of ``formula`` that made the check fail,
109
+ but only ever as rendered TEXT (``fc.to_unicode_str()``) — a caller has to
110
+ find that text again inside the ORIGINAL ``formula`` string by eye (or by a
111
+ substring search that can fail to be unique, or fail outright once
112
+ rendering has re-bracketed or re-spaced anything). ``with_spans=True`` adds
113
+ a ``"span"`` key beside ``failing_conjunct`` instead: ``{"start": int, "end":
114
+ int, "line": int, "column": int, "end_line": int, "end_column": int, "text":
115
+ str}`` (a JSON rendering of a :class:`~unicode_logic_kit.fol.spans.Span`) when
116
+ one was recovered, else ``None`` — a character range directly into the
117
+ ``formula`` TEXT this call was given, exact and unambiguous, ready for a
118
+ caller to slice or highlight without re-deriving it.
119
+
120
+ This is built directly on
121
+ :meth:`unicode_logic_kit.fol.msflparser.MSFLParser.parse_with_spans` — a new,
122
+ additive method alongside ``.parse`` on the SAME instance (``.parse`` itself
123
+ is byte-for-byte unchanged) that returns a
124
+ :class:`~unicode_logic_kit.fol.spans.SpannedFormula`: ``.formula`` is the
125
+ identical AST ``.parse`` would build, ``.spans`` a
126
+ :class:`~unicode_logic_kit.fol.spans.SpanMap` from each of that AST's nodes
127
+ back to the slice of source text it was parsed from. ``SpanMap`` is keyed by
128
+ PATH (a tuple of child indices from the root — see
129
+ :mod:`unicode_logic_kit.fol.spans`'s module docstring), NOT by the node's own
130
+ value and NOT by Python ``id()``: every
131
+ :class:`~unicode_logic_kit.fol.nodes.Node` subclass is a frozen dataclass with
132
+ STRUCTURAL equality/hashing (see the kit-wide reason in ``_fol_nodes.py``),
133
+ so a value-keyed table would silently collapse two textually-distinct but
134
+ structurally-identical subformulas (``c(x) & c(x)``, two separate
135
+ occurrences of the same class predicate) onto a single span, and an
136
+ id()-keyed one goes stale the moment a node is rebuilt (which
137
+ ``map_children``-based rewrites, including the rename below, always do).
138
+ This module uses the convenience ``.for_node(node)`` lookup — a node object
139
+ in hand, resolved by identity against whichever tree the map was last bound
140
+ to — rather than the PATH-keyed ``.get(path)`` a caller doing its own
141
+ traversal would use.
142
+
143
+ This module supplies the piece the span layer does not itself cover: every
144
+ ``check_molecule``/``check_molecules``/``explain_molecule_failure`` call
145
+ renames the freshly parsed formula's chemical vocabulary to ChemLog spelling
146
+ before evaluating it (see "Chemical formulas MUST be written in TPTP"
147
+ above), and that rename reconstructs EVERY node in the tree — including
148
+ every ancestor of a renamed leaf, not just the renamed leaf itself, since
149
+ :meth:`~unicode_logic_kit.fol.nodes.Node.map_children` always allocates a new
150
+ object — so a fresh set of node OBJECTS exists after the rename even though
151
+ the tree's SHAPE (and hence every PATH into it) is unchanged
152
+ (:func:`~unicode_logic_kit.semantics.model_eval.evaluate_detailed` itself
153
+ never rebuilds a node, so identity survives evaluation, just not the rename
154
+ that happens first). :func:`unicode_logic_kit.chem.rename_with_spans` closes
155
+ exactly that gap, via :func:`unicode_logic_kit.fol.spans.project_spans` (the
156
+ span layer's own utility for carrying a ``SpanMap`` across a
157
+ shape-preserving rewrite — the rename only ever changes an ``Atom``'s
158
+ predicate or a ``Function``'s name, never the shape of the tree around it —
159
+ and re-binds the identity index to the renamed tree, so ``.for_node``
160
+ resolves against the SAME tree ``failing_conjunct`` is drawn from): a caller
161
+ sees a span on ``failing_conjunct`` whenever ``parse_with_spans`` recorded
162
+ one for the corresponding source node, all the way through the rename.
163
+
164
+ Only the dialects that route through ``MSFLParser`` support ``with_spans``
165
+ today — the kit's own surface syntax (``"unicode"``, or a specific mode name
166
+ such as ``"fol"``/``"msfol"``/...), NOT ``dialect="tptp_bare"`` (this
167
+ module's own default): TPTP is parsed by a wholly separate Lark grammar
168
+ (:mod:`unicode_logic_kit.fol.tptp_input`) that the span layer above does not
169
+ cover. This is not a theoretical restriction: the real external caller of
170
+ these tools (the ChEBI campaign) already calls every one of them with
171
+ ``dialect="unicode"`` throughout its own pipeline (never ``"tptp_bare"``),
172
+ specifically so its `check_formula`/`diagnose`/`repair_formula` loop and its
173
+ model-checking calls agree on one dialect — ``with_spans=True`` lines up
174
+ with that exact usage, not a hypothetical one. Passing ``with_spans=True``
175
+ with ``dialect="tptp_bare"`` (or any other non-kit-syntax dialect) is
176
+ reported as ``{"error": {"type": "ValueError", ...}}`` — a caller/config
177
+ mismatch, not formula data being bad.
178
+ """
179
+
180
+ from typing import Dict, List, Optional, Tuple
181
+
182
+ from .. import api, chem
183
+ from .. import simplify_for_checking, count_from_existential_chain
184
+ from ..fol.nodes import Node
185
+ from ..fol.tptp_repair import _render as _render_tptp
186
+ from ..semantics.model_eval import (
187
+ evaluate_detailed, EvalResult, UninterpretedSymbol, UnsupportedNode,
188
+ )
189
+ from ..semantics.structures import FiniteStructure
190
+
191
+ __all__ = [
192
+ "molecule_to_structure", "check_molecule", "check_molecules",
193
+ "explain_molecule_failure", "simplify_definition", "chemical_signature",
194
+ ]
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Shared error shaping (see module docstring's "Conventions")
199
+ # ---------------------------------------------------------------------------
200
+
201
+ def _error(exc: Exception) -> dict:
202
+ """A documented, non-argument exception as ``{"error": {...}}``.
203
+
204
+ :class:`~unicode_logic_kit.semantics.model_eval.UninterpretedSymbol`
205
+ additionally carries ``.symbol``/``.arity`` — surfaced here so a caller
206
+ can tell, without parsing the message, exactly which predicate/arity the
207
+ structure did not interpret (almost always a vocabulary typo, or a class
208
+ predicate from a definition this tool never saw).
209
+ """
210
+ payload = {"error": {"type": type(exc).__name__, "message": str(exc)}}
211
+ symbol = getattr(exc, "symbol", None)
212
+ if symbol is not None:
213
+ payload["error"]["symbol"] = symbol
214
+ payload["error"]["arity"] = getattr(exc, "arity", None)
215
+ return payload
216
+
217
+
218
+ def _smiles_error(message: str, argument: str = "smiles") -> dict:
219
+ """A SMILES that does not describe a valid molecule, in the SAME uniform
220
+ ``ok=False``/``argument``/``errors`` shape as a formula parse failure —
221
+ see the module docstring's "Conventions" for why the two are unified."""
222
+ return {"ok": False, "argument": argument,
223
+ "errors": [{"dialect": "smiles", "message": message}],
224
+ "spec_topic": "chemistry"}
225
+
226
+
227
+ # Lowercased substring of a parse-error message -> the syntax_spec topic that
228
+ # explains it. A small, honest heuristic (mirrors
229
+ # unicode_logic_kit.mcp.server's own, kept independent rather than imported —
230
+ # this module owns no dependency on the general-purpose server's private
231
+ # helpers): it names where to LOOK, not a diagnosis, which is why the
232
+ # fallback is "chemistry" (the topic covering the TPTP-only, 0-ary-
233
+ # definition, lowercase-predicate conventions every chemical formula must
234
+ # follow) rather than a guess.
235
+ _SPEC_HINTS: Tuple[Tuple[str, str], ...] = (
236
+ ("invalid name", "naming"),
237
+ ("invalid variable", "naming"),
238
+ ("invalid name/constant", "naming"),
239
+ ("unexpected character", "naming"),
240
+ ("not bound", "quantifiers"),
241
+ ("free variable", "quantifiers"),
242
+ ("incomplete formula", "operators"),
243
+ ("unexpected token", "operators"),
244
+ )
245
+
246
+
247
+ def _spec_topic_for(message: str) -> str:
248
+ low = message.lower()
249
+ for needle, topic in _SPEC_HINTS:
250
+ if needle in low:
251
+ return topic
252
+ return "chemistry"
253
+
254
+
255
+ def _parse_chem(text: str, dialect: str, argument: str = "formula"
256
+ ) -> Tuple[Optional[Node], Optional[dict]]:
257
+ """Parse chemical class-definition TEXT into a ChemLog-spelled
258
+ :class:`~unicode_logic_kit.fol.nodes.Node` — ``(node, None)`` on success,
259
+ ``(None, error_dict)`` on failure (see the module docstring's
260
+ "Conventions" for the error shape).
261
+
262
+ ``dialect="tptp_bare"`` (see the module docstring's "Chemical formulas
263
+ MUST be written in TPTP") routes through
264
+ :func:`unicode_logic_kit.chem.parse_chemlog_tptp` (repair + rename, in one
265
+ call). Any other ``dialect`` is parsed via
266
+ :func:`unicode_logic_kit.api.parse_any` (no repair pass) and then renamed
267
+ the identical way via :func:`unicode_logic_kit.chem.to_chemlog_names`, so
268
+ the returned node is ChemLog-spelled either way.
269
+ """
270
+ if dialect == "tptp_bare":
271
+ try:
272
+ node = chem.parse_chemlog_tptp(text)
273
+ except Exception as exc: # noqa: BLE001 — every TPTP parser raises
274
+ # its own exception type (TptpParsingError et al.), none of them
275
+ # a ValueError; catching broadly and reporting the message is
276
+ # exactly what unicode_logic_kit.api.parse_any's own _try() does
277
+ # for the identical reason.
278
+ message = str(exc)
279
+ return None, {"ok": False, "argument": argument,
280
+ "errors": [{"dialect": "tptp_bare",
281
+ "message": message}],
282
+ "spec_topic": _spec_topic_for(message)}
283
+ return node, None
284
+
285
+ parsed = api.parse_any(text, hint=dialect)
286
+ if not parsed.ok:
287
+ errors = parsed.to_dict()["errors"]
288
+ combined = " ".join(e.get("message", "") for e in errors)
289
+ return None, {"ok": False, "argument": argument, "errors": errors,
290
+ "spec_topic": _spec_topic_for(combined)}
291
+ return chem.to_chemlog_names(parsed.formula), None
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Spans (opt-in) -- see the module docstring's "Spans" section for the full
296
+ # contract this builds on (unicode_logic_kit.fol.msflparser.MSFLParser
297
+ # .parse_with_spans / unicode_logic_kit.fol.spans).
298
+ # ---------------------------------------------------------------------------
299
+
300
+ #: Dialects with_spans=True actually supports: the kit's own MSFLParser-
301
+ #: backed surface syntax -- api._UNICODE_HINTS's keys (each a single fixed
302
+ #: mode) plus "unicode" itself (the ladder that tries them in the SAME
303
+ #: order api._unicode_ladder does). Deliberately reuses api's own table
304
+ #: rather than restating the mode list here, the same way fol.dialect_repair
305
+ #: reuses fol.tptp_repair's fixes instead of reimplementing them -- two
306
+ #: independently-maintained copies of "which modes, which order" could
307
+ #: silently drift, and with_spans=True must pick the identical winning mode
308
+ #: plain parsing would, or the span-carrying node would not even be the SAME
309
+ #: formula. NOT "tptp_bare" (this module's own default): TPTP is parsed by
310
+ #: a wholly separate Lark grammar (fol.tptp_input) with no span tracking.
311
+ _SPAN_DIALECTS = frozenset(api._UNICODE_HINTS) | {"unicode"}
312
+
313
+
314
+ def _parse_chem_with_spans(text: str, dialect: str, argument: str = "formula"
315
+ ) -> Tuple[Optional[Node], Optional[chem.SpanMap],
316
+ Optional[dict]]:
317
+ """:func:`_parse_chem`, plus a :class:`unicode_logic_kit.chem.SpanMap` for
318
+ the returned node — ``(node, spans, None)`` on success.
319
+
320
+ ``(None, None, error_dict)`` on failure, where ``error_dict`` is one of
321
+ two shapes (see the module docstring's "Conventions" and "Spans"):
322
+
323
+ * ``{"error": {"type": "ValueError", ...}}`` — ``dialect`` is not in
324
+ :data:`_SPAN_DIALECTS` (a caller/config mistake: ``with_spans=True``
325
+ together with, most commonly, this module's own ``dialect="tptp_bare"``
326
+ default — not formula DATA being bad);
327
+ * the uniform ``{"ok": False, "argument": ..., "errors": [...],
328
+ "spec_topic": ...}`` shape for a genuine syntax error in ``text`` — the
329
+ SAME shape :func:`_parse_chem` reports for the identical input, so a
330
+ caller's ``result.get("ok") is False`` check behaves identically
331
+ whether or not it asked for spans.
332
+ """
333
+ if dialect not in _SPAN_DIALECTS:
334
+ return None, None, _error(ValueError(
335
+ f"chem_tools: with_spans=True is not supported for dialect={dialect!r} "
336
+ f"yet — only the kit's own surface syntax ({sorted(_SPAN_DIALECTS)}) "
337
+ "has a span-tracking parser (not this module's own default, "
338
+ "dialect='tptp_bare'). Call again without with_spans, or with one "
339
+ "of the dialects above."))
340
+
341
+ from ..fol.msflparser import MSFLParser
342
+
343
+ ladder = (api._UNICODE_MODES if dialect == "unicode"
344
+ else ((dialect, api._UNICODE_HINTS[dialect]),))
345
+ spanned = None
346
+ message = "no unicode-surface mode accepted the text"
347
+ for _name, kwargs in ladder:
348
+ try:
349
+ spanned = MSFLParser(**kwargs).parse_with_spans(text)
350
+ break
351
+ except Exception as exc: # noqa: BLE001 — mirrors api._try's own catch-all
352
+ message = str(exc)
353
+ if spanned is None:
354
+ return None, None, {"ok": False, "argument": argument,
355
+ "errors": [{"dialect": dialect, "message": message}],
356
+ "spec_topic": _spec_topic_for(message)}
357
+ node, spans = chem.to_chemlog_names_with_spans(spanned.formula, spanned.spans)
358
+ return node, spans, None
359
+
360
+
361
+ def _span_dict(span) -> Optional[dict]:
362
+ """JSON rendering of a :class:`unicode_logic_kit.fol.spans.Span`, or
363
+ ``None`` for :data:`~unicode_logic_kit.fol.spans.UNKNOWN` — a node the span
364
+ layer could not recover an exact source range for (see that module's
365
+ docstring for the two documented, narrow cases) is reported as ``None``,
366
+ never a guessed range."""
367
+ from ..fol.spans import UNKNOWN
368
+
369
+ if span is UNKNOWN:
370
+ return None
371
+ return {"start": span.start, "end": span.end, "line": span.line,
372
+ "column": span.column, "end_line": span.end_line,
373
+ "end_column": span.end_column, "text": span.text}
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Structure -> readable summary helpers (shared by molecule_to_structure /
378
+ # explain_molecule_failure)
379
+ # ---------------------------------------------------------------------------
380
+
381
+ #: The atom-type letters: ChemLog's own six, then the halogens the kit adds
382
+ #: (see chem._naming.ELEMENT_LETTERS — not imported: that module is
383
+ #: documented PRIVATE, and this literal tuple is the exact list
384
+ #: unicode_logic_kit.mcp.syntax_spec's own "chemistry" topic already
385
+ #: publishes, so duplicating it here does not risk drifting from an
386
+ #: undocumented internal any more than that existing publication already
387
+ #: does). Kept in sync by tests/test_chem.py's vocabulary test.
388
+ _ATOM_LETTERS: Tuple[str, ...] = (
389
+ "c", "n", "o", "s", "p", "h", "f", "cl", "br", "i", "at")
390
+
391
+ _BOND_KINDS: Tuple[str, ...] = ("bSINGLE", "bDOUBLE", "bTRIPLE", "bAROMATIC")
392
+
393
+
394
+ def _atoms_by_type(structure: FiniteStructure) -> Dict[str, List[str]]:
395
+ """Domain individuals grouped by ChemLog atom-type letter — only the
396
+ letters this structure actually interprets (always all six for a
397
+ structure ``mol_to_structure`` built, per its own "always-populated
398
+ predicates" contract; guarded here anyway so this helper stays correct
399
+ for a hand-built or ``naming="paper"`` structure too, where it silently
400
+ returns fewer groups instead of raising)."""
401
+ return {letter: list(structure.individuals_with(letter))
402
+ for letter in _ATOM_LETTERS if structure.interprets(letter, 1)}
403
+
404
+
405
+ def _bonds(structure: FiniteStructure) -> List[dict]:
406
+ """Every stored bond, ONE entry per unordered pair (bond relations are
407
+ stored symmetrically — see chem.mol's module docstring — so naively
408
+ listing both directions would double-count every bond)."""
409
+ seen = set()
410
+ out: List[dict] = []
411
+ for kind in _BOND_KINDS:
412
+ if not structure.interprets(kind, 2):
413
+ continue
414
+ for a in structure.domain:
415
+ for b in structure.neighbors(kind, a):
416
+ pair = frozenset((a, b))
417
+ key = (kind, pair)
418
+ if key in seen:
419
+ continue
420
+ seen.add(key)
421
+ out.append({"kind": kind, "atoms": sorted(pair)})
422
+ return out
423
+
424
+
425
+ def _atom_notes(structure: FiniteStructure, individual: str) -> List[str]:
426
+ """Every OTHER unary fact about ``individual`` (hydrogen count, charge,
427
+ chirality, and — when ``computed=True`` — ring/aromaticity/connectivity),
428
+ excluding the bare element letter and ``atom`` (already carried by
429
+ :func:`_atoms_by_type`). Read directly off ``structure.signature()``
430
+ rather than a hardcoded predicate list, so it stays correct regardless of
431
+ naming scheme, ``computed=`` choice, or a future vocabulary extension —
432
+ the one thing this helper must never do is silently miss a fact a wider
433
+ hardcoded list happened not to anticipate."""
434
+ skip = set(_ATOM_LETTERS) | {"atom"}
435
+ held = [name for name, arity in structure.signature()
436
+ if arity == 1 and name not in skip
437
+ and structure.holds(name, (individual,))]
438
+ return sorted(held)
439
+
440
+
441
+ def _eval_payload(result: EvalResult,
442
+ spans: Optional[chem.SpanMap] = None) -> dict:
443
+ """The holds/exhausted/steps/witness/failing_conjunct bookkeeping shared
444
+ by :func:`check_molecule`, :func:`check_molecules` and
445
+ :func:`explain_molecule_failure` — factored out so the three tools report
446
+ the identical shape for the identical :class:`EvalResult` rather than
447
+ three independently rewritten (and driftable) renderings.
448
+
449
+ ``witness``/``failing_conjunct`` are populated only when ``holds`` is
450
+ decisively ``True``/``False`` respectively (never both, never either when
451
+ ``holds`` is ``None`` — the budget ran out) — see
452
+ :mod:`unicode_logic_kit.semantics.model_eval`'s own docstring for exactly
453
+ what shape of formula each can explain.
454
+
455
+ ``spans``, when given (the caller asked for ``with_spans=True`` and
456
+ parsing succeeded with a span table — see the module docstring's
457
+ "Spans" section), adds a ``"span"`` key beside ``failing_conjunct``: a
458
+ JSON rendering of its EXTENT :class:`~unicode_logic_kit.fol.spans.Span`
459
+ when this exact node has one, else ``None`` (:data:`~unicode_logic_kit.fol.spans.UNKNOWN`
460
+ — never guessed). ``spans is None`` (the default — every caller that
461
+ never passes ``with_spans=True``) omits the key entirely, so the
462
+ returned shape is byte-identical to before this parameter existed.
463
+ :func:`~unicode_logic_kit.semantics.model_eval.evaluate_detailed` never
464
+ rebuilds any node (see that module's ``_find_blame``), so
465
+ ``failing_conjunct`` is always a literal object out of the tree
466
+ ``spans`` was built over —
467
+ :meth:`~unicode_logic_kit.fol.spans.SpanMap.for_node` finds it directly, by
468
+ identity, no re-derivation (``spans`` is already bound to that tree —
469
+ see :func:`_parse_chem_with_spans`, which routes it through
470
+ :func:`unicode_logic_kit.chem.to_chemlog_names_with_spans`).
471
+ """
472
+ payload: dict = {"holds": result.holds, "exhausted": result.exhausted,
473
+ "steps": result.steps}
474
+ if result.holds is True:
475
+ payload["witness"] = result.witness
476
+ elif result.holds is False:
477
+ fc = result.failing_conjunct
478
+ entry = ({"unicode": fc.to_unicode_str(), "formula": fc.to_dict()}
479
+ if fc is not None else None)
480
+ if spans is not None and entry is not None:
481
+ entry["span"] = _span_dict(spans.for_node(fc).extent)
482
+ payload["failing_conjunct"] = entry
483
+ return payload
484
+
485
+
486
+ def _build_structure(smiles: str, include_computed: bool
487
+ ) -> Tuple[Optional[FiniteStructure], Optional[dict]]:
488
+ """:func:`unicode_logic_kit.chem.mol_to_structure` in the fixed
489
+ ``naming="chemlog"`` scheme every model-checking tool below needs (a
490
+ formula parsed via :func:`_parse_chem` is ALWAYS ChemLog-spelled, so the
491
+ structure it is checked against must be too — exposing ``naming`` here
492
+ would let a caller silently break that pairing, which is why it is not a
493
+ parameter on any tool below except :func:`molecule_to_structure` itself,
494
+ whose whole point is inspecting the structure directly).
495
+
496
+ ``(structure, None)`` on success; ``(None, error_dict)`` on failure — an
497
+ ``ImportError`` (RDKit missing) is the ``{"error": ...}`` shape (an
498
+ environment problem, not a bad argument); any other exception
499
+ (``mol_to_structure``'s documented ``ValueError`` — unparseable SMILES,
500
+ an unsupported element/bond, a residual hydrogen atom) is the uniform
501
+ ``argument="smiles"`` shape (see the module docstring's "Conventions").
502
+
503
+ A ``smiles`` that is not even a ``str`` (an ``int``, ``None``, a list, …
504
+ — plausible in a batch call like :func:`check_molecules`, where one
505
+ caller-side mistake in a long list should not be worse than any other
506
+ per-item error) is caught HERE, before ever reaching
507
+ ``mol_to_structure``, and reported in that SAME uniform
508
+ ``argument="smiles"`` shape rather than as a raw, undocumented
509
+ :class:`TypeError` — ``mol_to_structure`` itself raises exactly that
510
+ :class:`TypeError` for a non-``str``/non-``Mol`` argument (see its own
511
+ docstring), which is correct for a direct caller but WRONG for this
512
+ tool layer's documented contract (see the module docstring's
513
+ "Conventions": every bad argument is ``ok=False``, never a traceback) —
514
+ left uncaught it would have propagated straight out of
515
+ :func:`check_molecules`, aborting every remaining molecule in the batch
516
+ over a single bad list entry.
517
+ """
518
+ if not isinstance(smiles, str):
519
+ return None, _smiles_error(
520
+ f"smiles must be a str, got {type(smiles).__name__}")
521
+ try:
522
+ return chem.mol_to_structure(smiles, naming="chemlog",
523
+ computed=include_computed), None
524
+ except ImportError as exc:
525
+ return None, _error(exc)
526
+ except ValueError as exc:
527
+ return None, _smiles_error(str(exc))
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # 1. molecule_to_structure
532
+ # ---------------------------------------------------------------------------
533
+
534
+ def molecule_to_structure(smiles: str, *, naming: str = "chemlog",
535
+ include_computed: bool = True) -> dict:
536
+ """Translate a SMILES string into the finite structure a definition is
537
+ checked against — lets the caller SEE what it is generating formulas for.
538
+
539
+ Args:
540
+ smiles: a SMILES string (e.g. ``"CCO"`` for ethanol).
541
+ naming: ``"chemlog"`` (default — ``c``, ``bSINGLE``, ``has_1_hs``,
542
+ ...; what :func:`check_molecule` and friends require) or
543
+ ``"paper"`` (the human-readable prose spelling — ``c``,
544
+ ``singleBond``, ``has1H``, ...; use this only to reproduce a
545
+ worked example written that way, never to feed a
546
+ ``dialect="tptp_bare"`` formula, which always comes back
547
+ ChemLog-spelled).
548
+ include_computed: attach the five ring/aromaticity/connectivity
549
+ predicates that need transitive closure (not first-order
550
+ expressible over the stored facts — see
551
+ :mod:`unicode_logic_kit.chem.mol`'s module docstring). ``False``
552
+ gives a structure whose entire vocabulary IS first-order
553
+ expressible over what is stored.
554
+
555
+ Returns:
556
+ On success: ``{"ok": True, "structure": <FiniteStructure.to_dict()>,
557
+ "domain_size": <int>, "nonempty_predicates": [...],
558
+ "computed_predicates": [...]}`` — ``nonempty_predicates`` (each
559
+ ``"name/arity"``, matching ``structure``'s own key format) is the
560
+ quick answer to "what does this molecule actually have", without
561
+ scanning the full (mostly-empty, canonically-pre-populated —
562
+ see ``mol_to_structure``'s docstring) extension table by hand.
563
+
564
+ ``naming`` outside ``{"chemlog", "paper"}`` is a caller/config
565
+ mistake, not molecule data — reported as
566
+ ``{"error": {"type": "ValueError", ...}}``. An unparseable/invalid
567
+ SMILES is reported as ``{"ok": False, "argument": "smiles", ...}``
568
+ (see the module docstring's "Conventions"); RDKit not being
569
+ installed is reported as ``{"error": {"type": "ImportError", ...}}``
570
+ naming the install command.
571
+ """
572
+ if naming not in ("chemlog", "paper"):
573
+ return _error(ValueError(
574
+ f"molecule_to_structure: unknown naming={naming!r}, expected "
575
+ "'chemlog' or 'paper'"))
576
+ if not isinstance(smiles, str):
577
+ # Same contract as _build_structure below (see its own docstring):
578
+ # a non-str smiles is a bad ARGUMENT, not molecule data, and must
579
+ # come back in the uniform ok=False/argument shape rather than as a
580
+ # raw, undocumented TypeError leaking out of mol_to_structure.
581
+ return _smiles_error(
582
+ f"smiles must be a str, got {type(smiles).__name__}")
583
+ try:
584
+ structure = chem.mol_to_structure(smiles, naming=naming,
585
+ computed=include_computed)
586
+ except ImportError as exc:
587
+ return _error(exc)
588
+ except ValueError as exc:
589
+ return _smiles_error(str(exc))
590
+
591
+ payload = structure.to_dict()
592
+ nonempty = sorted(label for label, rows in payload["extensions"].items()
593
+ if rows)
594
+ return {
595
+ "ok": True,
596
+ "structure": payload,
597
+ "domain_size": len(payload["domain"]),
598
+ "nonempty_predicates": nonempty,
599
+ "computed_predicates": payload["computed"],
600
+ }
601
+
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # 2. check_molecule
605
+ # ---------------------------------------------------------------------------
606
+
607
+ def check_molecule(formula: str, smiles: str, *, all_different: bool = True,
608
+ dialect: str = "tptp_bare", include_computed: bool = True,
609
+ budget: Optional[int] = None,
610
+ with_spans: bool = False) -> dict:
611
+ """Does ``formula`` hold of the molecule ``smiles`` describes?
612
+
613
+ Parses ``formula`` (see the module docstring's "Chemical formulas MUST
614
+ be written in TPTP"), builds the molecule's structure, and model-checks
615
+ directly (:func:`unicode_logic_kit.semantics.model_eval.evaluate_detailed`
616
+ — no CNF/prenex normal form, so a negated existential or a top-level
617
+ disjunction never blows up the way it does for a normal-form-requiring
618
+ checker; see that module's own docstring).
619
+
620
+ Args:
621
+ formula: a class-definition formula, TEXT (see ``dialect``).
622
+ smiles: the molecule to check it against.
623
+ all_different: the ChemLog convention that separately ∃-bound
624
+ variables are pairwise distinct — see the module docstring for
625
+ why this defaults ``True`` here (opposite of the underlying
626
+ evaluator's own default).
627
+ dialect: ``"tptp_bare"`` (default) or any other
628
+ :func:`unicode_logic_kit.api.parse_any` hint.
629
+ include_computed: attach the ring/aromaticity/connectivity computed
630
+ predicates (see :func:`molecule_to_structure`).
631
+ budget: cap the evaluator's step count. ``None`` (default) is
632
+ unbounded. If given and exhausted, ``holds`` comes back ``None``
633
+ — an honest UNKNOWN, never a guessed ``False``.
634
+ with_spans: add a ``"span"`` byte-offset range beside
635
+ ``failing_conjunct`` — see the module docstring's "Spans"
636
+ section for the shape and its current dialect restriction.
637
+ Default ``False``: byte-identical to every call made before this
638
+ parameter existed.
639
+
640
+ Returns:
641
+ On success: ``{"ok": True, "holds": bool|None, "exhausted": bool,
642
+ "steps": int, "witness": {...}}`` (only when ``holds is True`` — a
643
+ variable->individual binding that makes the definition true) or
644
+ ``..., "failing_conjunct": {"unicode": ..., "formula": ...}}`` (only
645
+ when ``holds is False`` — the specific conjunct
646
+ :func:`evaluate_detailed`'s blame trail drilled down to; ``None`` if
647
+ the formula's shape has no natural single-conjunct blame — see that
648
+ function's own docstring for exactly which shapes it can explain).
649
+ With ``with_spans=True``, ``failing_conjunct`` additionally carries
650
+ a ``"span"`` key — see the module docstring's "Spans" section for
651
+ its shape.
652
+
653
+ A parse failure or invalid SMILES is the uniform ``ok=False``
654
+ argument/errors shape; a formula mentioning a predicate the
655
+ structure does not interpret
656
+ (:class:`~unicode_logic_kit.semantics.model_eval.UninterpretedSymbol`
657
+ — almost always a vocabulary typo, or a class predicate this call
658
+ never defined), an unsupported node type
659
+ (:class:`~unicode_logic_kit.semantics.model_eval.UnsupportedNode`), a
660
+ free variable, or (``with_spans=True`` only) an unsupported
661
+ ``dialect``/unavailable span layer are the ``{"error": {...}}``
662
+ shape.
663
+ """
664
+ spans = None
665
+ if with_spans:
666
+ node, spans, err = _parse_chem_with_spans(formula, dialect, argument="formula")
667
+ else:
668
+ node, err = _parse_chem(formula, dialect, argument="formula")
669
+ if err is not None:
670
+ return err
671
+ structure, err = _build_structure(smiles, include_computed)
672
+ if err is not None:
673
+ return err
674
+ try:
675
+ result = evaluate_detailed(node, structure,
676
+ all_different=all_different, budget=budget)
677
+ except (UninterpretedSymbol, UnsupportedNode, ValueError) as exc:
678
+ return _error(exc)
679
+ return {"ok": True, **_eval_payload(result, spans)}
680
+
681
+
682
+ # ---------------------------------------------------------------------------
683
+ # 3. check_molecules
684
+ # ---------------------------------------------------------------------------
685
+
686
+ def check_molecules(formula: str, smiles_list: List[str], *,
687
+ all_different: bool = True, dialect: str = "tptp_bare",
688
+ include_computed: bool = True,
689
+ budget: Optional[int] = None,
690
+ with_spans: bool = False) -> dict:
691
+ """:func:`check_molecule`, batched over a corpus — the core feedback
692
+ loop: one call shows which positive examples a definition actually
693
+ covers, without a round trip per molecule.
694
+
695
+ ``formula`` is parsed ONCE; a parse failure aborts the whole call (the
696
+ uniform ``ok=False`` shape, ``argument="formula"``). RDKit missing
697
+ likewise aborts the whole call (an environment problem, not a per-
698
+ molecule one). Everything else is per-molecule: one bad SMILES in the
699
+ list does not lose the results for the rest — it gets its own
700
+ ``{"smiles": ..., "ok": False, "error": ...}`` entry and the batch
701
+ continues, because seeing WHICH examples are unusable is itself part of
702
+ the feedback a correction loop needs.
703
+
704
+ Args:
705
+ formula: as :func:`check_molecule`.
706
+ smiles_list: the molecules to check it against, in order.
707
+ all_different, dialect, include_computed, budget, with_spans: as
708
+ :func:`check_molecule`.
709
+
710
+ Returns:
711
+ ``{"ok": True, "results": [...], "summary": {"total": int,
712
+ "holds": int, "fails": int, "unknown": int, "errors": int}}``.
713
+ Each ``results[i]`` is ``{"smiles": smiles_list[i], "ok": True,
714
+ **check_molecule-style holds/exhausted/steps/witness/
715
+ failing_conjunct}`` on success, or ``{"smiles": ..., "ok": False,
716
+ "error": <message>}`` on a per-molecule failure (bad SMILES, or the
717
+ same ``UninterpretedSymbol``/``UnsupportedNode``/free-variable
718
+ exceptions :func:`check_molecule` reports as ``{"error": ...}`` —
719
+ flattened to a plain message here since these entries already live
720
+ one level below the top-level ``ok``).
721
+ """
722
+ spans = None
723
+ if with_spans:
724
+ node, spans, err = _parse_chem_with_spans(formula, dialect, argument="formula")
725
+ else:
726
+ node, err = _parse_chem(formula, dialect, argument="formula")
727
+ if err is not None:
728
+ return err
729
+
730
+ results: List[dict] = []
731
+ counts = {"holds": 0, "fails": 0, "unknown": 0, "errors": 0}
732
+ for smiles in (smiles_list or []):
733
+ structure, build_err = _build_structure(smiles, include_computed)
734
+ if build_err is not None:
735
+ if "error" in build_err and build_err["error"]["type"] == "ImportError":
736
+ return build_err # environment-wide: abort the whole batch
737
+ message = build_err["errors"][0]["message"]
738
+ results.append({"smiles": smiles, "ok": False, "error": message})
739
+ counts["errors"] += 1
740
+ continue
741
+ try:
742
+ result = evaluate_detailed(node, structure,
743
+ all_different=all_different,
744
+ budget=budget)
745
+ except (UninterpretedSymbol, UnsupportedNode, ValueError) as exc:
746
+ results.append({"smiles": smiles, "ok": False, "error": str(exc)})
747
+ counts["errors"] += 1
748
+ continue
749
+ entry = {"smiles": smiles, "ok": True, **_eval_payload(result, spans)}
750
+ results.append(entry)
751
+ if result.holds is True:
752
+ counts["holds"] += 1
753
+ elif result.holds is False:
754
+ counts["fails"] += 1
755
+ else:
756
+ counts["unknown"] += 1
757
+
758
+ return {
759
+ "ok": True,
760
+ "results": results,
761
+ "summary": {"total": len(results), **counts},
762
+ }
763
+
764
+
765
+ # ---------------------------------------------------------------------------
766
+ # 4. explain_molecule_failure
767
+ # ---------------------------------------------------------------------------
768
+
769
+ def explain_molecule_failure(formula: str, smiles: str, *,
770
+ all_different: bool = True,
771
+ dialect: str = "tptp_bare",
772
+ include_computed: bool = True,
773
+ budget: Optional[int] = None,
774
+ with_spans: bool = False) -> dict:
775
+ """The full counterexample-explanation view for ONE molecule — the piece
776
+ a bare classifier does not give you (a plain model checker returns only
777
+ proved/not-proved, never a witness).
778
+
779
+ Runs the identical check :func:`check_molecule` does (same holds/
780
+ exhausted/steps/witness/failing_conjunct), plus the molecule's own
781
+ structure rendered for a human/LLM to read at a glance: every atom
782
+ grouped by element type, every other fact about each atom (hydrogen
783
+ count, charge, chirality, and — when ``include_computed=True`` —
784
+ ring/aromaticity/connectivity), and the full bond list. Reading the
785
+ failing conjunct against this is what lets a caller propose a concrete
786
+ fix (e.g. "the definition asks for a nitrogen but this molecule has
787
+ none") instead of just re-generating blind.
788
+
789
+ Args: as :func:`check_molecule` (including ``with_spans``).
790
+
791
+ Returns:
792
+ On success: ``{"ok": True, "formula_unicode": str, "domain": [...],
793
+ "domain_size": int, "atoms_by_type": {letter: [individuals]},
794
+ "atom_properties": {individual: [other unary facts]}, "bonds":
795
+ [{"kind": ..., "atoms": [a, b]}], **check_molecule-style holds/
796
+ exhausted/steps/witness/failing_conjunct}``. Same failure shapes as
797
+ :func:`check_molecule` for a bad formula/SMILES/vocabulary mismatch.
798
+ """
799
+ spans = None
800
+ if with_spans:
801
+ node, spans, err = _parse_chem_with_spans(formula, dialect, argument="formula")
802
+ else:
803
+ node, err = _parse_chem(formula, dialect, argument="formula")
804
+ if err is not None:
805
+ return err
806
+ structure, err = _build_structure(smiles, include_computed)
807
+ if err is not None:
808
+ return err
809
+ try:
810
+ result = evaluate_detailed(node, structure,
811
+ all_different=all_different, budget=budget)
812
+ except (UninterpretedSymbol, UnsupportedNode, ValueError) as exc:
813
+ return _error(exc)
814
+
815
+ return {
816
+ "ok": True,
817
+ "formula_unicode": node.to_unicode_str(),
818
+ "domain": list(structure.domain),
819
+ "domain_size": len(structure.domain),
820
+ "atoms_by_type": _atoms_by_type(structure),
821
+ "atom_properties": {ind: _atom_notes(structure, ind)
822
+ for ind in structure.domain},
823
+ "bonds": _bonds(structure),
824
+ **_eval_payload(result, spans),
825
+ }
826
+
827
+
828
+ # ---------------------------------------------------------------------------
829
+ # 5. simplify_definition
830
+ # ---------------------------------------------------------------------------
831
+
832
+ def simplify_definition(formula: str, *, all_different: bool = True,
833
+ dialect: str = "tptp_bare") -> dict:
834
+ """Shrink a class definition for model checking, and report exactly what
835
+ changed — the fix for the standard timeout cause (a redundant O(n²)
836
+ pairwise-inequality chain, e.g. 780 literals for "at least 40
837
+ carbons").
838
+
839
+ Two independent, differently-scoped rewrites are applied (see
840
+ :mod:`unicode_logic_kit.fol.simplify_check`'s own module docstring for the
841
+ full soundness argument of each):
842
+
843
+ 1. :func:`~unicode_logic_kit.fol.simplify_check.simplify_for_checking`
844
+ (``all_different=True`` by default here — see the module docstring)
845
+ — a conservative, EVERYWHERE-applicable pass: drops trivial ``t=t``
846
+ conjuncts, duplicate ∧/∨ operands, double negation, vacuous
847
+ quantifiers, and (under ``all_different``) a pairwise ``x≠y`` between
848
+ two separately ∃-bound variables, wherever in the tree it occurs —
849
+ even when interleaved with other content (a real bond literal between
850
+ the same two variables, say). This is what actually shrinks a real
851
+ definition, and is always applied.
852
+ 2. :func:`~unicode_logic_kit.fol.simplify_check.count_from_existential_chain`
853
+ — checked against the ORIGINAL (pre-simplification) formula, because
854
+ it recognises only a COMPLETE, EXACT top-level instance of the
855
+ distinct-witnesses pattern (every witness, every C(n,2) inequality,
856
+ nothing else — see that function's own docstring) and the ``≠``
857
+ literals rule (1) already removed would make an already-simplified
858
+ formula fail that exact-shape test even when the original genuinely
859
+ matched. Reported as ``count_pattern`` — separate, informational
860
+ ``diagnostic`` output, not folded into the main simplified formula
861
+ (see "Why the Count form is reported separately" below).
862
+
863
+ Why the Count form is reported separately
864
+ -------------------------------------------
865
+ TPTP (what :func:`check_molecule` reparses) has no native counting-
866
+ quantifier syntax — only the kit's own unicode surface syntax does. A
867
+ ``Count`` node can therefore never be rendered back to reusable
868
+ ``tptp_bare`` text, so folding it into the primary ``after_*`` output
869
+ would hand back a formula this same tool's sibling ``check_molecule``
870
+ could not accept. ``count_pattern`` is reported purely for information
871
+ (and for a caller working with a :class:`~unicode_logic_kit.fol.nodes.Node`
872
+ directly rather than through the TPTP round trip).
873
+
874
+ Args:
875
+ formula: the class definition to simplify.
876
+ all_different: passed to ``simplify_for_checking`` (default ``True``
877
+ — see the module docstring for why this differs from that
878
+ function's own, plain-FOL-safe default of ``False``).
879
+ dialect: as :func:`check_molecule`.
880
+
881
+ Returns:
882
+ ``{"ok": True, "before_unicode": str, "after_unicode": str,
883
+ "after_tptp": str|None, "changed": bool, "removed": [str, ...],
884
+ "removed_count": int, "count_pattern": {"detected": bool,
885
+ "folded_unicode": str, "note": str} | {"detected": False}}``.
886
+ ``after_tptp`` is ``None`` (with an explanatory
887
+ ``after_tptp_note``) only in the — for this rule set, unreachable in
888
+ practice — case that the simplified formula contains a node type
889
+ with no native TPTP rendering (a ``Count`` node, or an operator
890
+ outside classical FOL reached via a non-``tptp_bare`` ``dialect``);
891
+ ``simplify_for_checking`` itself never introduces one.
892
+ """
893
+ node, err = _parse_chem(formula, dialect, argument="formula")
894
+ if err is not None:
895
+ return err
896
+
897
+ counted = count_from_existential_chain(node)
898
+ result = simplify_for_checking(node, all_different=all_different)
899
+ simplified = result.simplified
900
+
901
+ payload = {
902
+ "ok": True,
903
+ "before_unicode": node.to_unicode_str(),
904
+ "after_unicode": simplified.to_unicode_str(),
905
+ "changed": result.changed,
906
+ "removed": list(result.removed),
907
+ "removed_count": len(result.removed),
908
+ }
909
+ # known_names maps the TPTP importer's forced-capitalised kit spelling
910
+ # back to the true ChemLog original for every symbol IN the declared
911
+ # chemical vocabulary (chem.KIT_TO_CHEMLOG) — Node.to_tptp() lowercases
912
+ # a predicate's ENTIRE name, which would silently corrupt a mixed-case
913
+ # ChemLog name like "bDOUBLE" into "bdouble" (a different, uninterpreted
914
+ # symbol); this is the same case-preserving renderer
915
+ # repair_tptp_formula's own `repaired_text` uses internally. A symbol
916
+ # OUTSIDE the chemical vocabulary (an auxiliary class predicate) falls
917
+ # back to that renderer's own best-effort un-capitalisation, which is
918
+ # exact for the common case (a name that arrived as a bare, TPTP-
919
+ # required-lowercase functor) — see fol.tptp_repair's module docstring
920
+ # for the one documented, narrow residual gap.
921
+ try:
922
+ payload["after_tptp"] = _render_tptp(simplified, dict(chem.KIT_TO_CHEMLOG))
923
+ except NotImplementedError:
924
+ payload["after_tptp"] = None
925
+ payload["after_tptp_note"] = (
926
+ "the simplified formula contains a node type with no native "
927
+ "TPTP rendering (e.g. a Count node, or an operator outside "
928
+ "classical FOL) — use after_unicode for display instead.")
929
+
930
+ if counted is not None:
931
+ payload["count_pattern"] = {
932
+ "detected": True,
933
+ "folded_unicode": counted.to_unicode_str(),
934
+ "note": (
935
+ "the ORIGINAL formula is exactly the distinct-witnesses "
936
+ "pattern for this counting quantifier. Reported for "
937
+ "information only — TPTP has no native counting syntax, so "
938
+ "this folded form cannot be re-submitted to check_molecule "
939
+ "as tptp_bare text; after_tptp above (still all_different-"
940
+ "simplified) evaluates the identical set of molecules under "
941
+ "the all_different convention."),
942
+ }
943
+ else:
944
+ payload["count_pattern"] = {"detected": False}
945
+ return payload
946
+
947
+
948
+ # ---------------------------------------------------------------------------
949
+ # 6. chemical_signature
950
+ # ---------------------------------------------------------------------------
951
+
952
+ # Grouping of chem.CHEMLOG_SIGNATURE's 35 declared predicates, for a caller
953
+ # that wants "which predicates cover charge" rather than a flat 35-entry
954
+ # list. Mirrors unicode_logic_kit.mcp.syntax_spec's own "chemistry" topic
955
+ # grouping exactly (both are hand-written against the SAME published
956
+ # vocabulary, chem.CHEMLOG_SIGNATURE — the assertion in
957
+ # chemical_signature() below is what keeps this list from silently drifting
958
+ # out of sync with it, rather than a shared import of chem's own PRIVATE
959
+ # _naming module).
960
+ _COMPUTED_PREDICATES: Tuple[str, ...] = (
961
+ "in_ring", "in_ring_of_size_3", "in_ring_of_size_4", "in_ring_of_size_5",
962
+ "in_ring_of_size_6", "in_ring_of_size_7", "in_ring_of_size_8",
963
+ "aromatic", "same_fragment", "carbon_connected",
964
+ )
965
+
966
+ _SIGNATURE_GROUPS: Dict[str, Tuple[str, ...]] = {
967
+ "atom_types": _ATOM_LETTERS,
968
+ "hydrogen_count": ("has_0_hs", "has_1_hs", "has_2_hs", "has_3_hs"),
969
+ "charge": ("charge0", "charge_m1", "charge_p1"),
970
+ "chirality": ("ChiralR", "ChiralS"),
971
+ "bonds": ("bSINGLE", "bDOUBLE", "bTRIPLE", "bAROMATIC", "bond",
972
+ "has_bond_to"),
973
+ "molecule_global": ("net_charge_neutral", "NetChargePositive",
974
+ "NetChargeNegative"),
975
+ "computed": _COMPUTED_PREDICATES,
976
+ }
977
+
978
+
979
+ def chemical_signature() -> dict:
980
+ """The full ChemLog vocabulary a chemical class definition may use
981
+ (https://github.com/sfluegel05/chemlog-peptides) — so a generator never
982
+ has to GUESS a predicate name or arity (a hallucinated predicate name is
983
+ a pure-SYNTAX failure that burns a whole generation attempt; an
984
+ unknown-predicate/wrong-arity slip is exactly what this catches before a
985
+ definition ever reaches model checking, via
986
+ ``api.check(formula, signature=chem.CHEMLOG_SIGNATURE)``).
987
+
988
+ Returns:
989
+ ``{"ok": True, "signature": <chem.CHEMLOG_SIGNATURE.to_dict()>,
990
+ "stored_predicates": [...], "computed_predicates": [...],
991
+ "groups": {"atom_types": [...], "hydrogen_count": [...], "charge":
992
+ [...], "chirality": [...], "bonds": [...], "molecule_global": [...],
993
+ "computed": [...]}, "dialect_note": str}``. ``signature`` is the
994
+ raw, declared ``(name, arity)`` table (40 predicates — see
995
+ :data:`unicode_logic_kit.chem.CHEMLOG_SIGNATURE`'s own docstring for
996
+ exactly what is and is not covered, e.g. only the CANONICAL
997
+ hydrogen-count 0..3 / charge -1/0/+1 range); ``stored_predicates``/
998
+ ``computed_predicates`` split that same set by whether
999
+ :func:`unicode_logic_kit.chem.mol_to_structure` materialises the
1000
+ extension up front or decides it algorithmically on demand (see that
1001
+ function's own "Why five predicates are COMPUTED" section);
1002
+ ``groups`` is the same 40 predicates organised by chemical
1003
+ role, for a caller that wants "what covers charge" rather than a
1004
+ flat list.
1005
+ """
1006
+ sig_dict = chem.CHEMLOG_SIGNATURE.to_dict()
1007
+ declared = set(sig_dict["predicates"])
1008
+ groups = {name: list(names) for name, names in _SIGNATURE_GROUPS.items()}
1009
+ grouped_names = {n for names in groups.values() for n in names}
1010
+ # Sanity, not a caller-facing failure mode: these groups are hand-
1011
+ # written against chem.CHEMLOG_SIGNATURE (see this section's own
1012
+ # docstring) rather than derived from it, precisely so this module never
1013
+ # imports chem's PRIVATE _naming — an assertion is what keeps that
1014
+ # duplication from silently drifting if the declared vocabulary ever
1015
+ # changes, per this kit's own "never approximate silently" convention.
1016
+ assert grouped_names <= declared, sorted(grouped_names - declared)
1017
+ stored = sorted(declared - set(_COMPUTED_PREDICATES))
1018
+ return {
1019
+ "ok": True,
1020
+ "signature": sig_dict,
1021
+ "stored_predicates": stored,
1022
+ "computed_predicates": list(_COMPUTED_PREDICATES),
1023
+ "groups": groups,
1024
+ "dialect_note": (
1025
+ "This vocabulary uses lowercase predicate names (c, o, n, "
1026
+ "bSINGLE, ...); in the kit's own unicode syntax those parse as "
1027
+ "variables/functions, never predicates. Write chemical class "
1028
+ "definitions in TPTP and pass dialect='tptp_bare' to "
1029
+ "check_molecule/check_molecules/explain_molecule_failure/"
1030
+ "simplify_definition rather than relying on auto-detection."),
1031
+ }