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,1170 @@
1
+ """Deductive verification layer for a collection of predicate definitions.
2
+
3
+ An LLM translates chemical class definitions to FOL, and classification is
4
+ then MODEL CHECKING a definition against a molecule structure
5
+ (:mod:`unicode_logic_kit.semantics.model_eval`). That measures whether the
6
+ definitions are individually *useful* against real data (a definition that
7
+ is too general matches far too much, and the precision cost stays invisible
8
+ until it is measured against a corpus). It says nothing about whether the
9
+ definitions are *coherent as a theory*: an auxiliary predicate is optimised
10
+ only in the context of the one class that introduces it and then silently
11
+ inherited by every subclass, which overfits it to that one context, and a
12
+ prover-backed OWL-subsumption check reports only proved/not proved, with
13
+ **no counterexample** when a subsumption fails. This module is the layer that
14
+ sits between "does this predicate fire on real molecules" and "is this
15
+ DEFINITION SET internally consistent" — it never touches a molecule; every
16
+ question here is decided purely from the definitions' own logical content,
17
+ via the kit's existing prover chain (:mod:`unicode_logic_kit.atp.protocol`)
18
+ and finite model finder.
19
+
20
+ Data model — why a plain ``name -> body`` mapping
21
+ ---------------------------------------------------
22
+ A "definition" is a **named 0-ary predicate** with a defining FORMULA: exactly
23
+ the ``className <=> body`` shape such a translation produces, e.g.::
24
+
25
+ molecule <=> net_charge_neutral
26
+ organicMolecularEntity <=> ?[A1]: (molecule & c(A1))
27
+ carboxylicAcid <=> (carbonOxoacid & ?[A1,A2,A3]: (c(A1) & o(A2) & o(A3) &
28
+ has_1_hs(A3) & bDOUBLE(A1,A2) & bSINGLE(A1,A3)))
29
+
30
+ Every LHS here (``molecule``, ``organicMolecularEntity``, ``carboxylicAcid``,
31
+ ``carbonOxoacid``) is used with NO arguments — a ChEBI class membership fact is
32
+ a global property of "the one molecule this structure represents"
33
+ (:class:`~unicode_logic_kit.semantics.structures.FiniteStructure` already builds
34
+ its 0-ary extension exactly this way: ``frozenset()`` or ``frozenset({()})``).
35
+ So this module represents a definition set as ``Mapping[str, Node]``: the key
36
+ is the defined 0-ary predicate's name, the value is its BODY (the right-hand
37
+ side of the ``<=>`` — never re-wrap it in ``Iff(Atom(name, ()), body)``, the
38
+ mapping key already carries the left-hand side). This is a deliberate,
39
+ narrower choice than a general "Definition object with parameters" — a
40
+ parametrised definition (``isRing(x) <=> ...``) would need a substitution-
41
+ with-arguments mechanic none of these definitions exercise; admitting it
42
+ here would be a real design expansion, not a small one, so a body atom that
43
+ names a defined predicate is only ever recognised as a USE of that definition
44
+ when it appears with ZERO arguments (:func:`dependency_graph`, :func:`unfold`)
45
+ — a same-named atom used elsewhere with arguments is simply a different,
46
+ primitive symbol, left completely alone.
47
+
48
+ Why unfolding is the hard part
49
+ -------------------------------
50
+ Definitions reference each other (``carboxylicAcid`` names ``carbonOxoacid``,
51
+ ``organicMolecularEntity`` names ``molecule``), and a subsumption or
52
+ satisfiability question is only meaningful against the CLOSED formula in
53
+ terms of primitive (undefined) predicates — a prover asked to check
54
+ ``carboxylicAcid -> carbonOxoacid`` while ``carbonOxoacid`` is still an
55
+ uninterpreted 0-ary atom would trivially fail to see that the axioms actually
56
+ force it (nothing tells the prover ``carbonOxoacid`` unfolds to anything).
57
+ :func:`unfold` collects that background theory correctly by *substituting*
58
+ every defined-predicate use with its own (recursively unfolded) body, so what
59
+ reaches the prover is a single sentence over only primitive vocabulary — no
60
+ separate list of background axioms is needed or possible, because a defined
61
+ 0-ary predicate reused in two different bodies is DEFINITIONALLY (not just
62
+ materially) identical to its expansion everywhere, and substitution is the
63
+ operation that makes that identity explicit to a syntactic prover.
64
+
65
+ Plain substitution like this is only correct when every body is CLOSED
66
+ (no free variable): pasting a body verbatim into an enclosing definition's
67
+ own ``∃``/``∀`` scope would otherwise silently CAPTURE a free variable that
68
+ never meant to refer to that binder at all. :func:`unfold` therefore refuses
69
+ up front — see :class:`NonClosedDefinition` — rather than attempt
70
+ capture-avoiding (alpha-renaming) substitution: this module's data model is
71
+ 0-ARY class-membership definitions (see "Data model" above), for which a
72
+ free variable is a malformed input, not a feature to accommodate.
73
+
74
+ Circular definitions are MEANINGLESS, not merely hard
75
+ -------------------------------------------------------
76
+ ``className <=> P(..., className, ...)`` pins down no fact: any truth value
77
+ of ``className`` is self-consistent with such a biconditional (a fixed point
78
+ is not guaranteed to be UNIQUE — the classical case for why circular
79
+ "definitions" are not definitions at all). This module never guesses a
80
+ reading for one (least/greatest fixed point, etc.) — :func:`unfold` refuses
81
+ with :class:`CyclicDefinition` the moment substitution would revisit a name
82
+ already being expanded on the current path, and :func:`find_cycles` reports
83
+ every such cycle in the STATIC dependency graph up front so a caller can see
84
+ the problem without triggering it via a substitution call. A definition
85
+ reachable from a cycle cannot be unfolded at all, so :func:`check_satisfiable`
86
+ / :func:`check_subsumption` report it as its own status, ``"cyclic"`` —
87
+ distinct from ``"unknown"`` (an honest "we could not decide this within the
88
+ budget") because a cyclic definition is not merely undecided, it is a PROVEN
89
+ defect in the definition set, on exactly the same footing as an unsatisfiable
90
+ one (see :attr:`TheoryReport.proved_problems`).
91
+
92
+ Three-valued honesty, end to end
93
+ -----------------------------------
94
+ Every check in this module returns one of a definitive PROVEN status
95
+ (``"unsatisfiable"`` / ``"entailed"`` / ``"refuted"``, the last always
96
+ carrying a countermodel — see :func:`check_subsumption`), the definitive
97
+ defect status ``"cyclic"``, or ``"unknown"``. ``"unknown"`` is never
98
+ downgraded to ``False``/``"refuted"`` and never upgraded to ``True``/
99
+ ``"entailed"`` — a timeout, a step-bound, or an incomplete backend all mean
100
+ exactly "not decided", full stop, matching this kit's house rule (see
101
+ CLAUDE.md) that an unknown result is never reported as a false one.
102
+ :func:`check_satisfiable` and :func:`check_subsumption` are thin, honest
103
+ wrappers around :func:`unicode_logic_kit.api.prove` (which already returns this
104
+ same three/four-valued :class:`~unicode_logic_kit.atp.protocol.Verdict`
105
+ contract over its whole backend chain — Z3, the kit's own tableau/resolution
106
+ provers, and the finite model finder by default): they add nothing but the
107
+ unfolding step and the translation from "prove/refute a goal" to "is this
108
+ definition satisfiable" / "does this subsumption hold", so an unsatisfiable
109
+ result is exactly a Z3/tableau/resolution-checkable PROOF of a contradiction
110
+ and a refuted subsumption's countermodel is exactly the model the backend
111
+ that refuted it actually produced (never fabricated or approximated).
112
+
113
+ What this module is deliberately silent about
114
+ -------------------------------------------------
115
+ It never runs a definition against real data (that is model_eval's job) and
116
+ it never suggests a fix for a broken definition — it only proves, precisely,
117
+ THAT something is broken (dead classifier, meaningless cycle, missing
118
+ conjunct) and, for a refuted subsumption, exhibits WHY via a concrete
119
+ countermodel. That is the whole value-add over a bare prover-backed
120
+ subsumption check, which reports only proved/not proved with no witness.
121
+ """
122
+
123
+ from dataclasses import dataclass, field
124
+ from typing import Dict, FrozenSet, List, Mapping, Optional, Sequence, Tuple
125
+
126
+ from ..fol.nodes import Node, Atom, Not, Implies
127
+ from ..atp.protocol import PROVED, REFUTED, UNKNOWN
128
+ # eval -> atp is already the established direction in this module (see the
129
+ # PROVED/REFUTED/UNKNOWN import above), so to_html() reuses the small,
130
+ # private HTML-page helper the atp Fitch/sequent renderers share rather than
131
+ # reimplementing page-wrapping/escaping a third time — see atp/_html's own
132
+ # module docstring for why it is safe to import across the package boundary
133
+ # this way (it imports nothing back).
134
+ from ..atp._html import esc_html, html_page
135
+
136
+ __all__ = [
137
+ "Definitions",
138
+ "CyclicDefinition", "UnfoldDepthExceeded", "NonClosedDefinition",
139
+ "DEFAULT_MAX_DEPTH",
140
+ "dependency_graph", "find_cycles", "unfold",
141
+ "SatisfiabilityResult", "check_satisfiable",
142
+ "SubsumptionResult", "check_subsumption",
143
+ "TheoryReport", "check_theory",
144
+ ]
145
+
146
+ #: A definition set: defined 0-ary predicate name -> its defining BODY (the
147
+ #: right-hand side of ``name <=> body``). See the module docstring's "Data
148
+ #: model" section for why this shape and not a parametrised Definition object.
149
+ Definitions = Mapping[str, Node]
150
+
151
+ #: How many nested definitional expansions :func:`unfold` will perform along
152
+ #: one substitution path before giving up with :class:`UnfoldDepthExceeded`.
153
+ #: Chosen generously relative to any real ChEBI-style class hierarchy (a
154
+ #: handful to a few dozen levels of subclassing) while still catching a
155
+ #: genuinely runaway/undetected-cycle situation instead of recursing forever.
156
+ DEFAULT_MAX_DEPTH = 50
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Errors
161
+ # ---------------------------------------------------------------------------
162
+
163
+ class CyclicDefinition(ValueError):
164
+ """:func:`unfold` refuses to expand a definition through a cycle.
165
+
166
+ Raised the moment substitution would revisit a name already being
167
+ expanded on the CURRENT path (a live on-stack check, the standard DFS
168
+ cycle test) — see the module docstring's "Circular definitions are
169
+ MEANINGLESS" section for why this is a refusal, not an approximation.
170
+ """
171
+
172
+
173
+ class UnfoldDepthExceeded(ValueError):
174
+ """:func:`unfold`'s substitution chain exceeded ``max_depth`` without
175
+ terminating in primitive-only vocabulary.
176
+
177
+ This is a SEPARATE failure mode from :class:`CyclicDefinition`: the
178
+ on-stack check catches every cycle regardless of depth, so hitting this
179
+ instead means either a definition chain that is genuinely deeper than
180
+ ``max_depth`` (raise it), or — mixed with sibling reuse of the same name
181
+ at different points of a body — a combinatorial expansion that never
182
+ revisits a name on any single path yet keeps growing (rare, but not
183
+ provably impossible for a hand-written definition set); either way this
184
+ is reported rather than silently truncating the formula.
185
+ """
186
+
187
+
188
+ class NonClosedDefinition(ValueError):
189
+ """:func:`unfold` refuses to substitute a definition body that is not a
190
+ CLOSED sentence (has a free logical variable).
191
+
192
+ Why this matters: :func:`unfold` substitutes a defined 0-ary atom
193
+ IN PLACE, wherever it occurs — including inside an enclosing definition's
194
+ own ``∃``/``∀`` scopes. If the substituted body itself has a free
195
+ variable with the SAME NAME as one of those enclosing binders, plain
196
+ (non-capture-avoiding) substitution silently re-binds it: the free
197
+ occurrence, which denoted "whatever the definition's own body meant by
198
+ that name" (nothing, since it was never bound — an ill-formed input to
199
+ begin with), now denotes "the enclosing definition's witness" instead —
200
+ two occurrences that do not denote the same thing get silently
201
+ identified. That is a SOUNDNESS bug, not a decidability one, so it is
202
+ caught up front rather than risked on every substitution.
203
+
204
+ Why refuse rather than substitute capture-freely (alpha-rename the
205
+ clashing binder before substituting): every definition in this module's
206
+ data model is a 0-ARY class-membership fact (see the module docstring's
207
+ "Data model" section) — its body is only ever quantified INTERNALLY
208
+ (auxiliary existentials over its own chemical-substructure witnesses,
209
+ say), never parametrised by a variable a caller substitutes in from
210
+ outside. A body with a genuine free variable is therefore not a
211
+ well-formed 0-ary definition at all — it is a typo, an accidentally
212
+ unbound witness, or an attempt at a parametrised definition this module
213
+ was never designed to represent (see the module docstring's "why a plain
214
+ name -> body mapping" section: admitting parametrised definitions would
215
+ be a real design expansion, not a small one). Capture-avoiding
216
+ substitution would quietly accept and "fix" that malformed input by
217
+ inventing a reading for it, rather than reporting the actual defect —
218
+ exactly the kind of silent approximation this kit's house rules forbid.
219
+ This mirrors the ``KeyError`` :func:`unfold` already raises for a name
220
+ that is not even a key of ``definitions``: both are input-validation
221
+ failures decided BEFORE any unfolding/proving work begins, not
222
+ proof-budget questions like :class:`UnfoldDepthExceeded`.
223
+ """
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Dependency graph / cycle detection
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def _direct_uses(body: Node, definitions: Definitions) -> FrozenSet[str]:
231
+ """The defined names ``body`` uses AS A DEFINITION (0-ary atom, name is a
232
+ key of ``definitions``) — direct references only, not transitive."""
233
+ used = set()
234
+ for node in body.walk():
235
+ if isinstance(node, Atom) and not node.args and node.predicate in definitions:
236
+ used.add(node.predicate)
237
+ return frozenset(used)
238
+
239
+
240
+ def dependency_graph(definitions: Definitions) -> Dict[str, FrozenSet[str]]:
241
+ """Direct definitional dependencies: ``name -> frozenset(names it uses)``.
242
+
243
+ A name is counted as "used" only where it occurs as a bare 0-ary atom
244
+ whose predicate is itself a key of ``definitions`` — matching exactly what
245
+ :func:`unfold` will substitute (see the module docstring). This is the
246
+ DIRECT (one-hop) graph; :func:`find_cycles` walks it to find cycles, and
247
+ a topological/transitive closure is deliberately not offered here — a
248
+ caller that needs it can compute it from this graph, and adding it would
249
+ just be more surface for something one line of graph code already covers.
250
+ """
251
+ return {name: _direct_uses(body, definitions) for name, body in definitions.items()}
252
+
253
+
254
+ def _normalize_cycle(cycle: Tuple[str, ...]) -> Tuple[str, ...]:
255
+ """Canonical form of a cycle (``(n0, n1, ..., n0)``, first==last) for
256
+ deduplication: rotate to start at the lexicographically smallest name,
257
+ keeping direction (A->B->A and B->A->B are the SAME cycle; A->B->A and
258
+ A->C->A, if both exist, are DIFFERENT cycles and stay distinct)."""
259
+ core = cycle[:-1]
260
+ best = None
261
+ for i in range(len(core)):
262
+ rotated = core[i:] + core[:i]
263
+ candidate = rotated + (rotated[0],)
264
+ if best is None or candidate < best:
265
+ best = candidate
266
+ return best
267
+
268
+
269
+ def find_cycles(definitions: Definitions) -> Tuple[Tuple[str, ...], ...]:
270
+ """Every elementary cycle in the definitional dependency graph.
271
+
272
+ Each cycle is a name sequence ``(n0, n1, ..., nk, n0)`` — first and last
273
+ entry identical, tracing the dependency chain that closes the loop. A
274
+ direct self-reference (``A <=> ... A ...``) appears as ``("A", "A")``.
275
+ Results are deduplicated (see :func:`_normalize_cycle`) and returned in
276
+ sorted order, so the output is deterministic regardless of dict iteration
277
+ order.
278
+
279
+ Implementation: plain DFS from every node with an explicit "on the current
280
+ path" set — the standard cycle test, and exactly what :func:`unfold`'s
281
+ live on-stack check also performs (this function just runs it eagerly,
282
+ up front, over the whole graph, rather than only along the one path a
283
+ particular :func:`unfold` call happens to take). Complexity is bounded by
284
+ the number of simple paths in the graph, which is fine for a definition
285
+ set the size of a class hierarchy (sparse, near-tree-shaped with the
286
+ occasional bug introducing a 2-3 node loop) but is NOT guaranteed
287
+ polynomial on a dense graph — this is a correctness-first, not a
288
+ scalability-first, implementation.
289
+ """
290
+ deps = dependency_graph(definitions)
291
+ cycles: set = set()
292
+
293
+ def dfs(node: str, path: Tuple[str, ...], on_path: FrozenSet[str]) -> None:
294
+ for nxt in sorted(deps.get(node, ())):
295
+ if nxt in on_path:
296
+ idx = path.index(nxt)
297
+ cycles.add(_normalize_cycle(path[idx:] + (nxt,)))
298
+ else:
299
+ dfs(nxt, path + (nxt,), on_path | {nxt})
300
+
301
+ for start in sorted(deps):
302
+ dfs(start, (start,), frozenset({start}))
303
+
304
+ return tuple(sorted(cycles))
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Unfolding
309
+ # ---------------------------------------------------------------------------
310
+
311
+ def _require_closed_definitions(definitions: Definitions) -> None:
312
+ """Raise :class:`NonClosedDefinition` if any body in ``definitions`` is
313
+ not a closed sentence.
314
+
315
+ Reuses :func:`unicode_logic_kit.eval.validate.validate`'s own closedness
316
+ check (``ValidationReport.is_closed``, which counts a free ``Variable``
317
+ OR a free ``LambdaVar`` against closedness) rather than reimplementing
318
+ it. Checks EVERY body in ``definitions``, not just ones reachable from a
319
+ particular call's starting name: :func:`unfold`'s substitution walk can
320
+ reach any definition transitively, and which ones it actually reaches
321
+ depends on ``name`` and on the bodies themselves, so a check narrower
322
+ than "the whole set" would make whether a malformed body gets caught
323
+ depend on which name happens to be unfolded first — see
324
+ :class:`NonClosedDefinition` for why this is refused at all.
325
+ """
326
+ from .validate import validate # lazy: mirrors this module's other lazy imports
327
+
328
+ offenders = []
329
+ for name, body in definitions.items():
330
+ report = validate(body)
331
+ if not report.is_closed:
332
+ offenders.append((name, report.free_variable_names))
333
+ if offenders:
334
+ detail = "; ".join(
335
+ f"{name!r} has free variable(s) {', '.join(free_names)}"
336
+ for name, free_names in offenders
337
+ )
338
+ raise NonClosedDefinition(
339
+ "unfold: every definition body must be a CLOSED sentence — this "
340
+ "module's data model is 0-ary class-membership definitions, not "
341
+ f"parametrised ones (see the module docstring): {detail}. See "
342
+ "NonClosedDefinition's docstring for why this is refused rather "
343
+ "than patched via capture-avoiding substitution."
344
+ )
345
+
346
+
347
+ def _substitute(node: Node, definitions: Definitions, active: Tuple[str, ...],
348
+ depth: int, max_depth: int) -> Node:
349
+ """Recursively replace every 0-ary defined-predicate atom in ``node`` with
350
+ its (further-unfolded) body. ``active`` is the ordered stack of names
351
+ currently being expanded ON THIS PATH — membership in it is the live
352
+ cycle check; ``depth`` is how many substitutions deep this path already
353
+ is. Sibling reuse of the same name (e.g. ``A & A`` in one body) is NOT a
354
+ cycle: each occurrence recurses independently with the SAME ``active``/
355
+ ``depth`` it was called with, since neither branch is "inside" the
356
+ other's expansion."""
357
+ if isinstance(node, Atom) and not node.args and node.predicate in definitions:
358
+ pred = node.predicate
359
+ if pred in active:
360
+ raise CyclicDefinition(
361
+ f"unfold: definitional cycle detected: {' -> '.join(active + (pred,))} "
362
+ "— a circular biconditional like this pins down no fact (any truth "
363
+ "value is self-consistent with it), so it is refused rather than "
364
+ "evaluated. See find_cycles() to locate every such cycle up front."
365
+ )
366
+ if depth >= max_depth:
367
+ raise UnfoldDepthExceeded(
368
+ f"unfold: expanding {active[0]!r} did not reach a primitive-only "
369
+ f"formula within max_depth={max_depth} steps (currently expanding: "
370
+ f"{' -> '.join(active + (pred,))}). Raise max_depth if this chain is "
371
+ "genuinely this deep, or check find_cycles() for an undetected cycle."
372
+ )
373
+ body = definitions[pred]
374
+ return _substitute(body, definitions, active + (pred,), depth + 1, max_depth)
375
+ return node.map_children(
376
+ lambda c: _substitute(c, definitions, active, depth, max_depth))
377
+
378
+
379
+ def unfold(name: str, definitions: Definitions, *,
380
+ max_depth: int = DEFAULT_MAX_DEPTH) -> Node:
381
+ """Expand ``definitions[name]`` until only primitive predicates remain.
382
+
383
+ Every 0-ary atom in the body whose predicate is itself a key of
384
+ ``definitions`` is replaced by that key's own (recursively unfolded)
385
+ body — see the module docstring's "Why unfolding is the hard part". The
386
+ result is a single formula a prover can check directly, with no separate
387
+ background-axiom list needed (there is nothing FOR such a list to say
388
+ that substitution has not already said).
389
+
390
+ Raises:
391
+ KeyError: ``name`` is not a key of ``definitions``.
392
+ NonClosedDefinition: some body in ``definitions`` has a free
393
+ variable — checked over the WHOLE ``definitions`` mapping before
394
+ any substitution begins (see :class:`NonClosedDefinition` for
395
+ why: substituting a non-closed body can silently capture its
396
+ free variable in an unrelated enclosing binder of the same
397
+ name).
398
+ CyclicDefinition: expanding ``name`` would revisit a name already
399
+ being expanded on the current path — see :func:`find_cycles` to
400
+ locate the cycle without triggering this.
401
+ UnfoldDepthExceeded: the expansion did not terminate within
402
+ ``max_depth`` nested substitutions.
403
+ """
404
+ if name not in definitions:
405
+ raise KeyError(
406
+ f"unfold: {name!r} is not a defined name (known: {sorted(definitions)})")
407
+ _require_closed_definitions(definitions)
408
+ return _substitute(definitions[name], definitions, (name,), 0, max_depth)
409
+
410
+
411
+ # ---------------------------------------------------------------------------
412
+ # Satisfiability
413
+ # ---------------------------------------------------------------------------
414
+
415
+ _SATISFIABILITY_STATUSES = ("satisfiable", "unsatisfiable", "unknown", "cyclic")
416
+
417
+
418
+ @dataclass(frozen=True)
419
+ class SatisfiabilityResult:
420
+ """Outcome of :func:`check_satisfiable`.
421
+
422
+ ``status``:
423
+ ``"unsatisfiable"`` — PROVEN: the unfolded definition is a
424
+ contradiction, so ``name`` can hold of no possible molecule/object
425
+ whatsoever — a dead classifier that silently returns 0 matches
426
+ forever. ``detail`` names the backend that proved it.
427
+ ``"satisfiable"`` — PROVEN: some model makes ``name`` true.
428
+ ``witness`` is that model, in the SAME JSON-able shape as
429
+ :attr:`unicode_logic_kit.atp.protocol.Verdict.countermodel`
430
+ (``{"kind": ..., ...}``) — it may occasionally be ``None`` even
431
+ on this status, for a backend that can refute unsatisfiability
432
+ without producing a readable model (rare; the default chain's
433
+ Z3/model-finder members always attach one).
434
+ ``"unknown"`` — neither was established within the backend chain's
435
+ budget. NEVER a claim of unsatisfiability — see the module
436
+ docstring's three-valued-honesty section.
437
+ ``"cyclic"`` — ``name`` could not even be unfolded (it is reachable
438
+ from a definitional cycle); ``unfolded`` is ``None`` in this case.
439
+ This is its own PROVEN-defect status, not folded into
440
+ ``"unknown"`` — see :func:`find_cycles`.
441
+ """
442
+
443
+ name: str
444
+ status: str
445
+ unfolded: Optional[Node]
446
+ witness: Optional[dict]
447
+ backend: Optional[str]
448
+ detail: Optional[str]
449
+
450
+ def __post_init__(self):
451
+ if self.status not in _SATISFIABILITY_STATUSES:
452
+ raise ValueError(
453
+ f"SatisfiabilityResult: unknown status {self.status!r} "
454
+ f"(use one of {_SATISFIABILITY_STATUSES})")
455
+
456
+ def to_dict(self) -> dict:
457
+ return {
458
+ "name": self.name,
459
+ "status": self.status,
460
+ "unfolded": self.unfolded.to_dict() if self.unfolded is not None else None,
461
+ "witness": self.witness,
462
+ "backend": self.backend,
463
+ "detail": self.detail,
464
+ }
465
+
466
+
467
+ def check_satisfiable(name: str, definitions: Definitions, *,
468
+ timeout: int = 10000,
469
+ backends: Optional[Sequence[str]] = None,
470
+ max_depth: int = DEFAULT_MAX_DEPTH,
471
+ **options) -> SatisfiabilityResult:
472
+ """Is ``name``'s (unfolded) definition satisfiable by ANY object at all?
473
+
474
+ Decided by handing ``Not(unfold(name, definitions))`` to
475
+ :func:`unicode_logic_kit.api.prove` (empty premises, i.e. a validity check)
476
+ over its FOL backend chain (Z3, the kit's tableau/resolution provers, and
477
+ the finite model finder, by default — see
478
+ :func:`unicode_logic_kit.atp.protocol.default_chain`): proving
479
+ ``¬unfolded`` valid is exactly proving ``unfolded`` unsatisfiable, and
480
+ REFUTING ``¬unfolded`` means some backend produced an actual model of
481
+ ``unfolded`` — a genuine, checkable witness rather than a guess. This is
482
+ the "und/oder api.prove" reading: the model finder is already one member
483
+ of that chain, so this one call already uses both routes the finder and
484
+ the prover can offer.
485
+
486
+ ``backends``/``timeout``/``**options`` are forwarded verbatim to
487
+ :func:`~unicode_logic_kit.api.prove` — as there, extra ``options`` (e.g.
488
+ ``max_steps=``) are sent to EVERY backend in the chain, so only combine
489
+ them with an explicit single-backend ``backends=[...]`` list unless every
490
+ member of the chain genuinely accepts that keyword.
491
+
492
+ Raises:
493
+ KeyError: ``name`` is not a key of ``definitions``.
494
+ NonClosedDefinition: some body in ``definitions`` is not closed —
495
+ see :func:`unfold`.
496
+ """
497
+ if name not in definitions:
498
+ raise KeyError(
499
+ f"check_satisfiable: {name!r} is not a defined name "
500
+ f"(known: {sorted(definitions)})")
501
+ try:
502
+ unfolded = unfold(name, definitions, max_depth=max_depth)
503
+ except CyclicDefinition as exc:
504
+ return SatisfiabilityResult(name=name, status="cyclic", unfolded=None,
505
+ witness=None, backend=None, detail=str(exc))
506
+ except UnfoldDepthExceeded as exc:
507
+ # NOT the same finding as CyclicDefinition: budget exhaustion proves
508
+ # nothing about whether name's definition chain is actually acyclic
509
+ # (it may well be a long but perfectly acyclic hierarchy — see
510
+ # UnfoldDepthExceeded's own docstring) — reporting "cyclic" here would
511
+ # upgrade an undecided budget question into a PROVEN-defect claim
512
+ # (see TheoryReport.proved_problems, which treats "cyclic" as exactly
513
+ # that). Honest status is "unknown".
514
+ return SatisfiabilityResult(name=name, status="unknown", unfolded=None,
515
+ witness=None, backend=None, detail=str(exc))
516
+
517
+ from .. import api # lazy: avoids importing the whole facade at module load
518
+
519
+ verdict = api.prove(Not(unfolded), timeout=timeout, backends=backends, **options)
520
+ if verdict.status == PROVED:
521
+ return SatisfiabilityResult(
522
+ name=name, status="unsatisfiable", unfolded=unfolded, witness=None,
523
+ backend=verdict.backend,
524
+ detail=f"{name} can hold of no object: its unfolded definition is a "
525
+ f"contradiction, proved by {verdict.backend}.")
526
+ if verdict.status == REFUTED:
527
+ return SatisfiabilityResult(
528
+ name=name, status="satisfiable", unfolded=unfolded,
529
+ witness=verdict.countermodel, backend=verdict.backend,
530
+ detail=f"a model satisfying {name} was found by {verdict.backend}.")
531
+ return SatisfiabilityResult(
532
+ name=name, status="unknown", unfolded=unfolded, witness=None,
533
+ backend=verdict.backend if verdict.backend != "chain" else None,
534
+ detail=verdict.detail or "neither proved unsatisfiable nor witnessed "
535
+ "satisfiable within the given backend budget")
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Subsumption
540
+ # ---------------------------------------------------------------------------
541
+
542
+ _SUBSUMPTION_STATUSES = ("entailed", "refuted", "unknown", "cyclic")
543
+
544
+
545
+ @dataclass(frozen=True)
546
+ class SubsumptionResult:
547
+ """Outcome of :func:`check_subsumption` — does ``Def(sub) |= Def(sup)``?
548
+
549
+ ``status``:
550
+ ``"entailed"`` — PROVEN: every object satisfying ``sub``'s unfolded
551
+ definition also satisfies ``sup``'s.
552
+ ``"refuted"`` — PROVEN false: ``countermodel`` is ALWAYS populated
553
+ here (never ``None`` — see the defensive fallback in
554
+ :func:`check_subsumption`'s implementation), a concrete witness
555
+ where ``sub`` holds and ``sup`` does not. This is the module's
556
+ main value-add over a bare prover's witness-free "not proved": a
557
+ reader can see EXACTLY which conjunct of the superclass the
558
+ subclass definition forgot.
559
+ ``"unknown"`` — neither proved nor refuted within the backend
560
+ budget (e.g. a timeout, or a deliberately tiny step bound) —
561
+ NEVER reported as ``"refuted"``.
562
+ ``"cyclic"`` — ``sub`` or ``sup`` could not be unfolded (reachable
563
+ from a definitional cycle); ``verdict``/``countermodel`` are
564
+ ``None`` in this case.
565
+
566
+ ``verdict`` is the underlying :class:`~unicode_logic_kit.atp.protocol.Verdict`
567
+ (as a dict, via its own ``to_dict()``) for full provenance — ``None`` only
568
+ for ``"cyclic"``. ``explanation`` is a short plain-English gloss: for
569
+ ``"refuted"`` it is built the same way ``api.countermodel`` builds
570
+ ``explanation_nl`` (:func:`unicode_logic_kit.eval.explain.explain_countermodel`),
571
+ for every other status it falls back to the verdict's own ``detail``/the
572
+ cycle message.
573
+ """
574
+
575
+ sub: str
576
+ sup: str
577
+ status: str
578
+ verdict: Optional[dict]
579
+ countermodel: Optional[dict]
580
+ explanation: Optional[str]
581
+
582
+ def __post_init__(self):
583
+ if self.status not in _SUBSUMPTION_STATUSES:
584
+ raise ValueError(
585
+ f"SubsumptionResult: unknown status {self.status!r} "
586
+ f"(use one of {_SUBSUMPTION_STATUSES})")
587
+ if self.status == "refuted" and self.countermodel is None:
588
+ raise ValueError(
589
+ "SubsumptionResult: status='refuted' must always carry a "
590
+ "countermodel — a refutation claim with no witness is exactly "
591
+ "what this module exists to never emit (see check_subsumption's "
592
+ "defensive fallback, which downgrades to 'unknown' instead of "
593
+ "constructing a result like this).")
594
+
595
+ def to_dict(self) -> dict:
596
+ return {
597
+ "sub": self.sub,
598
+ "sup": self.sup,
599
+ "status": self.status,
600
+ "verdict": self.verdict,
601
+ "countermodel": self.countermodel,
602
+ "explanation": self.explanation,
603
+ }
604
+
605
+
606
+ def check_subsumption(sub: str, sup: str, definitions: Definitions, *,
607
+ timeout: int = 10000,
608
+ backends: Optional[Sequence[str]] = None,
609
+ max_depth: int = DEFAULT_MAX_DEPTH,
610
+ **options) -> SubsumptionResult:
611
+ """Does ``Def(sub)`` entail ``Def(sup)`` — ``sub`` really a subclass?
612
+
613
+ Both definitions are unfolded (see :func:`unfold`), and
614
+ :func:`unicode_logic_kit.api.prove` is asked to decide
615
+ ``unfold(sub) -> unfold(sup)`` (empty premises: a plain validity check of
616
+ the implication) over its FOL backend chain. A ``"refuted"`` result is
617
+ NEVER handed back without a countermodel: if the chain's own
618
+ :class:`~unicode_logic_kit.atp.protocol.Verdict` reports REFUTED but (for
619
+ some backend combination) carries no witness, :func:`api.countermodel`
620
+ is tried once more before conceding — and if that also finds nothing,
621
+ the result is downgraded to ``"unknown"`` rather than asserting an
622
+ unwitnessed refutation (see :class:`SubsumptionResult`'s invariant).
623
+
624
+ ``backends``/``timeout``/``**options`` are forwarded verbatim to
625
+ :func:`~unicode_logic_kit.api.prove` — see :func:`check_satisfiable`'s
626
+ docstring for the same caveat about mixing ``**options`` with a
627
+ multi-backend chain.
628
+
629
+ Raises:
630
+ KeyError: ``sub`` or ``sup`` is not a key of ``definitions``.
631
+ NonClosedDefinition: some body in ``definitions`` is not closed —
632
+ see :func:`unfold`.
633
+ """
634
+ for role, class_name in (("sub", sub), ("sup", sup)):
635
+ if class_name not in definitions:
636
+ raise KeyError(
637
+ f"check_subsumption: {role}={class_name!r} is not a defined name "
638
+ f"(known: {sorted(definitions)})")
639
+
640
+ try:
641
+ unfolded_sub = unfold(sub, definitions, max_depth=max_depth)
642
+ unfolded_sup = unfold(sup, definitions, max_depth=max_depth)
643
+ except CyclicDefinition as exc:
644
+ return SubsumptionResult(sub=sub, sup=sup, status="cyclic", verdict=None,
645
+ countermodel=None, explanation=str(exc))
646
+ except UnfoldDepthExceeded as exc:
647
+ # See check_satisfiable's identical split: a depth-budget exhaustion
648
+ # is not a proof of a cycle (find_cycles proves that structurally,
649
+ # via find_cycles/CyclicDefinition), so it must not be reported as
650
+ # "cyclic" — that status is documented (TheoryReport.proved_problems)
651
+ # as a PROVEN defect. Honest status is "unknown".
652
+ return SubsumptionResult(sub=sub, sup=sup, status="unknown", verdict=None,
653
+ countermodel=None, explanation=str(exc))
654
+
655
+ from .. import api # lazy, matching check_satisfiable
656
+
657
+ goal = Implies(unfolded_sub, unfolded_sup)
658
+ verdict = api.prove(goal, timeout=timeout, backends=backends, **options)
659
+
660
+ if verdict.status == PROVED:
661
+ return SubsumptionResult(
662
+ sub=sub, sup=sup, status="entailed", verdict=verdict.to_dict(),
663
+ countermodel=None,
664
+ explanation=f"Def({sub}) |= Def({sup}) — proved by {verdict.backend}.")
665
+
666
+ if verdict.status == REFUTED:
667
+ countermodel = verdict.countermodel
668
+ if countermodel is None:
669
+ # Defensive fallback: every default-chain member that can REFUTE
670
+ # (z3, modelfinder) already attaches one, but a caller-supplied
671
+ # backends= list could in principle name one that cannot (see
672
+ # VampireBackend's documented CounterSatisfiable-without-model
673
+ # case). Try the dedicated countermodel search once before
674
+ # conceding — see the module/class docstrings for why an
675
+ # unwitnessed "refuted" is never acceptable here.
676
+ cm_result = api.countermodel(goal, timeout=timeout, backends=backends)
677
+ countermodel = cm_result.model if cm_result.found else None
678
+ if countermodel is None:
679
+ return SubsumptionResult(
680
+ sub=sub, sup=sup, status="unknown", verdict=verdict.to_dict(),
681
+ countermodel=None,
682
+ explanation="a backend reported refutation without a recoverable "
683
+ "countermodel witness; treated as undecided rather "
684
+ "than asserting an unwitnessed refutation.")
685
+ explanation = None
686
+ try:
687
+ from .explain import explain_countermodel
688
+ explanation = explain_countermodel(countermodel)
689
+ except Exception:
690
+ pass
691
+ if explanation is None:
692
+ explanation = (f"{verdict.backend} found a countermodel: {sub} holds "
693
+ f"but {sup} does not.")
694
+ return SubsumptionResult(
695
+ sub=sub, sup=sup, status="refuted", verdict=verdict.to_dict(),
696
+ countermodel=countermodel, explanation=explanation)
697
+
698
+ return SubsumptionResult(
699
+ sub=sub, sup=sup, status="unknown", verdict=verdict.to_dict(),
700
+ countermodel=None,
701
+ explanation=verdict.detail or "neither proved nor refuted within the "
702
+ "given backend budget")
703
+
704
+
705
+ # ---------------------------------------------------------------------------
706
+ # Whole-theory report
707
+ # ---------------------------------------------------------------------------
708
+
709
+ @dataclass(frozen=True)
710
+ class TheoryReport:
711
+ """The combined verification result over a definition set.
712
+
713
+ ``cycles`` is :func:`find_cycles`'s output (the authoritative structural
714
+ view — a name inside a cycle also shows up with ``status="cyclic"`` in
715
+ ``satisfiability``/``subsumptions``, individually, but ``cycles`` is what
716
+ names the actual loop). ``satisfiability`` covers EVERY name in the
717
+ definition set (not just ones mentioned in ``subsumptions``); a dead
718
+ classifier is exactly as reportable on its own as a broken subsumption.
719
+
720
+ :attr:`proved_problems` / :attr:`undecided` are the split the module
721
+ docstring promises: the first is every finding this module actually
722
+ PROVED (a cycle, an unsatisfiable definition, a refuted subsumption —
723
+ each with its evidence), the second is every finding that stayed
724
+ genuinely open. A caller building a pass/fail gate should fail on the
725
+ first and merely flag the second for human attention.
726
+ """
727
+
728
+ cycles: Tuple[Tuple[str, ...], ...]
729
+ satisfiability: Mapping[str, SatisfiabilityResult]
730
+ subsumptions: Tuple[SubsumptionResult, ...]
731
+
732
+ @property
733
+ def proved_problems(self) -> Tuple[dict, ...]:
734
+ """Every DEFINITIVELY established defect — never an 'unknown'."""
735
+ problems = []
736
+ for cycle in self.cycles:
737
+ problems.append({"kind": "cycle", "names": list(cycle)})
738
+ for name, result in sorted(self.satisfiability.items()):
739
+ if result.status == "unsatisfiable":
740
+ problems.append({"kind": "unsatisfiable", "name": name,
741
+ "detail": result.detail})
742
+ for result in self.subsumptions:
743
+ if result.status == "refuted":
744
+ problems.append({"kind": "subsumption_refuted", "sub": result.sub,
745
+ "sup": result.sup, "explanation": result.explanation})
746
+ return tuple(problems)
747
+
748
+ @property
749
+ def undecided(self) -> Tuple[dict, ...]:
750
+ """Every check that ended in 'unknown' — genuinely open, not a defect."""
751
+ open_items = []
752
+ for name, result in sorted(self.satisfiability.items()):
753
+ if result.status == "unknown":
754
+ open_items.append({"kind": "satisfiability", "name": name,
755
+ "detail": result.detail})
756
+ for result in self.subsumptions:
757
+ if result.status == "unknown":
758
+ open_items.append({"kind": "subsumption", "sub": result.sub,
759
+ "sup": result.sup, "detail": result.explanation})
760
+ return tuple(open_items)
761
+
762
+ def to_dict(self) -> dict:
763
+ return {
764
+ "cycles": [list(c) for c in self.cycles],
765
+ "satisfiability": {name: r.to_dict()
766
+ for name, r in sorted(self.satisfiability.items())},
767
+ "subsumptions": [r.to_dict() for r in self.subsumptions],
768
+ "proved_problems": list(self.proved_problems),
769
+ "undecided": list(self.undecided),
770
+ }
771
+
772
+ def to_markdown(self) -> str:
773
+ """Render this report as a plain-formatted Markdown document.
774
+
775
+ One top summary line (:attr:`proved_problems` vs :attr:`undecided`
776
+ counts), then a section each for ``cycles`` (the name chains
777
+ :func:`find_cycles` found), ``satisfiability`` (grouped by status; a
778
+ ``"satisfiable"`` witness is glossed by :func:`_witness_gloss` when
779
+ one was recovered — honestly, as an existential witness rather than
780
+ an implication countermodel; see that function's own docstring and
781
+ :class:`SatisfiabilityResult`), and ``subsumptions`` (grouped by
782
+ status; each row's own ``.explanation`` is rendered verbatim,
783
+ falling back to
784
+ :func:`~unicode_logic_kit.eval.explain.explain_countermodel` on
785
+ ``.countermodel`` only in the defensive case where ``.explanation``
786
+ is itself ``None``).
787
+
788
+ Every rendered name/detail/explanation is put through
789
+ :func:`_md_cell`, so a hostile string (one containing ``|`` or a
790
+ newline — a molecule name or an error message is never under this
791
+ module's control) cannot corrupt a table's row/column structure.
792
+ Calling this twice on the same report always returns the identical
793
+ string (nothing here depends on dict/set iteration order — every
794
+ grouping is walked in the fixed, sorted order already used by
795
+ :meth:`to_dict`/:attr:`proved_problems`).
796
+ """
797
+ return "\n".join(_theory_markdown_lines(self))
798
+
799
+ def to_html(self, title: str = "Theory report") -> str:
800
+ """Render as a self-contained, theme-aware HTML page.
801
+
802
+ Same idiom as :meth:`unicode_logic_kit.fol.derivation.CCGDerivation.to_html`
803
+ and the ``atp`` Fitch/sequent renderers built on
804
+ :mod:`unicode_logic_kit.atp._html`: one ``<!doctype html>`` page with
805
+ the shared colour tokens, headings/tables for the same three sections
806
+ :meth:`to_markdown` renders, and every user-supplied string
807
+ (definition name, detail, explanation) HTML-escaped via
808
+ :func:`~unicode_logic_kit.atp._html.esc_html`.
809
+ """
810
+ return html_page(title, _theory_html_body(self), _THEORY_HTML_CSS)
811
+
812
+
813
+ def check_theory(definitions: Definitions, *,
814
+ subsumptions: Sequence[Tuple[str, str]] = (),
815
+ timeout: int = 10000,
816
+ backends: Optional[Sequence[str]] = None,
817
+ max_depth: int = DEFAULT_MAX_DEPTH,
818
+ **options) -> TheoryReport:
819
+ """Run every check this module offers over a whole definition set.
820
+
821
+ Cycles (:func:`find_cycles`), satisfiability of every definition
822
+ (:func:`check_satisfiable`, one call per name), and every requested
823
+ subsumption pair (:func:`check_subsumption`, one call per pair in
824
+ ``subsumptions``) — see :class:`TheoryReport` for how the results are
825
+ organised and split into proved-vs-undecided. ``timeout``/``backends``/
826
+ ``max_depth``/``**options`` apply uniformly to every underlying call.
827
+
828
+ Raises:
829
+ KeyError: a name in ``subsumptions`` is not a key of ``definitions``.
830
+ NonClosedDefinition: some body in ``definitions`` is not closed —
831
+ see :func:`unfold`. Raised by the first underlying
832
+ :func:`check_satisfiable`/:func:`check_subsumption` call that
833
+ reaches it, so ``cycles`` (computed first, structurally, with no
834
+ unfolding involved) is never the cause of this and is simply not
835
+ returned when it happens.
836
+ """
837
+ cycles = find_cycles(definitions)
838
+ satisfiability = {
839
+ name: check_satisfiable(name, definitions, timeout=timeout,
840
+ backends=backends, max_depth=max_depth, **options)
841
+ for name in sorted(definitions)
842
+ }
843
+ sub_results = tuple(
844
+ check_subsumption(sub, sup, definitions, timeout=timeout,
845
+ backends=backends, max_depth=max_depth, **options)
846
+ for sub, sup in subsumptions
847
+ )
848
+ return TheoryReport(cycles=cycles, satisfiability=satisfiability,
849
+ subsumptions=sub_results)
850
+
851
+
852
+ # ---------------------------------------------------------------------------
853
+ # TheoryReport.to_markdown() / to_html() — display only, no new proof/model-
854
+ # finding logic (see the roadmap item this implements: a pure formatting
855
+ # layer over already-verified TheoryReport/SatisfiabilityResult/
856
+ # SubsumptionResult data, so it introduces no soundness risk).
857
+ # ---------------------------------------------------------------------------
858
+
859
+ def _md_cell(value) -> str:
860
+ """Escape a value for safe embedding in one Markdown table cell.
861
+
862
+ A bare ``|`` would be read as a new column and an embedded newline would
863
+ split the row across lines, silently corrupting every column after it —
864
+ so both are neutralised. This only ever touches the RENDERED copy: the
865
+ original string on the result object is never modified.
866
+ """
867
+ text = "" if value is None else str(value)
868
+ text = text.replace("|", "\\|")
869
+ return text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
870
+
871
+
872
+ def _witness_gloss(witness: Optional[dict]) -> Optional[str]:
873
+ """A short, honest gloss of a :class:`SatisfiabilityResult` witness, or
874
+ ``None`` if there is none.
875
+
876
+ ``witness`` here is documented (see :class:`SatisfiabilityResult`) to be
877
+ in the exact same JSON-able shape as
878
+ :class:`~unicode_logic_kit.atp.protocol.Verdict.countermodel` — but it is
879
+ NOT itself an implication countermodel: :func:`check_satisfiable` proves
880
+ ``Not(unfolded)`` REFUTED with EMPTY premises, so the model this witness
881
+ describes is a single-formula existential witness of ``unfolded``, not a
882
+ model where "premises hold and the goal fails" (the shape an actual
883
+ countermodel, e.g. :class:`SubsumptionResult`'s, has).
884
+ :func:`~unicode_logic_kit.eval.explain.explain_countermodel`'s Z3-assignment
885
+ branch is hardcoded to that implication wording ("... the two sides
886
+ differ"), which would misstate what was computed here — so any witness
887
+ carrying an ``"assignment"`` dict (a bare ``{name: value}`` witness, or a
888
+ ``{"kind": ..., "assignment": {...}}`` one — ``"z3_model"`` is the common
889
+ case since Z3 leads the default backend chain, but
890
+ :mod:`unicode_logic_kit.atp.cvc5_backend` emits the identical
891
+ ``{"kind": "cvc5_model", "assignment": {...}}`` shape and would otherwise
892
+ have no branch of its own here) is glossed locally instead, via
893
+ :func:`_z3_satisfiability_gloss`. This mirrors
894
+ :func:`unicode_logic_kit.eval.chem_batch._gloss_chem_witness`'s reasoning
895
+ for the exact same class of problem on that module's own (differently
896
+ shaped) row witnesses.
897
+
898
+ Every other witness kind that carries a ``"repr"`` fallback (a Kripke
899
+ model, the modelfinder's own ``{"kind": "finite_structure", "repr": ...}``,
900
+ a Nitpick counterexample, ...) has no implication-specific sentence in
901
+ :func:`explain_countermodel`'s output, so those are still glossed through
902
+ it as-is — reusing its richer world/domain/relation rendering rather than
903
+ duplicating it. A witness with neither an ``"assignment"`` dict nor a
904
+ ``"repr"`` key — e.g. the clingo/minizinc backends' own
905
+ ``{"kind": "finite_structure", "data": {...}}`` (a different key from the
906
+ modelfinder's ``"repr"`` shape above) — would make
907
+ :func:`explain_countermodel` raise ``ValueError`` (it has nothing to
908
+ explain), which must never propagate out of a report-rendering method, so
909
+ that case falls back to :func:`_generic_satisfiability_gloss` instead.
910
+ """
911
+ if witness is None:
912
+ return None
913
+ kind = witness.get("kind") if isinstance(witness, dict) else None
914
+ assignment: Optional[dict] = None
915
+ if isinstance(witness, dict):
916
+ if kind is None:
917
+ assignment = witness # bare {name: value}, no "kind" key
918
+ elif isinstance(witness.get("assignment"), dict):
919
+ assignment = witness["assignment"] # any *_model kind: z3_model, cvc5_model, ...
920
+ if assignment is not None:
921
+ return _z3_satisfiability_gloss(assignment)
922
+ if isinstance(witness, dict) and "repr" in witness:
923
+ from .explain import explain_countermodel # lazy, mirrors check_subsumption's own import
924
+ return explain_countermodel(witness)
925
+ return _generic_satisfiability_gloss(witness)
926
+
927
+
928
+ def _z3_satisfiability_gloss(assignment: dict) -> str:
929
+ """Render a Z3/cvc5-style ``{name: value}`` SATISFIABILITY witness
930
+ honestly.
931
+
932
+ Despite the name (kept for the common Z3 case, and for the existing test
933
+ surface), this is used for any backend's ``"assignment"``-shaped witness
934
+ — see :func:`_witness_gloss`'s docstring. Deliberately NOT
935
+ :func:`~unicode_logic_kit.eval.explain.explain_countermodel`: this
936
+ assignment satisfies the definition directly — there is no second side
937
+ to compare it against, and that function does not accept a non-Z3 kind
938
+ with an assignment at all (it would raise).
939
+ """
940
+ if not assignment:
941
+ return "a model was found, but it recorded no variable assignments."
942
+ items = sorted(assignment.items(), key=lambda kv: str(kv[0]))
943
+ assigned_str = ", ".join(f"{k} := {v}" for k, v in items)
944
+ return f"Under the assignment {assigned_str}, the definition is satisfied."
945
+
946
+
947
+ def _generic_satisfiability_gloss(witness: object) -> str:
948
+ """A minimal, honest, NEVER-raising gloss for a satisfiability witness
949
+ that :func:`_witness_gloss` could not route anywhere more specific: no
950
+ ``"assignment"`` dict (so :func:`_z3_satisfiability_gloss` does not
951
+ apply) and no ``"repr"`` fallback (so
952
+ :func:`~unicode_logic_kit.eval.explain.explain_countermodel` would raise
953
+ ``ValueError`` rather than render anything).
954
+
955
+ The real shape hitting this today is the clingo/minizinc backends'
956
+ ``{"kind": "finite_structure", "data": {...}}`` (see
957
+ ``unicode_logic_kit.atp.clingo_backend``/``minizinc_backend``) — a
958
+ ``"data"`` key, not the modelfinder's own ``"repr"``-carrying shape of
959
+ the same ``"kind"``. Deliberately does not attempt to parse or
960
+ pretty-print ``"data"``: that would risk a shape-specific, silently
961
+ incomplete duplication of what the backend already encodes, for a
962
+ one-line table cell that only needs to say a model exists.
963
+ """
964
+ kind = witness.get("kind") if isinstance(witness, dict) else None
965
+ label = kind if kind else "unlabelled"
966
+ return f'A "{label}" model was found; the definition is satisfied.'
967
+
968
+
969
+ def _subsumption_explanation(result: "SubsumptionResult") -> Optional[str]:
970
+ """``result.explanation`` if set, else a best-effort fallback computed
971
+ from ``result.countermodel`` — never raising, and never glossing a
972
+ countermodel as a refutation outside ``status="refuted"``.
973
+
974
+ Unlike a :class:`SatisfiabilityResult` witness (see :func:`_witness_gloss`),
975
+ a :class:`SubsumptionResult` countermodel genuinely IS an implication
976
+ countermodel when ``status == "refuted"`` (``check_subsumption`` proves
977
+ ``Def(sub) -> Def(sup)`` REFUTED, i.e. finds a model where ``sub`` holds
978
+ and ``sup`` does not), so :func:`~unicode_logic_kit.eval.explain.explain_countermodel`'s
979
+ wording is the right one there — this is not the satisfiability-witness
980
+ deviation. But ``SubsumptionResult.__post_init__`` only requires
981
+ ``countermodel is not None`` when ``status == "refuted"``; it never
982
+ forbids a countermodel from also being present alongside
983
+ ``status in ("unknown", "entailed", "cyclic")`` on a hand-built instance
984
+ (as this file's own tests build throughout), and this module's own
985
+ docstring promises ``"unknown"`` is NEVER reported as ``"refuted"`` — so
986
+ the countermodel-based fallback below is only ever computed for
987
+ ``status == "refuted"``, matching what ``check_subsumption`` itself ever
988
+ produces.
989
+
990
+ ``check_subsumption`` itself always sets ``explanation`` to a non-``None``
991
+ string (it wraps its own ``explain_countermodel`` call in
992
+ ``try/except Exception`` and falls back to a generic sentence — see that
993
+ function's body), so this fallback path is never hit by the module's own
994
+ top-level API. But ``SubsumptionResult`` is a public dataclass whose
995
+ ``__post_init__`` never requires ``explanation`` to be set. A caller
996
+ building one by hand can therefore reach a ``status="refuted"``,
997
+ ``explanation=None`` object carrying a countermodel shape
998
+ ``explain_countermodel`` cannot handle — a bare
999
+ ``cvc5_model``/``finite_structure``-without-``repr`` witness, for
1000
+ instance — and a report renderer must never crash on that, so the same
1001
+ guard ``check_subsumption`` uses internally is mirrored here.
1002
+ """
1003
+ if result.explanation is not None:
1004
+ return result.explanation
1005
+ if result.status != "refuted" or result.countermodel is None:
1006
+ return None
1007
+ try:
1008
+ from .explain import explain_countermodel
1009
+ return explain_countermodel(result.countermodel)
1010
+ except Exception:
1011
+ backend = None
1012
+ if isinstance(result.verdict, dict):
1013
+ backend = result.verdict.get("backend")
1014
+ who = backend or "a backend"
1015
+ return (f"{who} found a countermodel: {result.sub} holds "
1016
+ f"but {result.sup} does not.")
1017
+
1018
+
1019
+ def _theory_markdown_lines(report: TheoryReport) -> List[str]:
1020
+ lines: List[str] = ["# Theory report", ""]
1021
+ lines.append(f"**{len(report.proved_problems)}** proved problem(s), "
1022
+ f"**{len(report.undecided)}** undecided.")
1023
+ lines.append("")
1024
+
1025
+ lines.append("## Cycles")
1026
+ lines.append("")
1027
+ if report.cycles:
1028
+ for cycle in report.cycles:
1029
+ lines.append("- " + " -> ".join(_md_cell(name) for name in cycle))
1030
+ else:
1031
+ lines.append("No cycles.")
1032
+ lines.append("")
1033
+
1034
+ lines.append("## Satisfiability")
1035
+ lines.append("")
1036
+ by_status: Dict[str, List[SatisfiabilityResult]] = {}
1037
+ for _, result in sorted(report.satisfiability.items()):
1038
+ by_status.setdefault(result.status, []).append(result)
1039
+ if not by_status:
1040
+ lines.append("No definitions.")
1041
+ for status in _SATISFIABILITY_STATUSES:
1042
+ results = by_status.get(status)
1043
+ if not results:
1044
+ continue
1045
+ lines.append(f"### {status}")
1046
+ lines.append("")
1047
+ lines.append("| name | detail |")
1048
+ lines.append("|---|---|")
1049
+ for result in results:
1050
+ detail = result.detail or ""
1051
+ # Only "satisfiable" is documented to carry a witness (see
1052
+ # SatisfiabilityResult's docstring); __post_init__ does not
1053
+ # forbid a witness on another status on a hand-built instance,
1054
+ # so gate on status here rather than on witness truthiness alone
1055
+ # to avoid glossing e.g. an "unsatisfiable" result as if a model
1056
+ # were found.
1057
+ gloss = _witness_gloss(result.witness) if result.status == "satisfiable" else None
1058
+ if gloss:
1059
+ detail = f"{detail} {gloss}".strip()
1060
+ lines.append(f"| {_md_cell(result.name)} | {_md_cell(detail)} |")
1061
+ lines.append("")
1062
+
1063
+ lines.append("## Subsumptions")
1064
+ lines.append("")
1065
+ sub_by_status: Dict[str, List[SubsumptionResult]] = {}
1066
+ for result in report.subsumptions:
1067
+ sub_by_status.setdefault(result.status, []).append(result)
1068
+ if not sub_by_status:
1069
+ lines.append("No subsumption checks.")
1070
+ for status in _SUBSUMPTION_STATUSES:
1071
+ results = sub_by_status.get(status)
1072
+ if not results:
1073
+ continue
1074
+ lines.append(f"### {status}")
1075
+ lines.append("")
1076
+ lines.append("| sub | sup | explanation |")
1077
+ lines.append("|---|---|---|")
1078
+ for result in results:
1079
+ explanation = _subsumption_explanation(result)
1080
+ lines.append(f"| {_md_cell(result.sub)} | {_md_cell(result.sup)} | "
1081
+ f"{_md_cell(explanation)} |")
1082
+ lines.append("")
1083
+
1084
+ while lines and lines[-1] == "":
1085
+ lines.pop()
1086
+ lines.append("")
1087
+ return lines
1088
+
1089
+
1090
+ _THEORY_HTML_CSS = """
1091
+ .rpt{max-width:900px;margin:0 auto;padding:26px 16px;
1092
+ font-family:ui-sans-serif,system-ui,"Segoe UI",Arial,sans-serif;
1093
+ font-size:14px;line-height:1.5}
1094
+ .rpt h1{font-size:20px;margin:0 0 8px}
1095
+ .rpt h2{font-size:16px;margin:22px 0 6px;border-bottom:1.3px solid var(--bar);padding-bottom:3px}
1096
+ .rpt h3{font-size:12.5px;margin:14px 0 4px;color:var(--muted);
1097
+ text-transform:uppercase;letter-spacing:.03em}
1098
+ .rpt table{border-collapse:collapse;width:100%;margin:4px 0 14px}
1099
+ .rpt th,.rpt td{border:1px solid var(--bar);padding:4px 8px;text-align:left;
1100
+ vertical-align:top}
1101
+ .rpt th{color:var(--muted);font-weight:600}
1102
+ .rpt ul{margin:6px 0 14px;padding-left:22px}
1103
+ .rpt .muted{color:var(--muted)}
1104
+ """
1105
+
1106
+
1107
+ def _status_table(rows: List[Tuple[str, ...]], headers: Tuple[str, ...]) -> str:
1108
+ head = "".join("<th>%s</th>" % esc_html(h) for h in headers)
1109
+ body = "".join(
1110
+ "<tr>%s</tr>" % "".join("<td>%s</td>" % esc_html(cell) for cell in row)
1111
+ for row in rows
1112
+ )
1113
+ return "<table><tr>%s</tr>%s</table>" % (head, body)
1114
+
1115
+
1116
+ def _theory_html_body(report: TheoryReport) -> str:
1117
+ parts: List[str] = ['<div class="rpt">', "<h1>Theory report</h1>",
1118
+ "<p>%d proved problem(s), %d undecided.</p>"
1119
+ % (len(report.proved_problems), len(report.undecided))]
1120
+
1121
+ parts.append("<h2>Cycles</h2>")
1122
+ if report.cycles:
1123
+ items = "".join("<li>%s</li>" % esc_html(" -> ".join(cycle))
1124
+ for cycle in report.cycles)
1125
+ parts.append("<ul>%s</ul>" % items)
1126
+ else:
1127
+ parts.append('<p class="muted">No cycles.</p>')
1128
+
1129
+ parts.append("<h2>Satisfiability</h2>")
1130
+ by_status: Dict[str, List[SatisfiabilityResult]] = {}
1131
+ for _, result in sorted(report.satisfiability.items()):
1132
+ by_status.setdefault(result.status, []).append(result)
1133
+ if not by_status:
1134
+ parts.append('<p class="muted">No definitions.</p>')
1135
+ for status in _SATISFIABILITY_STATUSES:
1136
+ results = by_status.get(status)
1137
+ if not results:
1138
+ continue
1139
+ rows = []
1140
+ for result in results:
1141
+ detail = result.detail or ""
1142
+ # See the matching comment in _theory_markdown_lines: gate on
1143
+ # status, not witness truthiness, so only "satisfiable" ever
1144
+ # gets model-found prose.
1145
+ gloss = _witness_gloss(result.witness) if result.status == "satisfiable" else None
1146
+ if gloss:
1147
+ detail = f"{detail} {gloss}".strip()
1148
+ rows.append((result.name, detail))
1149
+ parts.append("<h3>%s</h3>" % esc_html(status))
1150
+ parts.append(_status_table(rows, ("name", "detail")))
1151
+
1152
+ parts.append("<h2>Subsumptions</h2>")
1153
+ sub_by_status: Dict[str, List[SubsumptionResult]] = {}
1154
+ for result in report.subsumptions:
1155
+ sub_by_status.setdefault(result.status, []).append(result)
1156
+ if not sub_by_status:
1157
+ parts.append('<p class="muted">No subsumption checks.</p>')
1158
+ for status in _SUBSUMPTION_STATUSES:
1159
+ results = sub_by_status.get(status)
1160
+ if not results:
1161
+ continue
1162
+ rows = []
1163
+ for result in results:
1164
+ explanation = _subsumption_explanation(result)
1165
+ rows.append((result.sub, result.sup, explanation or ""))
1166
+ parts.append("<h3>%s</h3>" % esc_html(status))
1167
+ parts.append(_status_table(rows, ("sub", "sup", "explanation")))
1168
+
1169
+ parts.append("</div>")
1170
+ return "".join(parts)