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,750 @@
1
+ """Logical generality analysis — an early-warning signal for over-general
2
+ class definitions, computed WITHOUT any molecule database.
3
+
4
+ Motivation: an LLM translates chemical class definitions to FOL, and
5
+ classification is then model-checking a definition against real molecule
6
+ structures. A learned class definition that is too general matches far too
7
+ much, and the precision cost is invisible until it is measured against a
8
+ corpus. Two definitions of exactly the shape such a translation produces
9
+ show why, and both are concrete enough to serve as this module's
10
+ calibration cases:
11
+
12
+ molecule <=> net_charge_neutral
13
+ organicMolecularEntity <=> ?[A1]: (molecule & c(A1))
14
+
15
+ The right-hand side of the first is satisfied by ANY neutrally-charged
16
+ structure, however tiny; the second by any neutral structure with a single
17
+ carbon. A real acyl-CoA has on the order of 50 atoms — if its class
18
+ definition is already satisfied by a 3-atom structure, the definition is
19
+ under-determined, and this can be SEEN before a single real molecule is
20
+ checked. That is the whole point of this module: it never touches a molecule
21
+ database, ChemLog, or RDKit. It only asks the kit's own finite model finder
22
+ (:mod:`unicode_logic_kit.semantics.modelfinder`) "what is the SMALLEST finite
23
+ structure that satisfies this formula", and the kit's own prover chain
24
+ (:mod:`unicode_logic_kit.api`) "does this subclass body actually add anything
25
+ over its superclass body". Both questions are purely syntactic/semantic
26
+ properties of the FORMULA — no corpus required.
27
+
28
+ Three tools, matching the three questions a definition author should ask:
29
+
30
+ * :func:`minimal_model_size` / :func:`generality_report` — "how small a
31
+ structure already satisfies this?" A thin, HONEST wrapper around
32
+ :func:`~unicode_logic_kit.semantics.modelfinder.find_model`: that finder
33
+ already searches domain sizes ``1, 2, 3, …`` in ascending order and returns
34
+ the first (hence smallest) satisfying interpretation, so "minimal model
35
+ size" needs no search logic of its own here — just an honest reading of
36
+ what the finder already does, and an honest report of what it does NOT
37
+ prove (see the calibration discipline below).
38
+
39
+ * :func:`is_vacuous_specialisation` / :func:`strictly_stronger` — "does the
40
+ subclass body actually narrow down the superclass body, or does it
41
+ (possibly after being dressed up differently) mean the same thing?" A
42
+ "specialisation" that is logically equivalent to what it specialises has
43
+ added nothing — exactly the kind of silent redundancy an
44
+ auxiliary-predicate-inheritance mechanism (each helper predicate is
45
+ optimised only in the context of the class that introduces it, then
46
+ inherited by every subclass verbatim) can produce without anyone noticing.
47
+
48
+ **Calibration discipline (read before using the "underdetermined" verdict).**
49
+ A small minimal model size is an INDICATION of under-determination, never a
50
+ PROOF: a class can legitimately be satisfiable by a small structure (a
51
+ definition that is genuinely about a 2-atom functional group, say). This
52
+ module therefore refuses to invent an absolute threshold ("smaller than 5 is
53
+ bad") — :func:`generality_report`'s verdict is only ever RELATIVE to an
54
+ ``expected_min_size`` the CALLER supplies (e.g. from the real ChEBI class's
55
+ typical atom count); without one, the report states the fact (the size) and
56
+ makes no judgement at all. This mirrors the kit-wide rule that an "unknown"
57
+ or "not judged" outcome must never be silently reported as a negative
58
+ finding.
59
+
60
+ **Boundedness (the other honesty axis).** Finite-model search is inherently
61
+ bounded here, for two independent reasons documented in
62
+ :mod:`unicode_logic_kit.semantics.modelfinder` and inherited verbatim by this
63
+ module: (1) the search only goes up to ``max_size`` — a formula whose
64
+ smallest model is bigger is reported the same way as a genuinely
65
+ unsatisfiable one (``size=None, exhausted=True``); this module does NOT
66
+ attempt to disambiguate the two, because doing so would require a
67
+ decision procedure this kit does not have (FOL satisfiability is
68
+ undecidable). (2) a domain size whose interpretation space exceeds
69
+ ``max_candidates`` is SKIPPED rather than exhaustively searched — so, in
70
+ principle, a skipped smaller size could also have been satisfying, and the
71
+ "minimal" size this module reports is only minimal among the SIZES ACTUALLY
72
+ SEARCHED. Both are the modelfinder's own pre-existing, tested contract; nothing
73
+ here works around or hides them.
74
+
75
+ **Solver tri-state discipline.** :func:`strictly_stronger` /
76
+ :func:`is_vacuous_specialisation` route both entailment directions through
77
+ :func:`unicode_logic_kit.api.prove`'s backend chain, which is itself already
78
+ tri-state (PROVED / REFUTED / UNKNOWN). This module keeps that third value
79
+ alive end to end: an entailment direction the chain could not decide within
80
+ its budget is reported as ``None``, and — per the Kleene-logic combination
81
+ documented on :class:`StrictlyStrongerResult` — propagates to an honest
82
+ ``"undecided"`` classification rather than a guessed one, EXCEPT in the one
83
+ case where the algebra makes a guess unnecessary: once one conjunct of
84
+ "sub |= sup AND NOT sup |= sub" is definitely known to be False, the whole
85
+ conjunction is False regardless of the other, undecided, conjunct — that is
86
+ not a guess, it is deduction, and reporting it as ``None`` would itself be
87
+ the dishonest choice (silently downgrading a proven answer to "unknown").
88
+ """
89
+
90
+ from dataclasses import dataclass
91
+ from typing import List, Optional, Sequence, Tuple
92
+
93
+ from ..fol.nodes import (
94
+ Node, Variable, Constant, Number, Function, Atom,
95
+ Not, And, Or, Xor, Implies, Iff, Quantifier,
96
+ )
97
+ from ..fol.signature import Signature
98
+ from ..semantics.modelfinder import MAX_CANDIDATES, find_model
99
+ from ..semantics.tarski import Structure
100
+ from ..atp.protocol import PROVED, REFUTED
101
+ from .explain import explain_countermodel
102
+
103
+ __all__ = [
104
+ "MinimalModelResult", "minimal_model_size",
105
+ "GeneralityReport", "generality_report",
106
+ "StrictlyStrongerResult", "strictly_stronger",
107
+ "VacuousSpecialisationResult", "is_vacuous_specialisation",
108
+ "NO_SMALL_MODEL_FOUND", "REPORTED_ONLY", "UNDERDETERMINED", "MEETS_EXPECTATION",
109
+ "EQUIVALENT", "STRICTLY_STRONGER", "NOT_A_SPECIALISATION", "UNDECIDED",
110
+ ]
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # generality_report verdict vocabulary — see the module docstring's
114
+ # "Calibration discipline" for why there is no absolute-threshold verdict.
115
+ # ---------------------------------------------------------------------------
116
+
117
+ NO_SMALL_MODEL_FOUND = "no_small_model_found" # no model up to max_size — NOT a claim of unsatisfiability
118
+ REPORTED_ONLY = "reported_only" # a size was found, but no expected_min_size to judge it against
119
+ UNDERDETERMINED = "underdetermined" # size < expected_min_size — an INDICATION, not a proof
120
+ MEETS_EXPECTATION = "meets_expectation" # size >= expected_min_size — no warning raised (not a positive guarantee either)
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # is_vacuous_specialisation classification vocabulary
124
+ # ---------------------------------------------------------------------------
125
+
126
+ EQUIVALENT = "equivalent" # sub_body <=> sup_body: the "specialisation" adds nothing
127
+ STRICTLY_STRONGER = "strictly_stronger" # sub_body |= sup_body, sup_body does NOT |= sub_body: a genuine narrowing
128
+ NOT_A_SPECIALISATION = "not_a_specialisation" # sub_body does NOT even entail sup_body — the premise of the question is false
129
+ UNDECIDED = "undecided" # the backend chain could not settle enough of the two directions
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # minimal_model_size
134
+ # ---------------------------------------------------------------------------
135
+
136
+ @dataclass(frozen=True)
137
+ class MinimalModelResult:
138
+ """Outcome of :func:`minimal_model_size`.
139
+
140
+ ``size`` is the domain size of the smallest satisfying structure found,
141
+ or ``None`` iff no satisfying structure was found up to ``max_size_tried``
142
+ (``exhausted`` is then ``True`` — read as "no small satisfying structure
143
+ found", NEVER as "unsatisfiable": see the module docstring's
144
+ "Boundedness" section for the two independent reasons a genuinely
145
+ satisfiable formula can still come back this way). ``model`` is the
146
+ witness structure itself (a
147
+ :class:`~unicode_logic_kit.semantics.tarski.Structure`) when one was found,
148
+ else ``None``.
149
+ """
150
+
151
+ size: Optional[int]
152
+ model: Optional[Structure]
153
+ exhausted: bool
154
+ max_size_tried: int
155
+
156
+ def to_dict(self) -> dict:
157
+ return {
158
+ "size": self.size,
159
+ "model": ({"kind": "finite_structure", "repr": repr(self.model)}
160
+ if self.model is not None else None),
161
+ "exhausted": self.exhausted,
162
+ "max_size_tried": self.max_size_tried,
163
+ }
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # all_different — the ChemLog convention, as a formula transformation
168
+ # ---------------------------------------------------------------------------
169
+ #
170
+ # The model finder searches under plain FOL semantics, and there is no place
171
+ # to hand it a semantics switch: it evaluates with
172
+ # unicode_logic_kit.semantics.tarski.satisfies, which has no all_different
173
+ # reading. So the convention is applied where it CAN be applied exactly — to
174
+ # the formula, before the search — by making the distinctness the convention
175
+ # leaves implicit explicit as ≠ atoms. The two are the same statement:
176
+ # "separately introduced existential variables denote distinct individuals"
177
+ # IS the conjunction of those inequalities.
178
+ #
179
+ # The pairs are chosen to mirror
180
+ # unicode_logic_kit.semantics.model_eval's OWN reading of all_different, not a
181
+ # wider one: only existentials in an ANCESTOR/DESCENDANT relationship in the
182
+ # syntax tree (∃y somewhere inside the matrix ∃x quantifies over) are made
183
+ # distinct — two existentials in SIBLING positions (the two sides of an ∧)
184
+ # may still coincide there, and so they may here. Any drift between the two
185
+ # readings would mean the model checker and the generality analysis disagree
186
+ # about what the same formula says.
187
+
188
+ _NEGATIVE_NODES = (Not, Implies, Iff, Xor)
189
+ _COMPARISONS = frozenset({"=", "≠", "<", ">", "≤", "≥"})
190
+
191
+ #: Both spellings of the existential quantifier's ``type``. The kit's own
192
+ #: parsers emit ``"∃"``, hand-built ASTs (and several importers) use
193
+ #: ``"exists"``, and :class:`~unicode_logic_kit.fol.nodes.Quantifier` normalises
194
+ #: neither — so matching only one of them here would silently read a formula
195
+ #: as having no existentials at all, and answer an all_different question
196
+ #: under plain semantics without saying so. Same pair as
197
+ #: ``semantics.model_eval._EXISTS``.
198
+ _EXISTS = ("exists", "∃")
199
+
200
+
201
+ def _is_exists(node: Node) -> bool:
202
+ return isinstance(node, Quantifier) and node.type in _EXISTS
203
+
204
+
205
+ def _with_all_different(formula: Node, enclosing: Tuple[str, ...] = ()) -> Node:
206
+ """``formula`` with the all_different convention written out as ≠ atoms.
207
+
208
+ Each ``∃v`` gains, INSIDE its own scope, one ``u ≠ v`` for every
209
+ existential ``u`` it is nested in. Placing the atom inside the binder is
210
+ the whole difficulty: conjoining ``u ≠ v`` to the formula as a whole
211
+ would leave both variables FREE there, and the model finder reads a free
212
+ variable as a parameter: an unknown element of its own, which has nothing
213
+ to do with the bound variables of that name. The constraint would then say
214
+ "two parameters differ": it would leave the existentials it was written
215
+ for free to coincide, and only force the domain to have two elements.
216
+
217
+ A formula with no nested existentials comes back unchanged, so the
218
+ convention costs nothing where it says nothing.
219
+ """
220
+ if _is_exists(formula):
221
+ name = formula.variable.name
222
+ inner = _with_all_different(formula.formula, enclosing + (name,))
223
+ for outer in enclosing:
224
+ inner = And(inner, Atom("≠", [Variable(outer), Variable(name)]))
225
+ return Quantifier(formula.type, formula.variable, inner)
226
+ return formula.map_children(lambda child: _with_all_different(child, enclosing))
227
+
228
+
229
+ def _closed_form_size(formula: Node) -> Optional[int]:
230
+ """The minimal model size under all_different, computed rather than
231
+ searched — or ``None`` when the formula is outside the fragment where
232
+ that is provable.
233
+
234
+ **Fragment**: a chain of existential quantifiers over a matrix built only
235
+ from atoms, ∧ and ∨, with no comparison atom (``= ≠ < > ≤ ≥``) and no
236
+ :class:`~unicode_logic_kit.fol.nodes.Number` anywhere.
237
+
238
+ **Claim**: for such a formula with ``n`` existentially bound variables,
239
+ the smallest structure satisfying it under all_different has exactly
240
+ ``max(n, 1)`` individuals.
241
+
242
+ **Proof.** (≥) Every pair of the ``n`` variables stands in an
243
+ ancestor/descendant relation, so the convention makes them pairwise
244
+ distinct and any model has at least ``n`` individuals; a domain is
245
+ non-empty, so at least 1. (≤) Take ``D = {d_1, …, d_n}``, assign
246
+ ``v_i ↦ d_i``, interpret every predicate of arity ``k`` as ``D^k``, every
247
+ function as the constant ``d_1``, every constant as ``d_1``. Every atom
248
+ of the matrix is then true — every argument denotes an individual of
249
+ ``D``, and the predicate holds of every tuple — and a matrix built from
250
+ true atoms by ∧ and ∨ alone is true. ∎
251
+
252
+ The fragment conditions are exactly the proof's load-bearing
253
+ assumptions, which is why each is checked rather than assumed: a
254
+ negation would break "every atom true ⇒ matrix true"; an equality atom
255
+ would be made true between DISTINCT individuals by the all-tuples
256
+ interpretation, contradicting the assignment; a ``Number`` denotes an
257
+ individual outside ``D``; a universal quantifier ranges over ``D`` and
258
+ can fail. Out of fragment, the caller falls back to search — which is
259
+ correct but exponential, and for the formulas this matters for (a
260
+ ChEBI class definition binds up to two dozen atoms) will not finish.
261
+ Verified against that search on the fragment in
262
+ ``tests/test_generality.py``.
263
+ """
264
+ count = 0
265
+ node = formula
266
+ while isinstance(node, Quantifier):
267
+ if not _is_exists(node):
268
+ return None
269
+ count += 1
270
+ node = node.formula
271
+ for part in node.walk():
272
+ if isinstance(part, (Quantifier, Number)) or isinstance(part, _NEGATIVE_NODES):
273
+ return None
274
+ if isinstance(part, Atom) and part.predicate in _COMPARISONS:
275
+ return None
276
+ if not isinstance(part, (Atom, And, Or, Variable, Constant, Function)):
277
+ return None
278
+ return max(count, 1)
279
+
280
+
281
+ def _saturated_witness(formula: Node, size: int) -> Structure:
282
+ """The structure the closed form's (≤) direction constructs: ``size``
283
+ individuals, every predicate holding of every tuple, every function and
284
+ constant denoting the first individual."""
285
+ domain = tuple(f"d{i}" for i in range(1, size + 1))
286
+ predicates = {}
287
+ functions = {}
288
+ constants = {}
289
+ for part in formula.walk():
290
+ if isinstance(part, Atom):
291
+ arity = len(part.args)
292
+ predicates[(part.predicate, arity)] = (
293
+ True if arity == 0
294
+ else {tuple(t) for t in _tuples(domain, arity)})
295
+ elif isinstance(part, Function):
296
+ functions[(part.name, len(part.args))] = (
297
+ lambda *_args, first=domain[0]: first)
298
+ elif isinstance(part, Constant):
299
+ constants[part.name] = domain[0]
300
+ return Structure(domain, constants=constants, functions=functions,
301
+ predicates=predicates)
302
+
303
+
304
+ def _tuples(domain: Tuple[str, ...], arity: int):
305
+ from itertools import product
306
+ return product(domain, repeat=arity)
307
+
308
+
309
+ def minimal_model_size(
310
+ formula: Node, *,
311
+ signature: Optional[Signature] = None,
312
+ max_size: int = 6,
313
+ max_candidates: int = MAX_CANDIDATES,
314
+ all_different: bool = False,
315
+ ) -> MinimalModelResult:
316
+ """Search for the SMALLEST finite structure satisfying ``formula``.
317
+
318
+ This is a thin, honesty-preserving wrapper around
319
+ :func:`unicode_logic_kit.semantics.modelfinder.find_model`: that finder
320
+ already enumerates domain sizes ``1, 2, 3, …, max_size`` in ascending
321
+ order and returns the structure for the FIRST size at which one is found
322
+ — which is, by construction, the smallest size (among those actually
323
+ searched, see below) at which ``formula`` is satisfiable. No separate
324
+ search loop is implemented here; duplicating that logic would risk it
325
+ silently diverging from the finder's own (tested) enumeration order.
326
+
327
+ Args:
328
+ formula: the definitional BODY to test (e.g. the right-hand side of
329
+ a ``class <=> body`` definition) — not the whole biconditional,
330
+ which would trivially be satisfiable by choosing the class
331
+ predicate's extension to match whatever the body denotes. A free
332
+ variable is a PARAMETER, matching
333
+ :mod:`~unicode_logic_kit.semantics.modelfinder`'s own reading: one
334
+ unknown element, the same wherever the variable occurs, and a
335
+ model the search finds interprets it as the constant of its name.
336
+ So ``P(x) ∧ ¬P(y)`` has a model of size 2 (``x`` and ``y`` two
337
+ elements) and is not read as ``∀x ∀y (P(x) ∧ ¬P(y))``, which has
338
+ none. (The closed-form witness of ``all_different`` has no entry
339
+ for a free variable: every element satisfies the formula there.)
340
+ signature: if given, ``formula`` is validated against it first
341
+ (:meth:`~unicode_logic_kit.fol.signature.Signature.validate`) and a
342
+ formula using any undeclared predicate/function/constant is
343
+ REFUSED with ``ValueError`` before any search runs — a
344
+ generality analysis over a symbol that is not even part of the
345
+ real vocabulary (e.g. a typo'd helper-predicate name) would be
346
+ meaningless, and this kit does not silently search anyway when
347
+ the input itself looks wrong.
348
+ max_size: the largest domain size tried (inclusive). Bigger costs
349
+ more; see ``max_candidates`` for the per-size cutoff.
350
+ max_candidates: forwarded to :func:`~unicode_logic_kit.semantics.modelfinder.find_model`
351
+ — a domain size whose interpretation space would exceed this is
352
+ SKIPPED rather than exhaustively enumerated. This means the
353
+ "minimal" size reported here is minimal among the sizes that
354
+ were actually searched, not provably minimal overall — see the
355
+ module docstring's "Boundedness" section. Formulas built only
356
+ from unary predicates and 0-ary facts (the module docstring's
357
+ calibration cases, and this module's own tests) stay far under
358
+ the default budget for every ``max_size`` this module defaults to;
359
+ a formula with several binary predicates can hit the skip much
360
+ sooner, since a binary predicate's interpretation space grows as
361
+ ``2**(k**2)``.
362
+
363
+ all_different: read the formula under ChemLog's convention —
364
+ separately introduced existential variables denote PAIRWISE
365
+ DISTINCT individuals (see
366
+ :mod:`unicode_logic_kit.semantics.model_eval`, whose
367
+ ancestor/descendant reading of "separately introduced" this
368
+ mirrors exactly). Default ``False``: plain FOL semantics.
369
+
370
+ This matters more than it looks. Under plain semantics a
371
+ definition built only from ∃, ∧ and ∨ — which is what an LLM
372
+ writes for a chemical class — is satisfied by a ONE-element
373
+ structure in which every predicate holds, whatever it says: the
374
+ measurement is constant 1 and discriminates nothing. Under the
375
+ convention it measures what the definition actually demands,
376
+ namely how many DISTINCT atoms must exist. Implemented as a
377
+ formula transformation (the implicit distinctness written out as
378
+ ≠ atoms) rather than a search-time switch, because the finder
379
+ evaluates with :func:`~unicode_logic_kit.semantics.tarski.satisfies`,
380
+ which has no such reading — and answered in CLOSED FORM, without
381
+ any search, for the fragment where that is provable (see
382
+ :func:`_closed_form_size`); a two-dozen-variable class definition
383
+ is not reachable by enumeration at all.
384
+
385
+ Returns:
386
+ A :class:`MinimalModelResult`. Any exception the underlying finder
387
+ or evaluator raises for an out-of-fragment node (e.g. a modal or
388
+ fuzzy operator :func:`~unicode_logic_kit.semantics.tarski.satisfies`
389
+ has no rule for) propagates UNCHANGED — this module adds no
390
+ swallowing of its own, per the kit's loud-refusal convention.
391
+ """
392
+ if signature is not None:
393
+ violations = signature.validate(formula)
394
+ if violations:
395
+ extra = f" (and {len(violations) - 1} more)" if len(violations) > 1 else ""
396
+ raise ValueError(
397
+ "generality.minimal_model_size: formula uses vocabulary outside "
398
+ f"the given signature -- {violations[0]}{extra}. Fix the formula "
399
+ "or the signature before searching for a model: a generality "
400
+ "analysis over an undeclared symbol would be meaningless."
401
+ )
402
+
403
+ searched = formula
404
+ if all_different:
405
+ size = _closed_form_size(formula)
406
+ if size is not None:
407
+ return MinimalModelResult(
408
+ size=size, model=_saturated_witness(formula, size),
409
+ exhausted=False, max_size_tried=max(max_size, size))
410
+ searched = _with_all_different(formula)
411
+
412
+ model = find_model([searched], max_size=max_size, max_candidates=max_candidates)
413
+ if model is None:
414
+ return MinimalModelResult(size=None, model=None, exhausted=True,
415
+ max_size_tried=max_size)
416
+ return MinimalModelResult(size=len(model.domain), model=model, exhausted=False,
417
+ max_size_tried=max_size)
418
+
419
+
420
+ def _describe_model(model: Structure) -> str:
421
+ """A short, deterministic English rendering of a witness structure.
422
+
423
+ Delegates to :func:`unicode_logic_kit.eval.explain.explain_countermodel`,
424
+ which already renders an arbitrary
425
+ :class:`~unicode_logic_kit.semantics.tarski.Structure` as a few plain
426
+ sentences (domain, constants, every predicate's extension) — the
427
+ rendering logic is identical whether the structure is framed as a
428
+ countermodel of a failed entailment (its original purpose) or, as here,
429
+ as a minimal SATISFYING witness; reimplementing that formatting here
430
+ would just duplicate already-tested code for no behavioural difference.
431
+ """
432
+ return explain_countermodel(model)
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # generality_report
437
+ # ---------------------------------------------------------------------------
438
+
439
+ @dataclass(frozen=True)
440
+ class GeneralityReport:
441
+ """Outcome of :func:`generality_report`.
442
+
443
+ ``verdict`` is one of the module's four verdict constants
444
+ (:data:`NO_SMALL_MODEL_FOUND`, :data:`REPORTED_ONLY`,
445
+ :data:`UNDERDETERMINED`, :data:`MEETS_EXPECTATION`) — see the module
446
+ docstring's "Calibration discipline" for why :data:`UNDERDETERMINED` is
447
+ only ever reached RELATIVE to a caller-supplied ``expected_min_size``,
448
+ never from an absolute size alone. ``narrative`` is the same judgement
449
+ spelled out as one or two plain-English sentences, including the
450
+ relevant caveat every time (never a bare verdict word with no context).
451
+ """
452
+
453
+ name: str
454
+ formula: Node
455
+ minimal_model: MinimalModelResult
456
+ expected_min_size: Optional[int]
457
+ verdict: str
458
+ narrative: str
459
+ witness_description: Optional[str]
460
+
461
+ def to_dict(self) -> dict:
462
+ return {
463
+ "name": self.name,
464
+ "formula": self.formula.to_dict(),
465
+ "minimal_model": self.minimal_model.to_dict(),
466
+ "expected_min_size": self.expected_min_size,
467
+ "verdict": self.verdict,
468
+ "narrative": self.narrative,
469
+ "witness_description": self.witness_description,
470
+ }
471
+
472
+
473
+ def generality_report(
474
+ name: str, formula: Node, *,
475
+ expected_min_size: Optional[int] = None,
476
+ signature: Optional[Signature] = None,
477
+ max_size: int = 6,
478
+ max_candidates: int = MAX_CANDIDATES,
479
+ ) -> GeneralityReport:
480
+ """Report on how easily ``formula`` (a class's definitional body) is
481
+ satisfied, optionally judged against ``expected_min_size``.
482
+
483
+ Args:
484
+ name: a label for the class this ``formula`` defines (e.g.
485
+ ``"acylCoA"``) — carried through purely for a readable report,
486
+ never inspected.
487
+ formula: as in :func:`minimal_model_size`.
488
+ expected_min_size: the caller's own expectation of how large a
489
+ REAL instance of this class must be (e.g. the typical atom
490
+ count of the real ChEBI class) — the one number this module
491
+ will not invent for you (see the module docstring). Omitted
492
+ (``None``) means: report the fact, make no judgement.
493
+ signature / max_size / max_candidates: forwarded to
494
+ :func:`minimal_model_size` verbatim.
495
+
496
+ Returns:
497
+ A :class:`GeneralityReport`. Every verdict branch names its own
498
+ caveat in ``narrative`` — this module never emits a bare "bad" or
499
+ "good" without repeating why that is not a proof either way.
500
+ """
501
+ minimal = minimal_model_size(formula, signature=signature, max_size=max_size,
502
+ max_candidates=max_candidates)
503
+ witness = _describe_model(minimal.model) if minimal.model is not None else None
504
+
505
+ if minimal.size is None:
506
+ verdict = NO_SMALL_MODEL_FOUND
507
+ narrative = (
508
+ f"No structure of size <= {max_size} satisfies this definition. This "
509
+ "is honestly UNKNOWN territory, not a finding of overgenerality (if "
510
+ "anything the opposite direction) and not a proof of "
511
+ "unsatisfiability either -- FOL satisfiability is undecidable, and "
512
+ "the search is bounded by max_size and max_candidates (see "
513
+ "minimal_model_size's docstring)."
514
+ )
515
+ elif expected_min_size is None:
516
+ verdict = REPORTED_ONLY
517
+ narrative = (
518
+ f"The smallest structure satisfying '{name}' found within the "
519
+ f"search bound has {minimal.size} individual(s). No "
520
+ "expected_min_size was supplied, so no generality judgement is "
521
+ "made -- a bare 'small is bad' threshold is deliberately not "
522
+ "built into this module (see its docstring)."
523
+ )
524
+ elif minimal.size < expected_min_size:
525
+ verdict = UNDERDETERMINED
526
+ narrative = (
527
+ f"The smallest structure satisfying '{name}' has {minimal.size} "
528
+ f"individual(s), below the expected minimum of {expected_min_size}. "
529
+ "This is an INDICATION of possible under-determination (a "
530
+ "structure this small can hardly represent the intended real-world "
531
+ "instances) -- NOT a proof: a class can legitimately be satisfiable "
532
+ "by a small structure, and this module has no way to distinguish "
533
+ "that from a genuinely too-permissive definition on its own."
534
+ )
535
+ else:
536
+ verdict = MEETS_EXPECTATION
537
+ narrative = (
538
+ f"The smallest structure satisfying '{name}' has {minimal.size} "
539
+ f"individual(s), at or above the expected minimum of "
540
+ f"{expected_min_size}. No generality warning is raised here -- "
541
+ "this does not positively CONFIRM the definition is correctly "
542
+ "restrictive either; it only means this particular early-warning "
543
+ "check found nothing to flag."
544
+ )
545
+
546
+ return GeneralityReport(
547
+ name=name, formula=formula, minimal_model=minimal,
548
+ expected_min_size=expected_min_size, verdict=verdict,
549
+ narrative=narrative, witness_description=witness,
550
+ )
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # strictly_stronger / is_vacuous_specialisation
555
+ # ---------------------------------------------------------------------------
556
+
557
+ def _entails(
558
+ premise: Node, conclusion: Node, *,
559
+ timeout: int, backends: Optional[Sequence[str]],
560
+ ) -> Tuple[Optional[bool], Optional[dict], Optional[str]]:
561
+ """Tri-state answer to "does ``premise`` entail ``conclusion``?"
562
+
563
+ Routes through :func:`unicode_logic_kit.api.prove` (imported lazily —
564
+ ``api`` itself imports from this package at module scope elsewhere in
565
+ the kit, so importing it at THIS module's top level would risk a
566
+ circular import the moment something wires this module into
567
+ ``unicode_logic_kit.eval``'s own ``__init__``; every other entry point in
568
+ this file that needs ``api`` imports it the same way, immediately before
569
+ use). Returns ``(entails, countermodel, backend)``:
570
+
571
+ * ``(True, None, name)`` — the chain PROVED it; ``name`` is the
572
+ backend that closed it.
573
+ * ``(False, cm, name)`` — the chain REFUTED it; ``cm`` is the
574
+ Verdict-layer countermodel witness dict (same shape as
575
+ ``Verdict.countermodel`` / ``CountermodelResult.model``).
576
+ * ``(None, None, name)`` — neither: the chain's final UNKNOWN verdict
577
+ (``name`` is ``"chain"`` unless a single backend was requested via
578
+ ``backends``) — an honest "could not decide within budget", never
579
+ silently read as either PROVED or REFUTED.
580
+ """
581
+ from .. import api # lazy: see the docstring above for why
582
+
583
+ kwargs = {} if backends is None else {"backends": list(backends)}
584
+ verdict = api.prove(conclusion, [premise], timeout=timeout, **kwargs)
585
+ if verdict.status == PROVED:
586
+ return True, None, verdict.backend
587
+ if verdict.status == REFUTED:
588
+ return False, verdict.countermodel, verdict.backend
589
+ return None, None, verdict.backend
590
+
591
+
592
+ def _kleene_and(a: Optional[bool], b: Optional[bool]) -> Optional[bool]:
593
+ """Three-valued (Kleene strong) conjunction: ``False`` short-circuits
594
+ even when the OTHER operand is ``None`` (unknown) — this is the one
595
+ place :class:`StrictlyStrongerResult` legitimately reports a definite
596
+ answer despite one entailment direction being undecided; see the module
597
+ docstring's "Solver tri-state discipline"."""
598
+ if a is False or b is False:
599
+ return False
600
+ if a is True and b is True:
601
+ return True
602
+ return None
603
+
604
+
605
+ @dataclass(frozen=True)
606
+ class StrictlyStrongerResult:
607
+ """Tri-state answer to ``sub_body |= sup_body AND NOT sup_body |= sub_body``.
608
+
609
+ ``forward`` / ``backward`` are the two entailment directions
610
+ (``sub_body |= sup_body`` and ``sup_body |= sub_body`` respectively),
611
+ each ``True`` (PROVED) / ``False`` (REFUTED, with a witness) / ``None``
612
+ (the backend chain could not decide within ``timeout``) — see
613
+ :func:`unicode_logic_kit.api.prove`'s own tri-state contract, which this
614
+ is a direct read-out of.
615
+
616
+ ``stronger`` combines them via :func:`_kleene_and` applied to
617
+ ``(forward, not backward)`` — see the module docstring's "Solver
618
+ tri-state discipline" for why this can be a definite ``False`` even when
619
+ ``forward`` itself is ``None`` (once ``backward`` is PROVED ``True``,
620
+ ``NOT backward`` is definitely ``False``, and ``anything AND False`` is
621
+ ``False`` regardless of the unknown operand — a deduction, not a guess).
622
+
623
+ ``countermodel`` witnesses ``backward is False`` (a structure where
624
+ ``sup_body`` holds but ``sub_body`` does not — the genuine-narrowing
625
+ witness); ``forward_countermodel`` witnesses ``forward is False`` (a
626
+ structure where ``sub_body`` holds but ``sup_body`` does not — meaning
627
+ ``sub_body`` was never even a specialisation of ``sup_body`` to begin
628
+ with). Each is populated only when its corresponding direction was
629
+ actually REFUTED, ``None`` otherwise — never a guessed placeholder.
630
+ """
631
+
632
+ stronger: Optional[bool]
633
+ forward: Optional[bool]
634
+ backward: Optional[bool]
635
+ forward_backend: Optional[str]
636
+ backward_backend: Optional[str]
637
+ countermodel: Optional[dict] = None
638
+ forward_countermodel: Optional[dict] = None
639
+
640
+ def to_dict(self) -> dict:
641
+ return {
642
+ "stronger": self.stronger,
643
+ "forward": self.forward,
644
+ "backward": self.backward,
645
+ "forward_backend": self.forward_backend,
646
+ "backward_backend": self.backward_backend,
647
+ "countermodel": self.countermodel,
648
+ "forward_countermodel": self.forward_countermodel,
649
+ }
650
+
651
+
652
+ def strictly_stronger(
653
+ sub_body: Node, sup_body: Node, *,
654
+ timeout: int = 10000,
655
+ backends: Optional[Sequence[str]] = None,
656
+ ) -> StrictlyStrongerResult:
657
+ """Decide whether ``sub_body`` is a genuine (non-vacuous) logical
658
+ specialisation of ``sup_body``: ``sub_body |= sup_body`` AND
659
+ ``sup_body`` does NOT ``|= sub_body``, each direction decided through
660
+ :func:`unicode_logic_kit.api.prove`'s backend chain.
661
+
662
+ Args:
663
+ sub_body / sup_body: the two definitional bodies to compare (e.g. a
664
+ subclass's and its superclass's ``<=>`` right-hand sides).
665
+ timeout: forwarded to ``api.prove`` for EACH of the two entailment
666
+ checks (so the total wall-clock budget is up to ``2 * timeout``).
667
+ backends: forwarded to ``api.prove`` verbatim; ``None`` (the
668
+ default) uses the kit's full default chain for the detected
669
+ logic. Restricting this to a single, deliberately weak backend
670
+ (e.g. ``["z3"]`` with a tiny ``timeout``) is how a caller can
671
+ deliberately force the honest ``None``/undecided branch — see
672
+ ``tests/test_generality.py`` for exactly that use.
673
+
674
+ Returns:
675
+ A :class:`StrictlyStrongerResult` — see its docstring for the
676
+ Kleene-logic combination behind ``.stronger``.
677
+ """
678
+ fwd, fwd_cm, fwd_backend = _entails(sub_body, sup_body, timeout=timeout,
679
+ backends=backends)
680
+ bwd, bwd_cm, bwd_backend = _entails(sup_body, sub_body, timeout=timeout,
681
+ backends=backends)
682
+ not_bwd = {True: False, False: True, None: None}[bwd]
683
+ stronger = _kleene_and(fwd, not_bwd)
684
+ return StrictlyStrongerResult(
685
+ stronger=stronger, forward=fwd, backward=bwd,
686
+ forward_backend=fwd_backend, backward_backend=bwd_backend,
687
+ countermodel=bwd_cm if bwd is False else None,
688
+ forward_countermodel=fwd_cm if fwd is False else None,
689
+ )
690
+
691
+
692
+ @dataclass(frozen=True)
693
+ class VacuousSpecialisationResult:
694
+ """Outcome of :func:`is_vacuous_specialisation`.
695
+
696
+ ``classification`` is one of :data:`EQUIVALENT` (the "specialisation"
697
+ means the same thing as what it specialises — vacuous), :data:`STRICTLY_STRONGER`
698
+ (a genuine, machine-checked narrowing), :data:`NOT_A_SPECIALISATION`
699
+ (``sub_body`` does not even entail ``sup_body`` — the premise that this
700
+ IS a subclass/superclass pair is itself false), or :data:`UNDECIDED`
701
+ (the backend chain could not settle enough of the two directions to
702
+ place this in one of the other three — see ``detail`` for exactly what
703
+ WAS decided; in particular, a caller that only cares whether ``sub_body``
704
+ genuinely narrows ``sup_body`` should read ``detail.stronger`` directly
705
+ rather than only this coarser label, since ``detail.stronger`` can be a
706
+ definite ``False`` in one edge case where this classification still
707
+ says :data:`UNDECIDED` — see :class:`StrictlyStrongerResult`).
708
+ """
709
+
710
+ classification: str
711
+ detail: StrictlyStrongerResult
712
+
713
+ def to_dict(self) -> dict:
714
+ return {"classification": self.classification, "detail": self.detail.to_dict()}
715
+
716
+
717
+ def is_vacuous_specialisation(
718
+ sub_body: Node, sup_body: Node, *,
719
+ timeout: int = 10000,
720
+ backends: Optional[Sequence[str]] = None,
721
+ ) -> VacuousSpecialisationResult:
722
+ """Is ``sub_body`` (a subclass's definitional body) a VACUOUS
723
+ specialisation of ``sup_body`` (its superclass's) — i.e. logically
724
+ EQUIVALENT to it, so the "specialisation" adds nothing?
725
+
726
+ Built directly on :func:`strictly_stronger` (see its docstring for the
727
+ two entailment directions this classification is derived from):
728
+
729
+ * both directions PROVED -> :data:`EQUIVALENT`
730
+ * forward PROVED, backward REFUTED -> :data:`STRICTLY_STRONGER`
731
+ * forward REFUTED -> :data:`NOT_A_SPECIALISATION`
732
+ * anything else -> :data:`UNDECIDED`
733
+
734
+ Args:
735
+ sub_body / sup_body / timeout / backends: as in
736
+ :func:`strictly_stronger`.
737
+
738
+ Returns:
739
+ A :class:`VacuousSpecialisationResult`.
740
+ """
741
+ detail = strictly_stronger(sub_body, sup_body, timeout=timeout, backends=backends)
742
+ if detail.forward is True and detail.backward is True:
743
+ classification = EQUIVALENT
744
+ elif detail.forward is True and detail.backward is False:
745
+ classification = STRICTLY_STRONGER
746
+ elif detail.forward is False:
747
+ classification = NOT_A_SPECIALISATION
748
+ else:
749
+ classification = UNDECIDED
750
+ return VacuousSpecialisationResult(classification=classification, detail=detail)