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,791 @@
1
+ """Plain-English rendering of a countermodel — the "why did this fail" gloss.
2
+
3
+ A countermodel proves *invalidity* by exhibiting a concrete structure, but a
4
+ raw :class:`~unicode_logic_kit.semantics.kripke.KripkeModel`, a raw
5
+ :class:`~unicode_logic_kit.semantics.tarski.Structure`, or a Z3 assignment dict
6
+ is opaque to anyone who is not already reading this codebase.
7
+ :func:`explain_countermodel` turns any of those into a short, deterministic
8
+ English paragraph a human (or an LLM judge) can read directly.
9
+
10
+ Four input shapes are accepted — the ones that actually occur, either built by
11
+ hand/by a semantic search, or produced by the :mod:`unicode_logic_kit.atp.protocol`
12
+ Verdict layer as ``Verdict.countermodel`` / ``CountermodelResult.model``:
13
+
14
+ * :class:`~unicode_logic_kit.semantics.kripke.KripkeModel` — a possible-worlds
15
+ countermodel. Reported: the number of worlds, the edges of every named
16
+ accessibility relation, and the atoms true at each world. If ``formula`` is
17
+ supplied and can actually be evaluated at world 0
18
+ (:func:`~unicode_logic_kit.semantics.kripke.satisfies_modal`), a closing
19
+ sentence names whether it fails there — see the honesty note below.
20
+ * :class:`~unicode_logic_kit.semantics.tarski.Structure` — a finite first-order
21
+ countermodel. Reported: the domain size and its elements, the denotation of
22
+ every constant, and the extension of every predicate and function symbol.
23
+ * a bare ``dict`` with no ``"kind"`` key — a Z3-style assignment
24
+ (``{name: value}``, exactly the shape ``Z3Backend.decide`` puts in
25
+ ``Verdict.countermodel["assignment"]``). Reported: every assignment,
26
+ enumerated.
27
+ * a **witness dict** carrying a ``"kind"`` key — the shape the Verdict layer
28
+ actually stores at ``Verdict.countermodel`` / ``CountermodelResult.model``:
29
+ ``{"kind": "z3_model", "assignment": {...}}`` (routed to the same Z3
30
+ explanation as the bare-dict case above), or ``{"kind": "kripke" |
31
+ "finite_structure" | "nitpick" | ..., "repr": "<python repr string>"}``.
32
+ The repr-only shapes carry no structured data — only a Python ``repr()`` (or,
33
+ for Isabelle's nitpick, its own textual countermodel) — so they are framed as
34
+ an unparsed witness rather than reformatted as if they were structured; doing
35
+ otherwise would mean inventing structure that was never actually recovered.
36
+
37
+ Honesty note (the same discipline the rest of the kit applies to proof search):
38
+ this function only ever states what it *computed*. The world-0 "fails" sentence
39
+ for a Kripke model is added only when ``satisfies_modal`` actually returns
40
+ ``False`` — never inferred. Evaluation failures are treated as "not evaluable"
41
+ and the sentence is silently omitted rather than guessed: a
42
+ :class:`NotImplementedError` (the modal evaluator's own signal for an
43
+ out-of-fragment node, e.g. a quantifier, a fuzzy or lambda node) as well as a
44
+ :class:`ValueError` / :class:`KeyError` (a hybrid nominal with no assignment,
45
+ an unbound variable, a model with no object domains) all count, since none of
46
+ them yield a truth value to report.
47
+
48
+ Determinism: every enumeration (worlds, relation edges, atoms, domain
49
+ elements, constants, predicate/function extensions, Z3 assignments) is sorted
50
+ before rendering — falling back to a ``(type name, str value)`` key when the
51
+ raw values are not mutually orderable (e.g. mixed int/str worlds) — so calling
52
+ this function twice on the same input always returns byte-identical output.
53
+
54
+ Output shape: 2-6 short English sentences, plain text (no Markdown, no
55
+ line-art), joined with single spaces. ``max_sentences`` caps the sentence
56
+ count; content beyond the cap is dropped in the priority order documented on
57
+ each ``_explain_*`` helper below (never reordered, so raising the cap never
58
+ changes which sentences appear first — only how many spill over).
59
+
60
+ :func:`explain_proof` is the same idea for the other side of a
61
+ :class:`~unicode_logic_kit.atp.protocol.Verdict`: a proof of *validity* instead
62
+ of a countermodel of invalidity. See its own docstring for the shapes it
63
+ accepts.
64
+ """
65
+
66
+ from itertools import product
67
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
68
+
69
+ from ..atp.tableau import TableauProof
70
+ from ..atp.tstp import TstpDerivation
71
+ from ..atp.twee_entailment import TweeProof
72
+ from ..fol.nodes import Node
73
+ from ..semantics.kripke import KripkeModel, satisfies_modal
74
+ from ..semantics.tarski import Structure
75
+
76
+ __all__ = ["explain_countermodel", "explain_proof"]
77
+
78
+ #: Exceptions that mean "this evaluation attempt yielded no truth value" —
79
+ #: caught around the optional world-0 formula check so the missing sentence is
80
+ #: skipped instead of the whole explanation crashing. NotImplementedError is
81
+ #: satisfies_modal's own signal for an out-of-fragment node (quantifier, fuzzy,
82
+ #: lambda); ValueError/KeyError cover a hybrid nominal with no assignment, an
83
+ #: unknown quantifier type, or object quantifiers over a model without domains
84
+ #: — all genuine "not evaluable here", never a reason to guess a truth value.
85
+ _NOT_EVALUABLE = (NotImplementedError, ValueError, KeyError)
86
+
87
+ #: Cap on how many tuples a single predicate/function-extension sentence lists
88
+ #: verbatim before it switches to "... and N more" — keeps one enormous
89
+ #: extension from silently eating the whole sentence budget.
90
+ _TUPLE_LIST_LIMIT = 8
91
+
92
+ #: Cap on |domain|**arity before a callable function interpretation is
93
+ #: enumerated by brute-force calling — protects against paying an unbounded
94
+ #: number of calls (and their possible side effects) just to describe one.
95
+ _MAX_FUNCTION_ENUMERATION = 64
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Small deterministic-formatting helpers shared by every branch
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def _s(value: Any) -> str:
103
+ """Render ``value`` as a single-line string (collapses embedded newlines).
104
+
105
+ Used for every value that ends up inside a sentence, including opaque
106
+ ``repr()`` strings from a witness dict, so a stray newline in someone's
107
+ ``__repr__`` can never break the "no line-art" output contract.
108
+ """
109
+ return str(value).replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
110
+
111
+
112
+ def _safe_sorted(items: Iterable[Any]) -> List[Any]:
113
+ """Sort ``items``, falling back to a ``(type name, str)`` key if unorderable.
114
+
115
+ Kripke worlds and structure domain elements are typed as "any hashable
116
+ value", so a mixed-type collection (e.g. some int worlds, some str worlds)
117
+ can raise ``TypeError`` from a direct ``sorted()`` call. The fallback key
118
+ is still total and deterministic — it just does not claim a "natural"
119
+ ordering across incomparable types, only a reproducible one.
120
+ """
121
+ items = list(items)
122
+ try:
123
+ return sorted(items)
124
+ except TypeError:
125
+ return sorted(items, key=lambda x: (type(x).__name__, str(x)))
126
+
127
+
128
+ def _count_word(n: int, noun: str) -> str:
129
+ """``"1 world"`` / ``"3 worlds"`` — regular pluralisation by appending 's'.
130
+
131
+ Every noun this module counts with (world, edge, individual, assignment)
132
+ pluralises regularly, so no irregular-plural table is needed.
133
+ """
134
+ return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
135
+
136
+
137
+ def _format_tuple(value: Any) -> str:
138
+ """Render an argument tuple as ``"(a, b)"``, or a bare value as-is."""
139
+ if isinstance(value, tuple):
140
+ return "(" + ", ".join(_s(e) for e in value) + ")"
141
+ return _s(value)
142
+
143
+
144
+ def _format_tuple_list(tuples: List[Any], limit: int = _TUPLE_LIST_LIMIT) -> str:
145
+ """Join formatted tuples with commas, truncating with an "and N more" tail.
146
+
147
+ "entry"/"entries" is an irregular plural, so this is spelled out directly
148
+ rather than routed through :func:`_count_word` (which only handles regular
149
+ ``+s`` nouns).
150
+ """
151
+ shown = tuples[:limit]
152
+ text = ", ".join(_format_tuple(t) for t in shown)
153
+ remaining = len(tuples) - len(shown)
154
+ if remaining == 1:
155
+ text += ", and 1 more entry"
156
+ elif remaining > 1:
157
+ text += f", and {remaining} more entries"
158
+ return text
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # KripkeModel branch
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def _explain_kripke(model: KripkeModel, formula: Optional[Node],
166
+ max_sentences: int) -> str:
167
+ """Explain a possible-worlds countermodel.
168
+
169
+ Sentence priority (highest first, so raising ``max_sentences`` only ever
170
+ reveals more, never reorders what is already shown):
171
+
172
+ 1. World count.
173
+ 2. One sentence per named relation family with at least one edge (sorted
174
+ by relation name), or a single "no accessibility edges" sentence if
175
+ every relation is empty.
176
+ 3. One sentence per world (sorted) naming the atoms true there.
177
+ 4. If ``formula`` is given and ``satisfies_modal(formula, model, 0)``
178
+ actually evaluates to ``False``, the fixed sentence
179
+ "At world 0 the formula fails." — omitted (not guessed) whenever the
180
+ evaluation is not evaluable at all (see ``_NOT_EVALUABLE``) or returns
181
+ ``True``.
182
+ """
183
+ worlds = _safe_sorted(model.worlds)
184
+ sentences: List[str] = [
185
+ f"The countermodel has {_count_word(len(worlds), 'possible world')}."
186
+ ]
187
+
188
+ relation_sentences: List[str] = []
189
+ for name in sorted(model.relations):
190
+ edges = _safe_sorted(model.relations[name])
191
+ if not edges:
192
+ continue
193
+ edges_str = ", ".join(f"{_s(a)} → {_s(b)}" for a, b in edges)
194
+ relation_sentences.append(
195
+ f'The "{name}" relation has {_count_word(len(edges), "edge")}: {edges_str}.'
196
+ )
197
+ if not relation_sentences:
198
+ relation_sentences = ["There are no accessibility edges between worlds."]
199
+ sentences += relation_sentences
200
+
201
+ for w in worlds:
202
+ atoms = sorted(model.atoms_true_at(w))
203
+ if atoms:
204
+ noun = "atom" if len(atoms) == 1 else "atoms"
205
+ verb = "is" if len(atoms) == 1 else "are"
206
+ sentences.append(
207
+ f"At world {_s(w)}, the {noun} {', '.join(atoms)} {verb} true."
208
+ )
209
+ else:
210
+ sentences.append(f"At world {_s(w)}, no atoms are true.")
211
+
212
+ if formula is not None:
213
+ try:
214
+ value = satisfies_modal(formula, model, 0)
215
+ except _NOT_EVALUABLE:
216
+ value = None
217
+ if value is False:
218
+ sentences.append("At world 0 the formula fails.")
219
+
220
+ return " ".join(sentences[:max_sentences])
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Structure branch
225
+ # ---------------------------------------------------------------------------
226
+
227
+ def _resolve_function_mapping(interp: Any, domain: List[Any], arity: int
228
+ ) -> Optional[Dict[Tuple[Any, ...], Any]]:
229
+ """Return ``{arg_tuple: value}`` for a function interpretation, or ``None``.
230
+
231
+ ``None`` means "not enumerated" — either the interpretation is a callable
232
+ over a domain too large to brute-force (see
233
+ ``_MAX_FUNCTION_ENUMERATION``), or calling it raised. Both are reported
234
+ honestly as "not enumerated" rather than silently omitted or guessed.
235
+ """
236
+ if isinstance(interp, dict):
237
+ return dict(interp)
238
+ if callable(interp):
239
+ if len(domain) ** arity > _MAX_FUNCTION_ENUMERATION:
240
+ return None
241
+ mapping: Dict[Tuple[Any, ...], Any] = {}
242
+ try:
243
+ for args in product(domain, repeat=arity):
244
+ mapping[args] = interp(*args)
245
+ except Exception:
246
+ return None
247
+ return mapping
248
+ return None
249
+
250
+
251
+ def _explain_structure(structure: Structure, max_sentences: int) -> str:
252
+ """Explain a finite first-order countermodel.
253
+
254
+ Sentence priority: domain size and elements; the denotation of every
255
+ constant (one combined sentence); then one sentence per predicate and one
256
+ per function (both sorted by ``(name, arity)``), each stating its
257
+ extension.
258
+ """
259
+ domain = _safe_sorted(structure.domain)
260
+ sentences: List[str] = [
261
+ f"The domain has {_count_word(len(domain), 'individual')}: "
262
+ f"{', '.join(_s(d) for d in domain)}."
263
+ ]
264
+
265
+ if structure.constants:
266
+ parts = [f"{name} denotes {_s(structure.constants[name])}"
267
+ for name in sorted(structure.constants)]
268
+ label = "constant" if len(parts) == 1 else "constants"
269
+ sentences.append(f"The {label} {'; '.join(parts)}.")
270
+
271
+ for key in sorted(structure.predicates):
272
+ name, arity = key
273
+ extension = structure.predicates[key]
274
+ if arity == 0:
275
+ truth = "true" if bool(extension) else "false"
276
+ sentences.append(f"The nullary predicate {name} is {truth}.")
277
+ continue
278
+ tuples = _safe_sorted(extension)
279
+ if not tuples:
280
+ sentences.append(f"The predicate {name}/{arity} holds for no tuples.")
281
+ else:
282
+ sentences.append(
283
+ f"The predicate {name}/{arity} holds for: {_format_tuple_list(tuples)}."
284
+ )
285
+
286
+ for key in sorted(structure.functions):
287
+ name, arity = key
288
+ mapping = _resolve_function_mapping(structure.functions[key], domain, arity)
289
+ if mapping is None:
290
+ sentences.append(
291
+ f"The function {name}/{arity} is defined procedurally; "
292
+ "its extension was not enumerated."
293
+ )
294
+ elif not mapping:
295
+ sentences.append(f"The function {name}/{arity} has no defined values.")
296
+ else:
297
+ items = _safe_sorted(mapping.items())
298
+ pairs = ", ".join(f"{_format_tuple(k)} → {_s(v)}" for k, v in items)
299
+ sentences.append(f"The function {name}/{arity} maps {pairs}.")
300
+
301
+ return " ".join(sentences[:max_sentences])
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Z3-assignment branch (bare dict, or the "assignment" payload of a z3_model
306
+ # witness dict)
307
+ # ---------------------------------------------------------------------------
308
+
309
+ def _explain_z3_assignment(assignment: Dict[str, Any], max_sentences: int) -> str:
310
+ """Explain a Z3-style ``{name: value}`` assignment."""
311
+ if not assignment:
312
+ return "Z3 produced a model, but it recorded no variable assignments."
313
+ items = sorted(assignment.items(), key=lambda kv: str(kv[0]))
314
+ assigned_str = ", ".join(f"{_s(k)} := {_s(v)}" for k, v in items)
315
+ sentences = [
316
+ f"Z3 found a model with {_count_word(len(items), 'assignment')}.",
317
+ f"Under the assignment {assigned_str}, the two sides differ.",
318
+ ]
319
+ return " ".join(sentences[:max_sentences])
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Repr-only witness branch (Verdict-layer dicts carrying no structured payload)
324
+ # ---------------------------------------------------------------------------
325
+
326
+ def _explain_repr_witness(kind: Optional[str], repr_value: Any,
327
+ max_sentences: int) -> str:
328
+ """Frame an opaque ``repr()`` string as an explicitly-unstructured witness.
329
+
330
+ Deliberately does NOT attempt to parse or reformat ``repr_value`` — it is
331
+ presented verbatim inside an explanatory sentence so the reader can see
332
+ exactly, and only, what was actually recovered.
333
+ """
334
+ label = kind if kind else "unlabelled"
335
+ sentences = [
336
+ f'This countermodel was reported as a "{label}" witness carrying only '
337
+ "its Python repr, not a structured payload.",
338
+ f"The repr reads: {_s(repr_value)}",
339
+ ]
340
+ return " ".join(sentences[:max_sentences])
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Public entry point
345
+ # ---------------------------------------------------------------------------
346
+
347
+ def explain_countermodel(model: Any, formula: Optional[Node] = None, *,
348
+ max_sentences: int = 6) -> str:
349
+ """Render a countermodel as 2-6 short, deterministic English sentences.
350
+
351
+ Args:
352
+ model: the countermodel to explain. One of:
353
+
354
+ - a :class:`~unicode_logic_kit.semantics.kripke.KripkeModel`;
355
+ - a :class:`~unicode_logic_kit.semantics.tarski.Structure`;
356
+ - a bare Z3-style assignment ``dict`` (``{name: value}``, no
357
+ ``"kind"`` key — the shape of ``Verdict.countermodel["assignment"]``);
358
+ - a Verdict-layer witness ``dict`` carrying a ``"kind"`` key:
359
+ ``{"kind": "z3_model", "assignment": {...}}`` (routed to the same
360
+ handling as the bare-dict case), or ``{"kind": ..., "repr": "..."}``
361
+ (any other kind — ``"kripke"``, ``"finite_structure"``,
362
+ ``"nitpick"``, or a future one — framed as an unparsed witness).
363
+ formula: only consulted for a ``KripkeModel``, and only to decide
364
+ whether to append the fixed sentence "At world 0 the formula
365
+ fails." — see the module docstring's honesty note. Ignored for
366
+ every other ``model`` shape.
367
+ max_sentences: upper bound on the number of sentences returned (at
368
+ least 1 is enforced). Content beyond the cap is dropped, not
369
+ summarised — see each ``_explain_*`` helper for the priority order
370
+ that decides what survives.
371
+
372
+ Returns:
373
+ A plain-text string: sentences separated by single spaces, no
374
+ Markdown, no embedded newlines. Calling this twice on the same
375
+ arguments always returns the identical string (see the module
376
+ docstring's determinism note).
377
+
378
+ Raises:
379
+ ValueError: ``model`` is a witness ``dict`` whose ``"kind"`` is
380
+ recognised as ``"z3_model"`` but has no usable ``"assignment"``
381
+ sub-dict, and it also carries no ``"repr"`` fallback — i.e. there
382
+ is nothing in it to explain.
383
+ TypeError: ``model`` is not one of the four accepted shapes.
384
+ """
385
+ max_sentences = max(1, max_sentences)
386
+
387
+ if isinstance(model, KripkeModel):
388
+ return _explain_kripke(model, formula, max_sentences)
389
+
390
+ if isinstance(model, Structure):
391
+ return _explain_structure(model, max_sentences)
392
+
393
+ if isinstance(model, dict):
394
+ kind = model.get("kind")
395
+ if kind is None:
396
+ return _explain_z3_assignment(model, max_sentences)
397
+ if kind == "z3_model":
398
+ assignment = model.get("assignment")
399
+ if isinstance(assignment, dict):
400
+ return _explain_z3_assignment(assignment, max_sentences)
401
+ if "repr" in model:
402
+ return _explain_repr_witness(kind, model["repr"], max_sentences)
403
+ raise ValueError(
404
+ f"explain_countermodel: witness dict has kind={kind!r} but neither "
405
+ "a usable 'assignment' dict (for kind='z3_model') nor a 'repr' "
406
+ f"fallback to explain (keys present: {sorted(model)})."
407
+ )
408
+
409
+ raise TypeError(
410
+ f"explain_countermodel: unsupported model type {type(model).__name__} — "
411
+ "expected a KripkeModel, a Structure, a Z3 assignment dict, or a "
412
+ "Verdict-layer witness dict with a 'kind' key."
413
+ )
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # explain_proof — the validity-side counterpart of explain_countermodel
418
+ # ---------------------------------------------------------------------------
419
+ #
420
+ # Five proof shapes actually reach ``Verdict.proof`` today (verified by
421
+ # grepping every ``proof=`` assignment in ``unicode_logic_kit/atp/``):
422
+ #
423
+ # * :class:`~unicode_logic_kit.atp.tableau.TableauProof` — ``TableauBackend``.
424
+ # * :class:`~unicode_logic_kit.atp.tstp.TstpDerivation` — ``VampireBackend`` and
425
+ # ``EProverBackend`` (same shape, one renderer covers both).
426
+ # * :class:`~unicode_logic_kit.atp.twee_entailment.TweeProof` — ``TweeBackend``.
427
+ # * ``{"kind": "z3_unsat_core", "core": [...]}`` — ``Z3Backend``.
428
+ # * ``{"kind": "cvc5_alethe", "text": ..., "unsat_core": [...]}`` —
429
+ # :class:`~unicode_logic_kit.atp.cvc5_backend.Cvc5Backend`.
430
+ #
431
+ # The first three also round-trip through ``.to_dict()`` (the shape
432
+ # ``Verdict.proof`` actually carries once a verdict has crossed the
433
+ # process-pool boundary — see ``atp.portfolio._verdict_from_dict``); the last
434
+ # two are ALWAYS plain dicts, since no richer dataclass wraps them. Every
435
+ # renderer below therefore accepts both the typed instance and its dict via
436
+ # the ``_field``/``_node_str`` helpers, and the five key sets are disjoint (a
437
+ # ``"kind"`` key picks the two Z3/cvc5 shapes; among the rest, only
438
+ # ``TableauProof`` carries ``"root_formulas"``/``"closures"``, only
439
+ # ``TweeProof`` carries ``"axioms"``/``"lemmas"``/``"goal"``, and a bare
440
+ # ``{"steps"}`` is ``TstpDerivation``) — see :func:`explain_proof`'s dispatch.
441
+ #
442
+ # Fitch ``Proof``/``Line``/``Justification`` chains (``atp.fitch_search``) are
443
+ # deliberately NOT covered: no ``ProverBackend`` currently attaches one to
444
+ # ``Verdict.proof``, so there is no live caller through the eval/atp protocol
445
+ # layer to explain today — a natural follow-up once/if one is registered.
446
+
447
+ def _field(obj: Any, key: str) -> Any:
448
+ """Read ``key`` from ``obj``, whether it is a proof dataclass instance or
449
+ the matching ``to_dict()``-shaped plain dict — both share field names, so
450
+ this is the one place that bridges "typed object" and "Verdict.proof
451
+ dict" for every renderer below."""
452
+ return obj[key] if isinstance(obj, dict) else getattr(obj, key)
453
+
454
+
455
+ def _node_str(value: Any) -> str:
456
+ """Render a formula field as Unicode text — ``value`` is a :class:`Node`
457
+ when the caller passed typed proof objects, or a ``Node.to_dict()`` dict
458
+ when it passed the plain ``Verdict.proof`` dict."""
459
+ if isinstance(value, Node):
460
+ return value.to_unicode_str()
461
+ if isinstance(value, dict):
462
+ return Node.from_dict(value).to_unicode_str()
463
+ raise TypeError(
464
+ f"explain_proof: expected a Node or a Node.to_dict() dict, got "
465
+ f"{type(value).__name__}"
466
+ )
467
+
468
+
469
+ # ---------------------------------------------------------------------------
470
+ # TableauProof branch
471
+ # ---------------------------------------------------------------------------
472
+
473
+ def _explain_tableau(proof: Any, max_sentences: int) -> str:
474
+ """Explain a tableau refutation.
475
+
476
+ Sentence priority (highest first):
477
+
478
+ 1. Step count.
479
+ 2. A sorted (by rule name) histogram of rule applications — omitted if
480
+ there are no steps (a tableau that closes at the root needs none).
481
+ 3. Closed-branch count (``len(closures)``).
482
+ 4. One sentence per closure, sorted by ``leaf_id``, naming the two
483
+ closing literals — or, for a self-closing branch (``literal`` is ⊥,
484
+ ``complement`` is ``None`` per :class:`TableauClosure`'s own
485
+ docstring), stating that directly rather than inventing a complement.
486
+ """
487
+ steps = list(_field(proof, "steps"))
488
+ closures = list(_field(proof, "closures"))
489
+
490
+ sentences: List[str] = [
491
+ f"The tableau proof has {_count_word(len(steps), 'step')}."
492
+ ]
493
+
494
+ histogram: Dict[str, int] = {}
495
+ for step in steps:
496
+ rule = _field(step, "rule")
497
+ histogram[rule] = histogram.get(rule, 0) + 1
498
+ if histogram:
499
+ parts = [f"{rule} ({count})" for rule, count in sorted(histogram.items())]
500
+ sentences.append(f"Rule usage: {', '.join(parts)}.")
501
+
502
+ branch_word = "closed branch" if len(closures) == 1 else "closed branches"
503
+ sentences.append(f"The proof has {len(closures)} {branch_word}.")
504
+
505
+ for closure in sorted(closures, key=lambda c: _field(c, "leaf_id")):
506
+ leaf_id = _field(closure, "leaf_id")
507
+ literal = _node_str(_field(closure, "literal"))
508
+ complement = _field(closure, "complement")
509
+ if complement is None:
510
+ sentences.append(f"Branch closing at node {leaf_id} closes directly on {literal}.")
511
+ else:
512
+ sentences.append(
513
+ f"Branch closing at node {leaf_id} closes {literal} against "
514
+ f"{_node_str(complement)}."
515
+ )
516
+
517
+ return " ".join(sentences[:max_sentences])
518
+
519
+
520
+ # ---------------------------------------------------------------------------
521
+ # TstpDerivation branch (Vampire and E — same shape, same renderer)
522
+ # ---------------------------------------------------------------------------
523
+
524
+ def _explain_tstp(proof: Any, max_sentences: int) -> str:
525
+ """Explain a TSTP derivation DAG.
526
+
527
+ Sentence priority (highest first):
528
+
529
+ 1. Step count.
530
+ 2. The sorted set of roles present (``axiom``, ``negated_conjecture``, …).
531
+ 3. The final step's rule (or, if it names none, that it is an
532
+ unjustified leaf) together with its ancestor trace: a backward
533
+ breadth-first walk of the parent DAG rooted at the final step, listed
534
+ layer by layer — so a multi-level derivation names every ancestor
535
+ reached from the last step, not just one arbitrarily-chosen path.
536
+ A cited parent name absent from this derivation's own steps (e.g. an
537
+ external ``file(...)`` source never itself recorded) is left
538
+ unexpanded rather than guessed at.
539
+ """
540
+ steps = list(_field(proof, "steps"))
541
+ if not steps:
542
+ raise ValueError("explain_proof: TstpDerivation has no steps to explain.")
543
+
544
+ sentences: List[str] = [f"The derivation has {_count_word(len(steps), 'step')}."]
545
+
546
+ roles = sorted({_field(s, "role") for s in steps})
547
+ sentences.append(f"Roles present: {', '.join(roles)}.")
548
+
549
+ by_name = {_field(s, "name"): s for s in steps}
550
+ final = steps[-1]
551
+ final_name = _field(final, "name")
552
+ final_rule = _field(final, "rule")
553
+
554
+ if final_rule is None:
555
+ sentences.append(
556
+ f"The final step {final_name} is a leaf with no inference rule recorded."
557
+ )
558
+ else:
559
+ seen = {final_name}
560
+ order: List[str] = []
561
+ frontier = list(_field(final, "parents"))
562
+ while frontier:
563
+ next_frontier: List[str] = []
564
+ for name in frontier:
565
+ if name in seen:
566
+ continue
567
+ seen.add(name)
568
+ order.append(name)
569
+ parent_step = by_name.get(name)
570
+ if parent_step is not None:
571
+ next_frontier.extend(_field(parent_step, "parents"))
572
+ frontier = next_frontier
573
+ if order:
574
+ sentences.append(
575
+ f"The final step {final_name} applies {final_rule}, tracing "
576
+ f"back through {', '.join(order)}."
577
+ )
578
+ else:
579
+ sentences.append(
580
+ f"The final step {final_name} applies {final_rule} with no "
581
+ "recorded parents."
582
+ )
583
+
584
+ return " ".join(sentences[:max_sentences])
585
+
586
+
587
+ # ---------------------------------------------------------------------------
588
+ # TweeProof branch
589
+ # ---------------------------------------------------------------------------
590
+
591
+ def _format_twee_citation(citation: Any) -> str:
592
+ """Render one ``{ by axiom N (name) [R->L] }`` / ``{ by lemma N [R->L] }``
593
+ citation as ``"axiom N (name)"`` / ``"lemma N"``, with an " R->L" suffix
594
+ when the citation applies its equation right-to-left."""
595
+ kind = _field(citation, "kind")
596
+ number = _field(citation, "number")
597
+ name = _field(citation, "name")
598
+ text = f"{kind} {number}" + (f" ({name})" if name else "")
599
+ if _field(citation, "reversed"):
600
+ text += " R->L"
601
+ return text
602
+
603
+
604
+ def _explain_twee(proof: Any, max_sentences: int) -> str:
605
+ """Explain a Twee equational proof.
606
+
607
+ Sentence priority (highest first):
608
+
609
+ 1. Axiom and lemma counts.
610
+ 2. The goal's own equation, by name.
611
+ 3. The goal's rewrite chain, term by term.
612
+ 4. The chain's citations, in step order — or an explicit "no citations"
613
+ sentence for the (degenerate, single-term) chain that has none.
614
+ """
615
+ axioms = list(_field(proof, "axioms"))
616
+ lemmas = list(_field(proof, "lemmas"))
617
+ goal = _field(proof, "goal")
618
+
619
+ sentences: List[str] = [
620
+ f"The proof uses {_count_word(len(axioms), 'axiom')} and "
621
+ f"{_count_word(len(lemmas), 'lemma')}."
622
+ ]
623
+
624
+ goal_name = _field(goal, "name")
625
+ equation = _field(goal, "equation")
626
+ sentences.append(
627
+ f"The goal ({goal_name}) states "
628
+ f"{_node_str(_field(equation, 'lhs'))} = {_node_str(_field(equation, 'rhs'))}."
629
+ )
630
+
631
+ chain = _field(goal, "chain")
632
+ terms = [_node_str(t) for t in _field(chain, "terms")]
633
+ sentences.append(f"It rewrites {' → '.join(terms)}.")
634
+
635
+ citations = list(_field(chain, "citations"))
636
+ if citations:
637
+ cite_strs = [_format_twee_citation(c) for c in citations]
638
+ sentences.append(f"Citations: {', '.join(cite_strs)}.")
639
+ else:
640
+ sentences.append("No citations are recorded for this chain.")
641
+
642
+ return " ".join(sentences[:max_sentences])
643
+
644
+
645
+ # ---------------------------------------------------------------------------
646
+ # Z3Backend's unsat-core proof dict
647
+ # ---------------------------------------------------------------------------
648
+
649
+ def _explain_z3_unsat_core(proof: Dict[str, Any], max_sentences: int) -> str:
650
+ """Explain ``{"kind": "z3_unsat_core", "core": [...]}``.
651
+
652
+ ``core`` is a sound but not necessarily minimal unsat core (see
653
+ ``Z3Backend.decide``'s own comment) — reported as exactly that, every
654
+ tracked name listed in the order Z3/the backend already sorted them.
655
+ """
656
+ core = list(proof.get("core") or [])
657
+ if not core:
658
+ return "Z3 refutes the goal via an empty unsat core."
659
+ sentences = [
660
+ f"Z3 refutes the goal via an unsat core of "
661
+ f"{_count_word(len(core), 'tracked term')}: {', '.join(_s(c) for c in core)}."
662
+ ]
663
+ return " ".join(sentences[:max_sentences])
664
+
665
+
666
+ # ---------------------------------------------------------------------------
667
+ # CVC5Backend's Alethe proof dict
668
+ # ---------------------------------------------------------------------------
669
+
670
+ def _explain_cvc5_alethe(proof: Dict[str, Any], max_sentences: int) -> str:
671
+ """Explain ``{"kind": "cvc5_alethe", "text": ..., "unsat_core": [...]}``.
672
+
673
+ ``text`` (the Alethe proof, best-effort — see ``Cvc5Backend.decide``'s own
674
+ comment) is summarised by its non-blank line count rather than quoted in
675
+ full; ``unsat_core`` is the same "sound, not necessarily minimal" shape as
676
+ Z3's. Either can be empty/``None`` without this being an error — both are
677
+ reported honestly rather than guessed at.
678
+ """
679
+ text = proof.get("text")
680
+ core = list(proof.get("unsat_core") or [])
681
+
682
+ sentences: List[str] = []
683
+ if text:
684
+ line_count = len([ln for ln in text.splitlines() if ln.strip()])
685
+ sentences.append(
686
+ f"cvc5 refutes the goal with an Alethe proof of "
687
+ f"{_count_word(line_count, 'line')}."
688
+ )
689
+ else:
690
+ sentences.append("cvc5 refutes the goal; no Alethe proof text was recorded.")
691
+
692
+ if core:
693
+ sentences.append(
694
+ f"Its unsat core cites {_count_word(len(core), 'term')}: "
695
+ f"{', '.join(_s(c) for c in core)}."
696
+ )
697
+ else:
698
+ sentences.append("Its unsat core is empty.")
699
+
700
+ return " ".join(sentences[:max_sentences])
701
+
702
+
703
+ # ---------------------------------------------------------------------------
704
+ # Public entry point
705
+ # ---------------------------------------------------------------------------
706
+
707
+ def explain_proof(proof: Any, *, max_sentences: int = 6) -> str:
708
+ """Render a proof as short, deterministic English sentences.
709
+
710
+ The validity-side counterpart of :func:`explain_countermodel`: where that
711
+ function explains why a formula is *not* valid (a witnessing structure),
712
+ this one explains why it *is* (a proof search's own record of how it
713
+ closed) — see the module docstring for the honesty/determinism
714
+ discipline both share.
715
+
716
+ Args:
717
+ proof: the proof to explain. One of:
718
+
719
+ - a :class:`~unicode_logic_kit.atp.tableau.TableauProof` (or its
720
+ ``.to_dict()``) — from ``TableauBackend``;
721
+ - a :class:`~unicode_logic_kit.atp.tstp.TstpDerivation` (or its
722
+ ``.to_dict()``) — from ``VampireBackend``/``EProverBackend``;
723
+ - a :class:`~unicode_logic_kit.atp.twee_entailment.TweeProof` (or
724
+ its ``.to_dict()``) — from ``TweeBackend``;
725
+ - ``{"kind": "z3_unsat_core", "core": [...]}`` — from
726
+ ``Z3Backend``;
727
+ - ``{"kind": "cvc5_alethe", "text": ..., "unsat_core": [...]}`` —
728
+ from ``Cvc5Backend``.
729
+
730
+ See the module-level comment above this section for how the five
731
+ shapes are told apart unambiguously.
732
+ max_sentences: upper bound on the number of sentences returned (at
733
+ least 1 is enforced). Content beyond the cap is dropped, not
734
+ summarised — see each ``_explain_*`` helper for the priority
735
+ order that decides what survives.
736
+
737
+ Returns:
738
+ A plain-text string: sentences separated by single spaces, no
739
+ Markdown, no embedded newlines (every value rendered into a sentence
740
+ is either a :meth:`Node.to_unicode_str` result or a Python string
741
+ already free of embedded newlines by construction). Calling this
742
+ twice on the same argument always returns the identical string.
743
+
744
+ Raises:
745
+ ValueError: ``proof`` is a :class:`TstpDerivation` (or its dict) with
746
+ no steps, or a dict carrying a ``"kind"`` this function does not
747
+ recognise, or a dict whose keys match none of the five accepted
748
+ shapes.
749
+ TypeError: ``proof`` is not one of the five accepted shapes at all.
750
+ """
751
+ max_sentences = max(1, max_sentences)
752
+
753
+ if isinstance(proof, TableauProof):
754
+ return _explain_tableau(proof, max_sentences)
755
+ if isinstance(proof, TstpDerivation):
756
+ return _explain_tstp(proof, max_sentences)
757
+ if isinstance(proof, TweeProof):
758
+ return _explain_twee(proof, max_sentences)
759
+
760
+ if isinstance(proof, dict):
761
+ keys = set(proof)
762
+ if "kind" in proof:
763
+ kind = proof["kind"]
764
+ if kind == "z3_unsat_core":
765
+ return _explain_z3_unsat_core(proof, max_sentences)
766
+ if kind == "cvc5_alethe":
767
+ return _explain_cvc5_alethe(proof, max_sentences)
768
+ raise ValueError(
769
+ f"explain_proof: unrecognised proof dict kind={kind!r} — "
770
+ "expected 'z3_unsat_core' or 'cvc5_alethe' "
771
+ f"(keys present: {sorted(keys)})."
772
+ )
773
+ if {"root_formulas", "steps", "closures"} <= keys:
774
+ return _explain_tableau(proof, max_sentences)
775
+ if keys == {"steps"}:
776
+ return _explain_tstp(proof, max_sentences)
777
+ if {"axioms", "lemmas", "goal"} <= keys:
778
+ return _explain_twee(proof, max_sentences)
779
+ raise ValueError(
780
+ "explain_proof: unrecognised proof dict shape — expected a "
781
+ "TableauProof ({'root_formulas','steps','closures'}), a "
782
+ "TstpDerivation ({'steps'}), a TweeProof "
783
+ "({'axioms','lemmas','goal'}), or a 'kind'-tagged Verdict-layer "
784
+ f"dict (keys present: {sorted(keys)})."
785
+ )
786
+
787
+ raise TypeError(
788
+ f"explain_proof: unsupported proof type {type(proof).__name__} — "
789
+ "expected a TableauProof, TstpDerivation, TweeProof, or one of the "
790
+ "'kind'-tagged Verdict-layer proof dicts (z3_unsat_core, cvc5_alethe)."
791
+ )