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,678 @@
1
+ """Adapter for C3PO (CHEBI Chemical Classification Program Ontology
2
+ Benchmark, Mungall, Malik, Korn, Reese, O'Boyle & Hastings, "Chemical
3
+ classification program synthesis using generative artificial intelligence",
4
+ Journal of Cheminformatics, 2025, DOI 10.1186/s13321-025-01092-3) — local
5
+ JSONL only, no network access.
6
+
7
+ C3PO pairs each CHEBI class with its natural-language definition plus the
8
+ SMILES of molecules known to be members -- the shape any FOL formalisation
9
+ of chemical classes has to be evaluated against. Unlike every other adapter
10
+ in this subpackage, C3PO's gold is NOT a reference FOL formula to compare a
11
+ prediction against by parsing/structural-equality (there is no gold FOL
12
+ here at all — see "Honesty" below); it is an EXECUTABLE membership decision:
13
+ a molecule either is or is not in the class, decidable by MODEL CHECKING a
14
+ candidate FOL definition against the molecule as a
15
+ :class:`~unicode_logic_kit.semantics.structures.FiniteStructure`
16
+ (:func:`unicode_logic_kit.chem.mol_to_structure`). This module accordingly
17
+ ships two things: :func:`load_c3po` (the usual streaming loader) and
18
+ :func:`score_definition`, which runs that model-checking evaluation over a
19
+ positive/negative SMILES split and reports precision/recall/F1 — WITHOUT
20
+ silently folding a failed evaluation into "negative" (see its own docstring).
21
+
22
+ Source and verified schema
23
+ ---------------------------
24
+ Source: https://huggingface.co/datasets/MonarchInit/C3PO (unauthenticated,
25
+ CC0-1.0). Verified directly, 2026-08-13:
26
+
27
+ * The HF dataset-viewer's queryable ``"default"``/``"train"`` config (via the
28
+ ``datasets-server`` ``/first-rows`` API) exposes exactly the CLASSES table,
29
+ 9 flat fields, confirmed against real fetched rows (CHEBI:10036 "wax
30
+ ester", CHEBI:131565 "steroid aldehyde") AND cross-checked against the
31
+ ``ChemicalClass`` Pydantic model in the benchmark's own generator source,
32
+ https://github.com/cmungall/c3p (``c3p/datamodel.py``, fetched directly),
33
+ which agrees field-for-field:
34
+
35
+ * ``"id"`` -- ``str``, a CHEBI curie (e.g.
36
+ ``"CHEBI:10036"``) -- globally unique, unlike FOLIO's/ProofWriter's
37
+ grouping ids (see their modules' "Id resolution" notes) -- so this
38
+ loader uses it directly as :attr:`DatasetExample.id`, no positional
39
+ fallback scheme needed for the common case.
40
+ * ``"name"`` -- ``str``, the class's ``rdfs:label``.
41
+ * ``"definition"`` -- ``str``, the natural-language
42
+ definition an FOL-formalisation system (an LLM, or a human) is
43
+ asked to translate. THIS is what :func:`load_c3po` maps onto
44
+ ``nl_conclusion`` -- see "Field mapping" below.
45
+ * ``"parents"`` -- list of parent-class CHEBI curies.
46
+ * ``"xrefs"`` -- list of cross-references to other
47
+ databases (KEGG, MetaCyc, ...); absent for most classes (confirmed via
48
+ the mirror's own column statistics: only 367 of 1364 classes carry any).
49
+ * ``"all_positive_examples"`` -- list of SMILES strings: molecules
50
+ CHEBI records as members of this class. THE gold used by
51
+ :func:`score_definition`'s ``positives=`` argument.
52
+ * ``"parents_count"`` / ``"xrefs_count"`` / ``"all_positive_examples_count"``
53
+ -- ``int``/``float``/``int`` -- redundant with the length of the
54
+ corresponding list field (kept verbatim, not recomputed, in case a real
55
+ export ever has them drift).
56
+
57
+ Note for anyone working from prose descriptions of this benchmark rather
58
+ than the verified schema: there is NO "number of transitive subclasses" field
59
+ anywhere in this table -- ``parents_count`` counts DIRECT PARENTS, the
60
+ opposite direction. Whatever prior description mentioned a transitive-
61
+ subclass count was not corroborated by the live schema and this loader
62
+ does not invent one.
63
+
64
+ * **``structures.csv``** (every CHEBI structure with its SMILES and its
65
+ class memberships, referenced by the README as containing "a flag ... [for
66
+ the] validation split") is a REAL sibling file in the same HF repo but
67
+ could NOT be verified at the row level: it is a 38.5 MB Git-LFS blob, over
68
+ this environment's fetch size limit, is not exposed through the
69
+ ``datasets-server`` rows API (only the classes table is), and the repo has
70
+ no successful parquet auto-conversion to sample instead (checked: the
71
+ ``/parquet`` endpoint reports a failed conversion). The upstream Pydantic
72
+ model (``c3p/datamodel.py``'s ``ChemicalStructure``) declares only
73
+ ``name``/``smiles``; the validation-split flag the README describes is not
74
+ visible on that model at all, so its ACTUAL column name in the exported
75
+ CSV is unverified. Recorded honestly rather than glossed over: **this
76
+ adapter does not parse ``structures.csv``** rather than guess a column
77
+ name that could silently mislabel every molecule's split. This is not a
78
+ functional gap for the documented use: :func:`score_definition` takes its
79
+ ``positives``/``negatives`` SMILES directly from the CALLER (typically
80
+ ``example.meta["positive_smiles"]`` for positives, plus whatever negative
81
+ pool the caller assembles) rather than reading a structures table itself.
82
+
83
+ License: **CC0-1.0** (public-domain dedication), per the repo's own
84
+ ``cardData``/tags -- verified directly, not inferred. Unlike FOLIO
85
+ (CC-BY-SA-4.0, share-alike) or MALLS (CC-BY-NC-4.0, non-commercial), C3PO
86
+ carries no redistribution restriction at all; this loader still never
87
+ downloads or embeds the real data (see below), simply because there is no
88
+ license reason it would need to.
89
+
90
+ Field mapping
91
+ --------------
92
+ C3PO has no premise/conclusion entailment structure (like MALLS, not like
93
+ FOLIO) AND no gold FOL of any kind (like ProofWriter's flat NL split, not
94
+ like FOLIO/MALLS) -- see "Honesty" above. Mapped onto
95
+ :class:`~unicode_logic_kit.eval.datasets.DatasetExample`:
96
+
97
+ * ``id`` -- the record's own ``"id"`` (a CHEBI curie) verbatim, or the
98
+ positional fallback ``f"c3po:{line_no}"`` for a record with none.
99
+ * ``nl_premises`` / ``fol_premises`` -- always ``()`` (no premise structure).
100
+ * ``nl_conclusion`` -- ``"definition"`` verbatim (the NL text a
101
+ formalisation system is asked to translate).
102
+ * ``fol_conclusion`` -- always ``None`` (C3PO ships no FOL gold at all;
103
+ consequently :func:`~unicode_logic_kit.eval.datasets.audit_examples` run over
104
+ C3PO examples is VACUOUSLY ``ok=True`` for every one of them -- exactly the
105
+ same caveat as :mod:`~unicode_logic_kit.eval.datasets.proofwriter`'s
106
+ "Honesty" section, for the identical reason: nothing to parse means
107
+ nothing can fail to parse).
108
+ * ``label`` -- always ``None``. C3PO's "gold" is not a single categorical
109
+ label per example (unlike FOLIO's True/False/Uncertain) -- it is the
110
+ per-MOLECULE membership decision :func:`score_definition` computes, which
111
+ does not fit this dataclass's one-label-per-example slot.
112
+ * ``meta`` -- ``"chebi_id"`` (same value as ``id``, kept explicit alongside
113
+ it), ``"name"``, ``"positive_smiles"`` (tuple, from
114
+ ``"all_positive_examples"`` -- the field :func:`score_definition`'s
115
+ ``positives=`` argument is meant to be filled from), ``"parents"``,
116
+ ``"parents_count"``, ``"xrefs"``, ``"xrefs_count"``,
117
+ ``"all_positive_examples_count"``, ``"line_no"``, plus any other key the
118
+ record happens to carry (forward-compatibility, mirroring every other
119
+ adapter in this subpackage).
120
+
121
+ This module never downloads anything. The real C3PO ``classes.csv`` is a
122
+ CSV with (per the verified schema above) LIST-valued cells for
123
+ ``parents``/``xrefs``/``all_positive_examples``, whose exact upstream
124
+ serialisation delimiter could not itself be verified (the raw CSV bytes were
125
+ unreachable -- see "structures.csv" above for why); rather than guess a
126
+ delimiter that might silently mis-split a SMILES string containing the wrong
127
+ character, :func:`load_c3po` reads local JSONL with the SAME COLUMN NAMES
128
+ and plain JSON list values for those three fields -- trivially produced from
129
+ a real download with, e.g.::
130
+
131
+ import ast, json, pandas as pd
132
+ df = pd.read_csv("classes.csv")
133
+ for col in ("parents", "xrefs", "all_positive_examples"):
134
+ df[col] = df[col].apply(lambda v: ast.literal_eval(v) if isinstance(v, str) else v)
135
+ df.to_json("classes.jsonl", orient="records", lines=True)
136
+
137
+ (swap ``ast.literal_eval`` for ``json.loads`` if a real download turns out to
138
+ already use JSON-list syntax in its cells -- unverified either way here).
139
+ """
140
+
141
+ import json
142
+ from pathlib import Path
143
+ from typing import (
144
+ FrozenSet, Iterable, Iterator, List, MutableMapping, Optional,
145
+ Tuple, Union,
146
+ )
147
+
148
+ from dataclasses import dataclass
149
+
150
+ from ...fol.nodes import Node
151
+ from ...fol.tptp_input import TptpParsingError
152
+ from ...chem import mol_to_structure, parse_chemlog_tptp, to_chemlog_names
153
+ from ...chem.cache import StructureBuildError
154
+ from ...semantics.model_eval import (
155
+ evaluate_detailed, UninterpretedSymbol, UnsupportedNode,
156
+ )
157
+ from ...semantics.structures import FiniteStructure
158
+ from ._base import DatasetExample, _register_dataset_info
159
+
160
+ __all__ = ["load_c3po", "DefinitionScore", "score_definition"]
161
+
162
+ _register_dataset_info(
163
+ "c3po",
164
+ license="CC0-1.0",
165
+ source_url="https://huggingface.co/datasets/MonarchInit/C3PO",
166
+ citation_hint=(
167
+ "Mungall, Christopher J., Adnan Malik, Daniel R. Korn, Justin T. "
168
+ "Reese, Noel M. O'Boyle, and Janna Hastings. \"Chemical "
169
+ "classification program synthesis using generative artificial "
170
+ "intelligence.\" Journal of Cheminformatics (2025). "
171
+ "DOI:10.1186/s13321-025-01092-3."
172
+ ),
173
+ )
174
+
175
+ # The verified classes.csv/first-rows column set (see module docstring).
176
+ _KNOWN_KEYS = frozenset({
177
+ "id", "name", "definition", "parents", "xrefs", "all_positive_examples",
178
+ "parents_count", "xrefs_count", "all_positive_examples_count",
179
+ })
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # load_c3po
184
+ # ---------------------------------------------------------------------------
185
+
186
+ def _example_from_record(record: dict, line_no: int,
187
+ known_bad_ids: FrozenSet[str]) -> DatasetExample:
188
+ chebi_id = record.get("id")
189
+ example_id = str(chebi_id) if chebi_id is not None else f"c3po:{line_no}"
190
+ definition = record.get("definition")
191
+ positive_smiles = tuple(record.get("all_positive_examples") or ())
192
+
193
+ meta = {
194
+ "chebi_id": chebi_id,
195
+ "name": record.get("name"),
196
+ "positive_smiles": positive_smiles,
197
+ "parents": tuple(record.get("parents") or ()),
198
+ "parents_count": record.get("parents_count"),
199
+ "xrefs": tuple(record.get("xrefs") or ()),
200
+ "xrefs_count": record.get("xrefs_count"),
201
+ "all_positive_examples_count": record.get("all_positive_examples_count"),
202
+ }
203
+ # Forward-compatibility: preserve any key this record carries beyond the
204
+ # verified schema, same convention as every other adapter here.
205
+ for key, value in record.items():
206
+ if key not in _KNOWN_KEYS:
207
+ meta.setdefault(key, value)
208
+ meta["line_no"] = line_no
209
+
210
+ return DatasetExample(
211
+ id=example_id,
212
+ nl_premises=(),
213
+ fol_premises=(),
214
+ nl_conclusion=definition,
215
+ fol_conclusion=None,
216
+ label=None,
217
+ known_bad=example_id in known_bad_ids,
218
+ meta=meta,
219
+ )
220
+
221
+
222
+ def load_c3po(path: Union[str, Path], *,
223
+ known_bad_ids: FrozenSet[str] = frozenset()) -> Iterator[DatasetExample]:
224
+ """Stream :class:`~unicode_logic_kit.eval.datasets.DatasetExample` from a
225
+ local C3PO classes JSONL file (verified schema -- see module docstring).
226
+
227
+ Args:
228
+ path: path to a local ``.jsonl`` file -- one ``{"id", "name",
229
+ "definition", "parents", "xrefs", "all_positive_examples",
230
+ "parents_count", "xrefs_count", "all_positive_examples_count"}``
231
+ object per non-blank line (see module docstring for producing
232
+ this from a real ``classes.csv`` download). NEVER downloaded by
233
+ this function.
234
+ known_bad_ids: ids (the record's own CHEBI curie, or the positional
235
+ fallback ``f"c3po:{line_no}"`` for a record with none) whose
236
+ ``all_positive_examples`` is known to be broken (e.g. a SMILES
237
+ that fails to parse, found by a prior audit). Every yielded
238
+ example with a matching id gets ``known_bad=True``.
239
+
240
+ Yields:
241
+ One :class:`~unicode_logic_kit.eval.datasets.DatasetExample` per
242
+ non-blank JSONL line, in file order -- see module docstring's "Field
243
+ mapping" for exactly what goes where. ``fol_premises`` is always
244
+ ``()`` and ``fol_conclusion`` is always ``None`` (C3PO has no FOL
245
+ gold of any kind).
246
+
247
+ Raises:
248
+ FileNotFoundError: ``path`` does not exist.
249
+ json.JSONDecodeError: a non-blank line is not valid JSON -- raised,
250
+ not swallowed (a malformed dataset file must fail loudly).
251
+ """
252
+ path = Path(path)
253
+ with path.open("r", encoding="utf-8") as fh:
254
+ for line_no, raw_line in enumerate(fh):
255
+ line = raw_line.strip()
256
+ if not line:
257
+ continue
258
+ record = json.loads(line)
259
+ yield _example_from_record(record, line_no, known_bad_ids)
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # score_definition -- model-checking a candidate FOL definition
264
+ # ---------------------------------------------------------------------------
265
+
266
+ @dataclass(frozen=True)
267
+ class DefinitionScore:
268
+ """Outcome of :func:`score_definition`: a confusion matrix PLUS two
269
+ honest failure categories that are never folded into it.
270
+
271
+ ``tp``/``fp``/``fn``/``tn`` count only molecules the evaluator reached a
272
+ DEFINITE two-valued verdict on. ``n_errors`` (with per-item detail in
273
+ ``errors``) counts molecules where building the structure or evaluating
274
+ the formula raised one of the documented, expected exceptions (an
275
+ unparseable/invalid SMILES; a formula predicate/constant the structure
276
+ does not interpret; a formula outside the evaluator's supported
277
+ fragment; a free variable) -- these are NEITHER a positive NOR a
278
+ negative prediction, so they must never silently become one. Likewise
279
+ ``n_exhausted`` (``exhausted``) counts molecules where the evaluator's
280
+ step ``budget`` ran out before a definite answer -- an honest UNKNOWN,
281
+ not a guessed ``False``. ``n_positives``/``n_negatives`` are the raw
282
+ input counts, so ``tp + fn + (errors/exhausted tagged "positive") ==
283
+ n_positives`` always holds (and symmetrically for negatives) -- every
284
+ input molecule is accounted for exactly once, in exactly one bucket.
285
+
286
+ ``precision``/``recall``/``f1`` are ``None`` when their denominator would
287
+ be zero (e.g. ``precision`` needs at least one of ``tp``/``fp``) --
288
+ never silently reported as ``0.0``, which would misrepresent "no data"
289
+ as "definitely wrong". When both ``precision`` and ``recall`` ARE
290
+ defined but sum to zero (both exactly 0.0), ``f1`` is reported as
291
+ ``0.0`` by the standard convention (the ``2pr/(p+r)`` formula's own
292
+ removable singularity at the origin), not ``None``.
293
+ """
294
+
295
+ tp: int
296
+ fp: int
297
+ fn: int
298
+ tn: int
299
+ n_positives: int
300
+ n_negatives: int
301
+ precision: Optional[float]
302
+ recall: Optional[float]
303
+ f1: Optional[float]
304
+ n_errors: int
305
+ n_exhausted: int
306
+ errors: Tuple[dict, ...] = ()
307
+ exhausted: Tuple[dict, ...] = ()
308
+
309
+ def to_dict(self) -> dict:
310
+ return {
311
+ "tp": self.tp, "fp": self.fp, "fn": self.fn, "tn": self.tn,
312
+ "n_positives": self.n_positives, "n_negatives": self.n_negatives,
313
+ "precision": self.precision, "recall": self.recall, "f1": self.f1,
314
+ "n_errors": self.n_errors, "n_exhausted": self.n_exhausted,
315
+ "errors": list(self.errors), "exhausted": list(self.exhausted),
316
+ }
317
+
318
+
319
+ #: Sentinel cached in place of a :class:`FiniteStructure` when
320
+ #: :func:`unicode_logic_kit.chem.mol_to_structure` refused a SMILES (invalid
321
+ #: syntax, unsupported element/bond, ...) -- see :func:`_structure_for`.
322
+ #: Caching the failure too (not just successes) means a SMILES that fails once
323
+ #: is never re-run through RDKit on a later call sharing the same
324
+ #: ``structure_cache``, the same cost argument the module docstring makes for
325
+ #: successes.
326
+ #:
327
+ #: ALIASED, not redefined: a campaign shares one
328
+ #: :class:`~unicode_logic_kit.chem.cache.StructureCache` between this module and
329
+ #: :mod:`unicode_logic_kit.eval.chem_batch`, and two structurally identical
330
+ #: sentinel classes would make each module's ``isinstance`` check silently
331
+ #: miss the other's cached failures -- reporting them as structures and
332
+ #: crashing in the evaluator instead.
333
+ _StructureBuildError = StructureBuildError
334
+
335
+
336
+ #: :func:`_structure_for`'s cache key -- see that function's own docstring
337
+ #: for why it is NOT just the bare SMILES string. Identical to
338
+ #: :data:`unicode_logic_kit.chem.cache.CacheKey`, so a
339
+ #: :class:`~unicode_logic_kit.chem.cache.StructureCache` can be handed straight
340
+ #: to :func:`score_definition`'s ``structure_cache``.
341
+ _CacheKey = Tuple[str, str, bool, bool]
342
+
343
+
344
+ def _structure_for(
345
+ smiles: str, cache: MutableMapping[_CacheKey, object], *,
346
+ naming: str = "chemlog", aromatic: bool, computed: bool,
347
+ ) -> Union[FiniteStructure, _StructureBuildError]:
348
+ """``cache[(smiles, naming, aromatic, computed)]``, building and inserting
349
+ it first if absent.
350
+
351
+ The cache key is the FULL triple, not the bare SMILES string, because
352
+ ``aromatic``/``computed`` are STRUCTURE-DETERMINING parameters, not
353
+ incidental ones: :func:`unicode_logic_kit.chem.mol_to_structure` builds a
354
+ genuinely different :class:`FiniteStructure` for the same SMILES
355
+ depending on either (``aromatic=False`` Kekulizes the bond typing
356
+ instead of keeping ``bAROMATIC``; ``computed=False`` omits the five
357
+ ring/aromaticity/connectivity predicates entirely -- see that
358
+ function's own module docstring). Keying on the bare SMILES alone would
359
+ let one call's ``aromatic=True`` structure silently answer a LATER
360
+ call's ``aromatic=False`` request for the identical molecule whenever
361
+ the two calls share a ``structure_cache`` -- a real, reproduced bug:
362
+ two :func:`score_definition` calls sharing one cache, one with
363
+ ``aromatic=True`` (checking ``bAROMATIC`` on benzene, correctly True)
364
+ and a second with ``aromatic=False`` on the same SMILES (which should
365
+ make ``bAROMATIC`` empty -- Kekulized bond typing has none), returned
366
+ the FIRST call's stale ``aromatic=True`` structure to the second before
367
+ this fix, silently reporting the wrong verdict rather than raising or
368
+ rebuilding.
369
+
370
+ ``naming`` is the third structure-determining parameter and is in the key
371
+ for the same reason, even though this module always passes
372
+ ``"chemlog"``: every formula path here ends up in ChemLog spelling
373
+ (:func:`unicode_logic_kit.chem.parse_chemlog_tptp` renames back to it, and
374
+ the ``dialect="unicode"`` path applies
375
+ :func:`unicode_logic_kit.chem.to_chemlog_names` explicitly -- see
376
+ :func:`_resolve_formula`), so the structure side has no reason to diverge
377
+ from it here. It is in the key anyway because the cache is no longer
378
+ private to this module: :class:`unicode_logic_kit.chem.cache.StructureCache`
379
+ is shared across a whole campaign, and other entry points DO expose
380
+ ``naming`` (``mcp.chem_tools.molecule_to_structure``). Leaving naming out
381
+ kept this module correct only by an invariant nothing enforced -- the
382
+ moment one cache serves both, a ``naming="paper"`` request would be
383
+ answered with a ChemLog-spelled structure and every predicate would come
384
+ back uninterpreted.
385
+
386
+ Only :class:`ValueError` from ``mol_to_structure`` (a bad/unsupported
387
+ molecule) is caught and turned into a cached
388
+ :class:`_StructureBuildError` -- :class:`ImportError` (RDKit not
389
+ installed) and :class:`TypeError` (a caller passing something that is
390
+ not even a string) are environment/caller bugs, not a per-molecule data
391
+ problem, and are left to propagate immediately rather than being
392
+ reported as one identical "error" per molecule in the corpus.
393
+ """
394
+ key: _CacheKey = (smiles, naming, aromatic, computed)
395
+ if key in cache:
396
+ return cache[key]
397
+ try:
398
+ structure = mol_to_structure(
399
+ smiles, naming=naming, aromatic=aromatic, computed=computed)
400
+ except ValueError as exc:
401
+ result: Union[FiniteStructure, _StructureBuildError] = (
402
+ _StructureBuildError(f"{type(exc).__name__}: {exc}"))
403
+ else:
404
+ result = structure
405
+ cache[key] = result
406
+ return result
407
+
408
+
409
+ def _resolve_formula(formula: Union[Node, str], dialect: str) -> Node:
410
+ """``formula`` as a :class:`Node`, in ChemLog predicate spelling.
411
+
412
+ ``formula`` already a :class:`Node` is returned UNCHANGED -- this
413
+ function trusts the caller to have it in the right vocabulary already
414
+ (typically because it came from :func:`unicode_logic_kit.chem.
415
+ parse_chemlog_tptp` itself, or from :func:`_resolve_formula`'s own
416
+ ``dialect="unicode"`` branch on an earlier call).
417
+
418
+ ``dialect="tptp"`` (the default -- this is the format an LLM asked for
419
+ FOL emits, and the format ChemLog's own axiom files use):
420
+ parsed via :func:`unicode_logic_kit.chem.parse_chemlog_tptp`, which
421
+ already renames the chemical vocabulary back to ChemLog's lower-case
422
+ spelling (bare TPTP capitalises every predicate on import -- see that
423
+ function's own docstring) and applies the LLM-syntax repair layer.
424
+
425
+ ``dialect="unicode"``: parsed via :func:`unicode_logic_kit.api.parse_any`
426
+ (this kit's own ∃/∧/... surface syntax, predicates conventionally
427
+ CAPITALISED, e.g. ``"∃x (C(x) ∧ O(x))"``), then explicitly renamed to
428
+ ChemLog spelling with :func:`unicode_logic_kit.chem.to_chemlog_names` --
429
+ without that second step a kit-syntax formula's ``C(x)``/``O(x)`` would
430
+ never match a structure's stored ``c``/``o`` predicates and every
431
+ molecule would fail with :class:`~unicode_logic_kit.semantics.model_eval.
432
+ UninterpretedSymbol`, which is a genuinely different failure than "this
433
+ formula does not actually hold" and would be a confusing default.
434
+
435
+ Raises:
436
+ TypeError: ``formula`` is neither a :class:`Node` nor a ``str``.
437
+ ValueError: ``dialect`` is neither ``"tptp"`` nor ``"unicode"``; OR
438
+ the text does not parse AT ALL under the chosen dialect -- this
439
+ is a HARD, IMMEDIATE failure (there is nothing any molecule
440
+ could be evaluated against), deliberately NOT reported as a
441
+ per-molecule error the way an :class:`~unicode_logic_kit.
442
+ semantics.model_eval.UninterpretedSymbol` (formula parses fine,
443
+ just does not match this structure's vocabulary) is -- see
444
+ :func:`score_definition`'s docstring for that distinction. Note:
445
+ :func:`unicode_logic_kit.chem.parse_chemlog_tptp` itself actually
446
+ raises :class:`~unicode_logic_kit.fol.tptp_input.TptpParsingError`
447
+ for a genuine syntax error (its own docstring's ``ValueError``
448
+ claim does not match this kit version's actual behaviour, verified
449
+ directly here) -- caught and re-raised as ``ValueError`` below so
450
+ this function keeps ONE stable exception contract regardless of
451
+ which dialect the caller chose.
452
+ """
453
+ if isinstance(formula, Node):
454
+ return formula
455
+ if not isinstance(formula, str):
456
+ raise TypeError(
457
+ "c3po.score_definition: formula must be a Node or str, got "
458
+ f"{type(formula).__name__}")
459
+
460
+ if dialect == "tptp":
461
+ try:
462
+ return parse_chemlog_tptp(formula)
463
+ except (TptpParsingError, ValueError) as exc:
464
+ raise ValueError(
465
+ "c3po.score_definition: formula did not parse under "
466
+ f"dialect='tptp': {exc}") from exc
467
+
468
+ if dialect == "unicode":
469
+ from ... import api # lazy: avoid import cycle
470
+
471
+ parsed = api.parse_any(formula)
472
+ if not parsed.ok:
473
+ detail = parsed.errors[-1]["message"] if parsed.errors else "unparseable"
474
+ raise ValueError(
475
+ "c3po.score_definition: formula did not parse under "
476
+ f"dialect='unicode' ({detail}): {formula!r}")
477
+ return to_chemlog_names(parsed.formula)
478
+
479
+ raise ValueError(
480
+ f"c3po.score_definition: dialect must be 'tptp' or 'unicode', got "
481
+ f"{dialect!r}")
482
+
483
+
484
+ def _classify(
485
+ smiles_list: Iterable[str], split_name: str, formula: Node, *,
486
+ all_different: bool, budget: Optional[int], aromatic: bool, computed: bool,
487
+ structure_cache: MutableMapping[_CacheKey, object],
488
+ errors: List[dict], exhausted: List[dict],
489
+ ) -> Tuple[int, int]:
490
+ """Evaluate ``formula`` against every molecule in ``smiles_list``.
491
+
492
+ Returns ``(n_holds, n_fails)`` -- how many molecules ``formula`` held /
493
+ did not hold on, EXCLUDING anything routed into ``errors``/``exhausted``
494
+ (appended to in place). The caller reinterprets ``(n_holds, n_fails)``
495
+ as ``(tp, fn)`` for the positive split or ``(fp, tn)`` for the negative
496
+ one -- see :func:`score_definition`.
497
+ """
498
+ n_holds = 0
499
+ n_fails = 0
500
+ for smiles in smiles_list:
501
+ structure = _structure_for(
502
+ smiles, structure_cache, aromatic=aromatic, computed=computed)
503
+ if isinstance(structure, _StructureBuildError):
504
+ errors.append({
505
+ "smiles": smiles, "split": split_name, "stage": "structure",
506
+ "kind": "StructureBuildError", "message": structure.message,
507
+ })
508
+ continue
509
+ try:
510
+ result = evaluate_detailed(
511
+ formula, structure, all_different=all_different, budget=budget)
512
+ except (UninterpretedSymbol, UnsupportedNode, ValueError) as exc:
513
+ errors.append({
514
+ "smiles": smiles, "split": split_name, "stage": "evaluate",
515
+ "kind": type(exc).__name__, "message": str(exc),
516
+ })
517
+ continue
518
+ if result.exhausted:
519
+ exhausted.append({
520
+ "smiles": smiles, "split": split_name, "steps": result.steps,
521
+ })
522
+ continue
523
+ if result.holds:
524
+ n_holds += 1
525
+ else:
526
+ n_fails += 1
527
+ return n_holds, n_fails
528
+
529
+
530
+ def _safe_div(numerator: int, denominator: int) -> Optional[float]:
531
+ return None if denominator == 0 else numerator / denominator
532
+
533
+
534
+ def _f1_of(precision: Optional[float], recall: Optional[float]) -> Optional[float]:
535
+ if precision is None or recall is None:
536
+ return None
537
+ if precision + recall == 0:
538
+ return 0.0
539
+ return 2 * precision * recall / (precision + recall)
540
+
541
+
542
+ def score_definition(
543
+ formula: Union[Node, str],
544
+ positives: Iterable[str],
545
+ negatives: Iterable[str],
546
+ *,
547
+ dialect: str = "tptp",
548
+ all_different: bool = True,
549
+ budget: Optional[int] = None,
550
+ aromatic: bool = True,
551
+ computed: bool = True,
552
+ structure_cache: Optional[MutableMapping[_CacheKey, object]] = None,
553
+ ) -> DefinitionScore:
554
+ """Model-check ``formula`` against a positive/negative SMILES split.
555
+
556
+ This is C3PO-style evaluation: ``formula`` is a CLOSED FOL sentence over
557
+ the ChemLog vocabulary (typically the right-hand side of a ChEBI class's
558
+ ``<=>`` definition -- e.g. the ``?[A1,A2,A3]: (...)`` half of a
559
+ ``carboxylicAcid`` definition, NOT the biconditional itself: the
560
+ left-hand class name is a bare 0-ary atom this module's structures do
561
+ not interpret, so evaluating the whole biconditional would raise
562
+ :class:`~unicode_logic_kit.semantics.model_eval.UninterpretedSymbol` on
563
+ every molecule). It is evaluated ONCE PER MOLECULE, each against its OWN
564
+ :func:`~unicode_logic_kit.chem.mol_to_structure` structure -- there is no
565
+ cross-molecule comparison; "classifying" a molecule means deciding
566
+ whether ``formula`` is true in that ONE molecule's structure.
567
+
568
+ Args:
569
+ formula: a parsed :class:`~unicode_logic_kit.fol.nodes.Node`, or TPTP/
570
+ kit-unicode text (see ``dialect=``) -- resolved ONCE, before the
571
+ per-molecule loop (see :func:`_resolve_formula`).
572
+ positives: SMILES of molecules that SHOULD satisfy ``formula`` (the
573
+ gold-positive set -- typically a C3PO example's
574
+ ``meta["positive_smiles"]``).
575
+ negatives: SMILES of molecules that should NOT (the gold-negative
576
+ set -- this module does not source it for you; see the module
577
+ docstring's ``structures.csv`` note for why).
578
+ dialect: ``"tptp"`` (default) or ``"unicode"`` -- see
579
+ :func:`_resolve_formula`. Ignored if ``formula`` is already a
580
+ :class:`Node`.
581
+ all_different: forwarded to
582
+ :func:`~unicode_logic_kit.semantics.model_eval.evaluate_detailed`.
583
+ Defaults to ``True`` -- NOT this evaluator's own library default
584
+ (which is ``False``, plain FOL semantics) -- because ChemLog's
585
+ own TPTP convention (documented in ``model_eval``'s module
586
+ docstring) is that separately-quantified existentials denote
587
+ PAIRWISE DISTINCT individuals with no explicit ``≠`` ever
588
+ written, and ``dialect="tptp"`` formulas are exactly that
589
+ convention's own output. Pass ``all_different=False`` explicitly
590
+ for a formula that does NOT follow it (e.g. a hand-written
591
+ ``dialect="unicode"`` formula using genuine plain-FOL semantics).
592
+ budget: forwarded to ``evaluate_detailed`` as its per-molecule step
593
+ budget. ``None`` (default) means unlimited -- ``exhausted`` will
594
+ then always be empty.
595
+ aromatic / computed: forwarded to
596
+ :func:`~unicode_logic_kit.chem.mol_to_structure` for every
597
+ molecule this call builds a structure for.
598
+ structure_cache: a caller-owned, mutable
599
+ ``{(smiles, aromatic, computed): structure}`` dict, extended IN
600
+ PLACE -- the key is the FULL triple, not the bare SMILES, because
601
+ ``aromatic``/``computed`` are structure-determining parameters
602
+ (see :func:`_structure_for`'s own docstring): a SMILES cached
603
+ under one ``aromatic=``/``computed=`` combination is never
604
+ returned for a call using a DIFFERENT combination, even when both
605
+ calls share this same dict, so passing one shared cache to calls
606
+ that deliberately vary ``aromatic=``/``computed=`` is always
607
+ safe. Pass the SAME dict across multiple :func:`score_definition`
608
+ calls that share (even partially overlapping) molecule sets AND
609
+ the same ``aromatic=``/``computed=`` choice -- e.g. scoring
610
+ several candidate definitions against one C3PO class's
611
+ positives, or scoring one definition against many classes that
612
+ share common negatives -- to build each distinct SMILES's
613
+ structure ONCE, not once per call (RDKit parsing + the
614
+ ring/fragment computed-predicate pass is the dominant
615
+ per-molecule cost). ``None`` (default) creates a fresh,
616
+ call-local dict -- structures are still deduplicated WITHIN one
617
+ call (e.g. a SMILES appearing in both ``positives`` and
618
+ ``negatives``, or repeated in one list), just not reused across
619
+ separate calls.
620
+
621
+ Returns:
622
+ A :class:`DefinitionScore`. See its docstring for exactly how the
623
+ four confusion-matrix cells, the two honest failure categories, and
624
+ the derived precision/recall/F1 relate.
625
+
626
+ Raises:
627
+ TypeError: ``formula`` is neither a :class:`Node` nor ``str``.
628
+ ValueError: ``dialect`` is invalid, or ``formula`` (as text) fails to
629
+ parse AT ALL -- see :func:`_resolve_formula`. This is the ONE
630
+ failure mode NOT absorbed into ``DefinitionScore.errors``: a
631
+ formula that never became a valid AST cannot meaningfully be
632
+ "scored" as 0-for-everything, so this raises immediately instead
633
+ of manufacturing a degenerate result the caller might not
634
+ scrutinise. A formula that DOES parse but names a predicate this
635
+ vocabulary does not have (e.g. a typo, or a genuinely unknown
636
+ predicate) is different: it fails identically on every molecule,
637
+ but via the ordinary per-molecule ``UninterpretedSymbol`` path,
638
+ so it shows up as ``n_errors == n_positives + n_negatives`` in an
639
+ otherwise normally-returned score -- exactly the distinction
640
+ that matters here: an unusable vocabulary is reported as its own
641
+ error category, never as "everything came out negative".
642
+ ImportError: RDKit is not installed (propagated from the first
643
+ :func:`~unicode_logic_kit.chem.mol_to_structure` call).
644
+ """
645
+ resolved = _resolve_formula(formula, dialect)
646
+ cache: MutableMapping[_CacheKey, object] = (
647
+ {} if structure_cache is None else structure_cache)
648
+
649
+ positives_list = list(positives)
650
+ negatives_list = list(negatives)
651
+
652
+ errors: List[dict] = []
653
+ exhausted: List[dict] = []
654
+
655
+ tp, fn = _classify(
656
+ positives_list, "positive", resolved,
657
+ all_different=all_different, budget=budget,
658
+ aromatic=aromatic, computed=computed,
659
+ structure_cache=cache, errors=errors, exhausted=exhausted,
660
+ )
661
+ fp, tn = _classify(
662
+ negatives_list, "negative", resolved,
663
+ all_different=all_different, budget=budget,
664
+ aromatic=aromatic, computed=computed,
665
+ structure_cache=cache, errors=errors, exhausted=exhausted,
666
+ )
667
+
668
+ precision = _safe_div(tp, tp + fp)
669
+ recall = _safe_div(tp, tp + fn)
670
+ f1 = _f1_of(precision, recall)
671
+
672
+ return DefinitionScore(
673
+ tp=tp, fp=fp, fn=fn, tn=tn,
674
+ n_positives=len(positives_list), n_negatives=len(negatives_list),
675
+ precision=precision, recall=recall, f1=f1,
676
+ n_errors=len(errors), n_exhausted=len(exhausted),
677
+ errors=tuple(errors), exhausted=tuple(exhausted),
678
+ )