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,1135 @@
1
+ """Direct structural evaluation of a formula against a :class:`FiniteStructure`.
2
+
3
+ This is model CHECKING (is this sentence true HERE, on this one finite
4
+ structure?), computed by walking the formula's AST directly — no prenex /
5
+ clausal preprocessing of any kind. The kit already has a Tarskian evaluator
6
+ (:func:`unicode_logic_kit.semantics.tarski.satisfies`); it is correct and
7
+ general but iterates every quantifier over the WHOLE domain and gives no
8
+ special treatment to negated quantifiers or counting. This module targets
9
+ exactly the practical bottleneck of that style of checker when the structure
10
+ is a molecule (one individual per non-hydrogen atom) and the formula is an
11
+ LLM-produced ChEBI class definition. Three design decisions carry the
12
+ module's value:
13
+
14
+ 1. **No PNF/CNF normal form, ever.** The existing ChemLog TPTP checker this
15
+ module is meant to out-perform requires prenex form with a CNF matrix, and
16
+ that requirement alone produces two normal-form-*induced* timeout sources
17
+ that direct evaluation simply does not have:
18
+
19
+ * A negated existential ``¬(∃o: o(o) ∧ bDOUBLE(c,o))`` is mechanically
20
+ Skolem/prenex-turned into ``∀x (¬o(x) ∨ ¬bDOUBLE(c,x))``, which then has
21
+ to range over *every* atom in the molecule. Evaluated directly, ``¬∃x φ``
22
+ is "search for one witness of φ, stop at the first hit, otherwise true" —
23
+ the same search :func:`evaluate` uses for a bare ``∃``, just negated at
24
+ the end (see :func:`_eval`, the ``Not`` / ``Quantifier`` cases). It never
25
+ becomes a ``∀`` over the whole domain.
26
+ * A top-level disjunction of two existentials gets DISTRIBUTED across a
27
+ CNF matrix — a realistic ``oxoFattyAcid`` definition turns into 32
28
+ disjunctive clauses that must be resolved "simultaneously". Evaluated
29
+ directly, ``Or`` just short-circuits (Python's ``or`` on the two
30
+ recursive calls in :func:`_eval`): the first disjunct that is true wins,
31
+ full stop.
32
+
33
+ 2. **Index-driven candidate generation with dynamically reordered variables.**
34
+ ``∃x (o(x) ∧ bDOUBLE(c,x) ∧ …)`` must not iterate the whole domain for
35
+ ``x``. :func:`_variable_candidates` derives a SOUND (superset) candidate
36
+ set for a quantified variable from the immediate body by intersecting (i)
37
+ :meth:`FiniteStructure.individuals_with` for every unary atom the variable
38
+ occurs in POSITIVELY (i.e. un-negated, and not hidden behind ``∨`` /
39
+ ``→`` / another quantifier — see the harvesting rule below) and (ii)
40
+ :meth:`FiniteStructure.neighbors` (or its local reverse index, see
41
+ :func:`_reverse_neighbors`) for every binary atom relating it to an
42
+ ALREADY-BOUND variable or constant. When several existential variables are
43
+ introduced back to back (``∃x∃y: …``, the common ChemLog shape), the search
44
+ in :func:`_search_exists` picks, at every step, the still-unbound variable
45
+ with the SMALLEST current candidate set (a minimum-remaining-values /
46
+ most-constrained-variable heuristic borrowed from constraint search) —
47
+ concretely: start at the rare heteroatom, then walk outward along its
48
+ bonds, rather than iterating the common atom type first and hoping a bond
49
+ happens to match. This is a HEURISTIC, not a completeness or optimality
50
+ guarantee: it can never make the answer wrong (the harvested constraints
51
+ are all genuinely necessary — see below — so the candidate set is always a
52
+ superset of the true satisfiers, and every candidate is still fully
53
+ re-checked by evaluating the whole body), but a formula whose real
54
+ constraints live behind an ``∨`` or inside a nested ``Count`` gets none of
55
+ this narrowing and falls back to scanning the whole domain for that
56
+ variable — still correct, just not fast.
57
+
58
+ *Harvesting rule (soundness argument):* :func:`_harvest_atoms` collects
59
+ atoms reachable from the body through a chain of nested ``And`` nodes only
60
+ (both branches), and stops (does not descend) at ``Or``, ``Not``,
61
+ ``Implies``, ``Iff``, ``Xor``, nested ``Quantifier`` or ``Count``. Anything
62
+ found this way is a conjunct that is unconditionally required for the
63
+ whole formula to hold, so filtering candidates by it can never exclude a
64
+ genuine satisfier — which is exactly what makes it safe to use for a
65
+ NEGATIVE existential search too (``¬∃x φ`` is false only if some candidate
66
+ in this superset satisfies φ; if the whole harvested superset is checked
67
+ and none does, there truly is no witness, precisely because the superset
68
+ was sound).
69
+
70
+ 3. **``Count`` is evaluated natively, not expanded.** The kit's ``Count``
71
+ node (``∃≥n``/``∃≤n``/``∃=n``) already keeps its bound symbolically instead
72
+ of unrolling to n nested existentials — but every OTHER consumer
73
+ (``to_z3``/``to_prover9``/``to_tptp``) still has to lower it to the
74
+ standard distinct-witnesses encoding before use, because those back-ends
75
+ only understand plain FOL. This evaluator does not: :func:`_eval_count`
76
+ counts satisfying individuals directly off the candidate set from (2),
77
+ short-circuiting as soon as the bound is reached for ``op="ge"`` (and as
78
+ soon as it is provably exceeded for ``"le"``/``"eq"``). The motivating
79
+ case is ``hasAtLeast40Carbons``, written today as 40 nested existentials
80
+ plus all C(40,2)=780 pairwise inequalities — a formula shape that is a
81
+ primary timeout source in practice. As ``∃≥40 x c(x)`` against a
82
+ structure of ~50 carbons, this evaluator does a single pass over the
83
+ ~50-element carbon candidate list and stops at the 40th hit (see
84
+ ``tests/test_model_eval.py``'s performance test). Evaluating the SAME
85
+ formula already given in its expanded nested-∃-with-pairwise-≠ form gets
86
+ none of this — the ``≠`` atoms carry no membership information for
87
+ :func:`_harvest_atoms` (deliberately: a disequality is not "individual x
88
+ has property p", so it is not folded into ``individuals_with``), so the
89
+ search degrades back to unindexed backtracking. This is not a bug this
90
+ module could fix by being cleverer about ``≠``: recognising an expanded
91
+ counting encoding and un-expanding it back to ``Count`` is a distinct,
92
+ out-of-scope problem. The fix is architectural — a front-end should emit
93
+ ``Count`` directly, which is exactly what makes this evaluator's
94
+ native-``Count`` path pay off.
95
+
96
+ Supported nodes: ``Atom`` (incl. ``=``/``≠``, read as term identity — domain
97
+ individuals are opaque names, so identity is Python ``==``), ``Not``, ``And``,
98
+ ``Contrast`` (truth-functionally ``And``: concession is a DISCOURSE relation,
99
+ and the node's own contract says every export treats it as ``∧``, so reading
100
+ it any other way here would invent a semantics that contract denies), ``Or``,
101
+ ``Xor``, ``Implies``, ``Iff``, ``Quantifier`` (``forall``/``exists``, ASCII or
102
+ ``∀``/``∃``), ``Count`` (``ge``/``le``/``eq``) and ``Cardinality``
103
+ (``|{v : φ}|``, in a comparison — see below). Argument positions accept
104
+ ``Variable`` (via ``assignment``), ``Constant`` (via
105
+ :attr:`FiniteStructure.constants`) and, as of the case described below,
106
+ ``Function``. Anything else (modal/epistemic/temporal operators,
107
+ Łukasiewicz/fuzzy connectives, lambda terms, second-order quantifiers,
108
+ sorted/many-sorted nodes, ``Measure``, ...) is refused LOUDLY with
109
+ :class:`UnsupportedNode` rather than silently approximated — this kit never
110
+ guesses at semantics it was not told to implement. ``SortedCount`` was
111
+ weighed deliberately rather than just skipped: it needs a sort universe to
112
+ range over, and :class:`FiniteStructure` has no ``sorts`` concept at all
113
+ (unlike ``tarski.Structure``) — inventing one here would mean extending the
114
+ shared structure contract, out of bounds for this module.
115
+
116
+ **``Function`` terms.** A :class:`FiniteStructure` interprets a function
117
+ symbol ``f``/``k`` the same way
118
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` already
119
+ builds one: as a ``(name, k+1)`` "total relation" extension (the ``k``
120
+ arguments, then the result). :func:`_function_value` reads ``f(t1,...,tk)``
121
+ off that relation — the unique row whose leading ``k`` components equal the
122
+ (recursively evaluated, so nested composition ``f(g(x))`` needs no special
123
+ case) arguments — and refuses LOUDLY, by :class:`ValueError`, if no such row
124
+ exists (the function is PARTIAL on these arguments in a hand-built structure)
125
+ or more than one does (the relation is not FUNCTIONAL): a hand-built
126
+ :class:`FiniteStructure` that puts a partial/non-functional relation under a
127
+ function's key is refused, never silently misread. A ``computed`` function
128
+ relation is supported too (searched over the whole domain for the unique
129
+ witness, mirroring :func:`_reverse_neighbors`'s identical treatment of a
130
+ computed binary predicate — see :func:`_function_value`'s own docstring for
131
+ the full account, including why this reuses :func:`_holds`'s
132
+ :class:`UninterpretedSymbol` pattern for a function this structure does not
133
+ interpret at all). Candidate generation (design decision #2 above) is
134
+ UNAFFECTED: :func:`_variable_candidates`/:func:`_resolved_individual` only
135
+ ever recognise a BARE ``Variable``/``Constant`` occurrence, so a variable
136
+ that occurs only inside a ``Function`` argument (``p(f(x))``) is never
137
+ narrowed by the heuristic — it simply falls back to the documented
138
+ full-domain scan for that variable (see :func:`_resolved_individual`'s own
139
+ docstring for why this stays sound rather than an argument for extending the
140
+ heuristic to peer inside a ``Function``). And the two arithmetic-adjacent
141
+ concerns this addition could in principle have reopened both stay closed by
142
+ construction, not by a new check here: (1) the four arithmetic operator names
143
+ ``+``/``-``/``*``/``/`` are excluded from
144
+ :meth:`~unicode_logic_kit.fol.signature.Signature.from_formulas`'s
145
+ ``functions`` section (the ``_BUILTIN_FUNCS`` carve-out), so a structure
146
+ built via :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
147
+ never has an extension for them and :func:`_function_value` reports them
148
+ UNINTERPRETED like any other undeclared symbol — no name-based refusal is
149
+ needed in this module; (2) a ``Function`` term is evaluated ONLY by
150
+ :func:`_term_value`/:func:`_function_value`, which answer with individuals,
151
+ and NEVER by :func:`_numeric_value`, which answers with integers and only
152
+ ever receives the operand syntactically marked ``Cardinality``/``Number`` —
153
+ the two paths share no lookup, so this cannot reintroduce the kind of
154
+ numeral-read-as-a-structure-constant confusion
155
+ :mod:`~unicode_logic_kit.semantics.tarski` had to fix for a bare ``Number``
156
+ next to a ``Cardinality`` (see the "Two kinds of term value" bullet below).
157
+
158
+ **Two kinds of term value, kept apart.** ``Cardinality`` denotes a NATURAL
159
+ NUMBER, not a domain individual, and every term in an ARGUMENT position must
160
+ denote an individual (that is what makes it usable as a predicate argument).
161
+ Admitting numbers everywhere really would be a redesign. It is not needed:
162
+ over a finite structure a numeric term can only occur as an operand of a
163
+ COMPARISON, so the two notions never have to mix. :func:`_term_value` still
164
+ answers with individuals (now including ``Function``'s result — see above)
165
+ and still refuses ``Cardinality``; :func:`_numeric_value` answers with
166
+ integers and is reached only from the comparison branch of
167
+ :func:`_atom_value`, which switches on the syntactic shape of the operands —
168
+ a ``Function`` term compared against a ``Cardinality`` (``f(a) > |{x :
169
+ P(x)}|``) reaches :func:`_numeric_value` on the ``Function`` operand and is
170
+ refused there ("does not denote a number"), the identical reading already
171
+ given to comparing a domain individual with a bare numeral. ``|{v : φ}|`` is
172
+ then simply counted over the domain — the same "counting is decidable on a
173
+ finite structure" that makes ``Count`` native here, one level down at the
174
+ term. This is what lets the finite-domain backends
175
+ (:mod:`unicode_logic_kit.atp.clingo_backend`) have their answers CHECKED: a
176
+ solver that decides a cardinality comparison is of no use if the kit cannot
177
+ verify the model it returns.
178
+
179
+ **``all_different`` — a SEMANTICS switch, not a performance knob.** Under
180
+ plain FOL semantics (``all_different=False``, the default), two separately
181
+ quantified existential variables MAY denote the same individual unless the
182
+ formula itself says otherwise. ChemLog's TPTP output instead follows the
183
+ convention that separately introduced existential variables always denote
184
+ PAIRWISE DISTINCT individuals (e.g. ``∃x∃y (c(x) ∧ c(y) ∧ …)`` demands two
185
+ DIFFERENT carbons, with no explicit ``x≠y`` anywhere in the text). Passing
186
+ ``all_different=True`` reproduces that convention, but only among
187
+ existentials in an ANCESTOR/DESCENDANT relationship in the formula's syntax
188
+ tree — i.e. ``∃y`` sits somewhere inside the MATRIX that ``∃x`` quantifies
189
+ over, reachable by descending through any mix of ``Not``/``And``/``Or``/
190
+ ``Xor``/``Implies``/``Iff``/``∀``/``Count``'s own formula/further ``∃``
191
+ layers. Concretely: every individual already bound to such an ENCLOSING
192
+ active ``Quantifier(type="exists", …)`` — including an outer layer of the
193
+ SAME peeled ``∃x∃x∃x…`` chain reusing one variable name, each layer counted
194
+ separately even though later layers shadow earlier ones in the final
195
+ assignment — is excluded from the candidate set considered for the next one,
196
+ for as long as that enclosing search is still on the call stack (tracked as
197
+ ``bound_existentials``, extended only inside :func:`_search_exists` /
198
+ :func:`_search_exists_witness` and threaded unchanged through every other
199
+ node's recursive calls).
200
+
201
+ **This does NOT reach SIBLING existentials** — two ``∃`` nodes where neither
202
+ is nested inside the other's matrix, e.g. ``(∃x φ(x)) ∧ (∃y ψ(y))`` at the
203
+ same ``And``, or two ``∃`` under different disjuncts of an ``Or``, or one in
204
+ each of two ``Count`` bodies. Each such ``∃`` is evaluated with the
205
+ ``bound_existentials`` frozenset :func:`_eval` received on entry to that
206
+ connective — a sibling's search accumulates its own bindings only for the
207
+ duration of its own (fully self-contained) call to :func:`_search_exists`,
208
+ and that accumulation is discarded, never propagated sideways, once that
209
+ call returns the connective's boolean result. So ``x`` and ``y`` above MAY
210
+ still denote the same individual even under ``all_different=True`` — this is
211
+ a deliberate, narrow reading of "separately introduced": the module only
212
+ tracks quantifiers that are still SYNTACTICALLY ACTIVE (an ancestor whose
213
+ witness search has not yet returned) at the point ``∃y`` is evaluated, not
214
+ every existential anywhere else in the formula. Widening it to cover
215
+ siblings would need a whole-formula pre-pass collecting every ``∃`` in the
216
+ tree before evaluation starts (or a different, non-local candidate-pruning
217
+ strategy) — a real semantics change, not attempted here; nest the
218
+ quantifiers (``∃x (φ(x) ∧ ∃y ψ(y))``) if sibling-level distinctness is what a
219
+ formula needs.
220
+
221
+ This applies ONLY to plain existential ``Quantifier`` nodes. It does NOT
222
+ reach into ``Count``: ``Count``'s own ``n`` witnesses are already required to
223
+ be pairwise distinct by definition (that is what ``∃≥n`` means) regardless of
224
+ this flag, and this flag does not additionally force a ``Count``'s witnesses
225
+ to avoid individuals used by an outer, separately scoped ``∃`` — model that
226
+ case as nested ``∃`` if you need it. ``∀``-bound variables are never
227
+ affected.
228
+
229
+ **Budget / the three-valued contract.** ``evaluate_detailed(..., budget=k)``
230
+ counts one "step" per node visited (see ``_Ctx.tick``, called once per
231
+ :func:`_eval` invocation and once per node of the existential search tree in
232
+ :func:`_search_exists`); if evaluation would need more than ``k`` steps, it
233
+ stops and reports ``exhausted=True`` with ``holds=None`` — an honest UNKNOWN,
234
+ never a guessed ``False``. :func:`evaluate` (the boolean-only entry point)
235
+ cannot represent "unknown" in its return type, so on exhaustion it raises
236
+ :class:`BudgetExhausted` instead of silently returning a value: a caller that
237
+ only wants a bool must explicitly decide what an unknown answer means to it,
238
+ rather than have this module decide for them. ``witness``/``failing_conjunct``
239
+ extraction (see below) is a secondary pass that reuses the caller's budget but
240
+ degrades to ``None`` on running out, WITHOUT invalidating the already-decided
241
+ ``holds`` — see :func:`evaluate_detailed`.
242
+
243
+ **Uninterpreted symbols.** :meth:`FiniteStructure.holds` /
244
+ :meth:`~FiniteStructure.individuals_with` / :meth:`~FiniteStructure.neighbors`
245
+ raise ``KeyError`` for a predicate the structure does not interpret. This
246
+ module catches that and re-raises :class:`UninterpretedSymbol`, a small typed
247
+ exception carrying ``.symbol``/``.arity`` plus a message naming what is
248
+ missing — a vocabulary mismatch between a formula and a structure is a bug in
249
+ the caller's pipeline, not a reason to call the definition merely unsatisfied.
250
+
251
+ **``witness`` / ``failing_conjunct`` — best-effort explanations, not a proof
252
+ object.** They recognise exactly the ``∧``/``∃``/``∨`` shape that a class
253
+ definition like ChEBI's typically has:
254
+
255
+ * ``witness`` (populated only when ``holds`` is ``True``) is built by
256
+ :func:`_find_witness`: an ``And`` merges the witnesses of both conjuncts: an
257
+ ``∃`` (chain) re-derives, via the SAME candidate search as the main
258
+ evaluation, ONE variable→individual binding that makes it true, and merges
259
+ in the witness of its matrix under that binding; an ``Or`` reports the
260
+ witness of whichever disjunct is true (preferring the left, matching
261
+ evaluation order). Everything else (``Not``, ``∀``, ``Count``, ``Implies``,
262
+ ``Iff``, ``Xor``, a bare ``Atom``) contributes no bindings — not every true
263
+ formula has a natural single-assignment witness, and this does not attempt
264
+ to invent one (a ``Count``'s witness is a SET of individuals, which does
265
+ not fit a ``Dict[str, str]``; a disjunction's untaken branch is simply not
266
+ reported).
267
+ * ``failing_conjunct`` (populated only when ``holds`` is ``False``) is built
268
+ by :func:`_find_blame`: it drills through a chain of nested ``And`` nodes
269
+ (left-to-right, the same short-circuit order the main evaluation used) to
270
+ the first conjunct that is actually false, and stops there — an ``Or``, a
271
+ ``Not``, or any other non-``And`` node is reported AS ITSELF (not
272
+ decomposed further: which of two false disjuncts is "the" reason is
273
+ genuinely ambiguous, so this does not guess).
274
+ """
275
+
276
+ from dataclasses import dataclass
277
+ from typing import Dict, FrozenSet, List, Mapping, Optional, Tuple
278
+
279
+ from .structures import FiniteStructure, Individual, Key
280
+ from ..fol._tptp_symbols import is_tptp_boolean_atom as _is_tptp_boolean_atom
281
+ from ..fol._tptp_symbols import truth_constant_word as _truth_constant_word
282
+ from ..fol.nodes import (
283
+ Node, Variable, Constant, Number, Cardinality, Function,
284
+ Atom, Not, And, Contrast, Or, Xor, Implies, Iff, Quantifier, Count,
285
+ )
286
+
287
+ __all__ = [
288
+ "EvalResult", "BudgetExhausted", "UninterpretedSymbol", "UnsupportedNode",
289
+ "evaluate", "evaluate_detailed",
290
+ ]
291
+
292
+ # Quantifier.type spellings accepted for each quantifier kind (matches tarski.py).
293
+ _FORALL = ("forall", "∀")
294
+ _EXISTS = ("exists", "∃")
295
+
296
+ Assignment = Mapping[str, Individual]
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Errors
301
+ # ---------------------------------------------------------------------------
302
+
303
+ class UninterpretedSymbol(LookupError):
304
+ """A formula mentions a predicate/constant this structure does not interpret.
305
+
306
+ Raised instead of letting :class:`FiniteStructure`'s bare ``KeyError``
307
+ propagate, so a caller sees WHICH symbol (name + arity) is missing without
308
+ having to parse a generic key-error message. ``.symbol`` and ``.arity``
309
+ carry that structurally (``.arity`` is ``None`` for a missing constant).
310
+ """
311
+
312
+ def __init__(self, message: str, *, symbol: str, arity: Optional[int]):
313
+ super().__init__(message)
314
+ self.symbol = symbol
315
+ self.arity = arity
316
+
317
+
318
+ class UnsupportedNode(NotImplementedError):
319
+ """A node type outside this evaluator's classical, non-modal, non-fuzzy,
320
+ non-lambda, non-second-order, first-order fragment (see the module
321
+ docstring's "Supported nodes" list). Raised loudly rather than
322
+ approximated — this kit never silently treats an operator it was not told
323
+ how to evaluate as a no-op."""
324
+
325
+
326
+ class BudgetExhausted(RuntimeError):
327
+ """:func:`evaluate` could not determine a truth value within its step
328
+ budget. The honest reading is UNKNOWN, not ``False`` — ``evaluate`` cannot
329
+ express that in a ``bool`` return, so it raises instead of guessing. Call
330
+ :func:`evaluate_detailed` directly to receive the three-valued
331
+ :class:`EvalResult` (``holds=None``, ``exhausted=True``) rather than an
332
+ exception. ``.steps`` is how many steps were actually spent."""
333
+
334
+ def __init__(self, message: str, *, steps: int):
335
+ super().__init__(message)
336
+ self.steps = steps
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Result object
341
+ # ---------------------------------------------------------------------------
342
+
343
+ @dataclass(frozen=True)
344
+ class EvalResult:
345
+ """Outcome of :func:`evaluate_detailed`.
346
+
347
+ ``holds`` is ``Optional[bool]``: ``None`` iff ``exhausted`` is ``True``
348
+ (the budget ran out before a definitive answer — UNKNOWN, not ``False``).
349
+ ``witness`` (only ever set when ``holds is True``) and ``failing_conjunct``
350
+ (only ever set when ``holds is False``) are best-effort explanations — see
351
+ the module docstring for exactly what shape of formula they can explain.
352
+ ``steps`` is the number of evaluation steps actually performed (see
353
+ ``_Ctx.tick``), including any spent on ``witness``/``failing_conjunct``
354
+ extraction. ``exhausted`` is ``True`` iff the ``budget`` passed to
355
+ :func:`evaluate_detailed` was used up before the core truth value itself
356
+ was decided (running out of budget only *during* witness/blame extraction
357
+ does not set this — see :func:`evaluate_detailed`).
358
+ """
359
+
360
+ holds: Optional[bool]
361
+ witness: Optional[Dict[str, str]]
362
+ failing_conjunct: Optional[Node]
363
+ steps: int
364
+ exhausted: bool
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Budget / evaluation context
369
+ # ---------------------------------------------------------------------------
370
+
371
+ class _BudgetHit(Exception):
372
+ """Internal control-flow signal only: the step budget ran out. Always
373
+ caught inside this module; never escapes evaluate()/evaluate_detailed()."""
374
+
375
+
376
+ class _Ctx:
377
+ """Mutable state threaded through one :func:`evaluate_detailed` call:
378
+ the step counter/budget, the ``all_different`` flag, and a per-call cache
379
+ for reverse binary-relation lookups (see :func:`_reverse_neighbors`) —
380
+ scoped to one call, not to the structure, because unlike
381
+ :meth:`FiniteStructure.neighbors`'s own cache this module cannot attach
382
+ a cache to the (frozen, unowned-by-us) structure object itself."""
383
+
384
+ __slots__ = ("budget", "steps", "all_different", "reverse_cache",
385
+ "harvest_cache", "function_cache")
386
+
387
+ def __init__(self, budget: Optional[int], all_different: bool):
388
+ self.budget = budget
389
+ self.steps = 0
390
+ self.all_different = all_different
391
+ self.reverse_cache: Dict[Key, Dict[Individual, FrozenSet[Individual]]] = {}
392
+ #: node -> _harvest_atoms(node). The harvest depends on the FORMULA
393
+ #: alone, never on the assignment, but candidate generation asks for
394
+ #: it once per binding attempt — so without this it is recomputed,
395
+ #: by full recursive descent, on every single step. Keyed by the node
396
+ #: itself (nodes are frozen and hashable), scoped to one call.
397
+ self.harvest_cache: Dict[Node, List[Atom]] = {}
398
+ #: (name, arity+1) -> {input_tuple: result} for a STORED function
399
+ #: extension — built once (see :func:`_function_value`) rather than
400
+ #: re-scanned on every occurrence of the same function symbol, the
401
+ #: same reasoning as ``reverse_cache`` for a stored binary relation.
402
+ #: A COMPUTED function key is deliberately never cached here — see
403
+ #: :func:`_function_value`'s own docstring, mirroring
404
+ #: :func:`_reverse_neighbors`'s identical choice for a computed
405
+ #: binary predicate.
406
+ self.function_cache: Dict[Key, Dict[Tuple[Individual, ...], Individual]] = {}
407
+
408
+ def tick(self) -> None:
409
+ self.steps += 1
410
+ if self.budget is not None and self.steps > self.budget:
411
+ raise _BudgetHit()
412
+
413
+
414
+ def _extend(assignment: Assignment, name: str, value: Individual) -> Dict[str, Individual]:
415
+ """A copy of ``assignment`` with ``name`` bound to ``value`` (functional
416
+ style — the caller's dict, and every other branch's, is never mutated)."""
417
+ new = dict(assignment)
418
+ new[name] = value
419
+ return new
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Terms and atoms
424
+ # ---------------------------------------------------------------------------
425
+
426
+ def _term_value(term: Node, structure: FiniteStructure, assignment: Assignment,
427
+ ctx: "_Ctx") -> Individual:
428
+ """Evaluate a TERM to an individual. ``Variable``, ``Constant`` and
429
+ ``Function`` are supported (see the module docstring) — a
430
+ :class:`FiniteStructure` interprets predicates AND, as of the ``Function``
431
+ case below, functions (read off the ``(name, arity+1)`` total-relation
432
+ extension :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
433
+ already builds); ``Number``/``Cardinality``/``Measure`` denote NUMBERS,
434
+ not individuals, and have nothing to evaluate against here — see
435
+ :func:`_numeric_value` for those, and the module docstring's "Two kinds
436
+ of term value, kept apart" for why the two are never merged."""
437
+ if isinstance(term, Variable):
438
+ if term.name not in assignment:
439
+ raise ValueError(
440
+ f"model_eval: variable {term.name!r} is free — it is bound by "
441
+ "no enclosing quantifier and was not supplied in assignment=."
442
+ )
443
+ return assignment[term.name]
444
+ if isinstance(term, Constant):
445
+ if term.name not in structure.constants:
446
+ raise UninterpretedSymbol(
447
+ f"model_eval: constant {term.name!r} is not interpreted by this "
448
+ f"structure (known constants: {sorted(structure.constants)}).",
449
+ symbol=term.name, arity=None,
450
+ )
451
+ return structure.constants[term.name]
452
+ if isinstance(term, Function):
453
+ return _function_value(term, structure, assignment, ctx)
454
+ raise UnsupportedNode(
455
+ f"model_eval: term node {type(term).__name__} is not supported — only "
456
+ "Variable, Constant and Function terms are evaluated (no Number/"
457
+ "Cardinality/Measure terms; see the module docstring)."
458
+ )
459
+
460
+
461
+ def _function_value(term: Function, structure: FiniteStructure, assignment: Assignment,
462
+ ctx: "_Ctx") -> Individual:
463
+ """Evaluate a ``Function`` TERM ``f(t1,...,tk)`` to the individual it denotes.
464
+
465
+ Arguments are evaluated recursively through :func:`_term_value` FIRST —
466
+ which is also what makes nested composition (``f(g(x))``) just work, via
467
+ ordinary Python recursion, with no special-casing here. The function
468
+ itself is then read off the SAME ``(name, arity+1)`` "total relation"
469
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` already
470
+ reconstructs for a function symbol (inputs, then result — see that
471
+ function's own docstring): the unique row whose leading ``k`` components
472
+ equal the evaluated arguments supplies the result.
473
+
474
+ A STORED extension is indexed into a plain ``{inputs: result}`` dict ONCE
475
+ per ``(name, arity+1)`` key and cached in ``ctx.function_cache`` for the
476
+ rest of this evaluation call (mirroring ``reverse_cache`` for a stored
477
+ binary relation) — building that index is also where FUNCTIONALITY is
478
+ checked: two different rows sharing the same input tuple is a defect in a
479
+ hand-built structure (a genuine solver-produced structure already passed
480
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`'s own
481
+ functionality check, so this can only fire for a structure built by hand
482
+ with a broken ``Function``-shaped extension). A COMPUTED relation cannot
483
+ be inverted this way (an opaque callable has no rows to scan) and is
484
+ instead searched over the WHOLE domain for the individual ``y`` with
485
+ ``computed(*args, y)`` true — uncached, the identical trade-off
486
+ :func:`_reverse_neighbors` already makes for a computed binary predicate,
487
+ for the identical reason. Either way, ANYTHING other than exactly one
488
+ matching result — none (the function is PARTIAL on these arguments) or
489
+ more than one (the relation is not FUNCTIONAL) — is refused loudly with a
490
+ named :class:`ValueError`, mirroring :func:`_holds`'s
491
+ :class:`UninterpretedSymbol` pattern for "this structure does not say
492
+ what I need it to": a hand-built :class:`FiniteStructure` that puts a
493
+ partial or non-functional relation under a function's key must be
494
+ refused, not silently misread as picking an arbitrary row or as
495
+ "uninterpreted".
496
+
497
+ An entirely UNINTERPRETED function symbol (neither stored nor computed —
498
+ this is what a genuinely arithmetic name like ``+``/``-``/``*``/``/``
499
+ hits: :meth:`~unicode_logic_kit.fol.signature.Signature.from_formulas`
500
+ never declares them as user functions, so a structure built via
501
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` never
502
+ has an extension for them either — this evaluator needs no special check
503
+ of its own to keep them refused, see the module docstring) raises
504
+ :class:`UninterpretedSymbol` exactly like an uninterpreted predicate.
505
+ """
506
+ args = tuple(_term_value(a, structure, assignment, ctx) for a in term.args)
507
+ name = term.name
508
+ k = len(args)
509
+ key = (name, k + 1)
510
+
511
+ if key in structure.extensions:
512
+ graph = ctx.function_cache.get(key)
513
+ if graph is None:
514
+ graph = {}
515
+ for row in structure.extensions[key]:
516
+ inputs, result = row[:-1], row[-1]
517
+ if inputs in graph and graph[inputs] != result:
518
+ raise ValueError(
519
+ f"model_eval: function {name!r}/{k} is not FUNCTIONAL "
520
+ f"in this structure — both {graph[inputs]!r} and "
521
+ f"{result!r} are claimed as the result for "
522
+ f"{name}{inputs} (a hand-built FiniteStructure must "
523
+ "obey the same 'exactly one result per input tuple' "
524
+ "contract structure_from_solution enforces on a "
525
+ "solver's own output)."
526
+ )
527
+ graph[inputs] = result
528
+ ctx.function_cache[key] = graph
529
+ if args not in graph:
530
+ raise ValueError(
531
+ f"model_eval: function {name!r}/{k} has no result for "
532
+ f"{name}{args} in this structure — it must be TOTAL (a row "
533
+ "for every input tuple), the other half of the "
534
+ "structure_from_solution contract a hand-built structure "
535
+ "must also obey."
536
+ )
537
+ return graph[args]
538
+
539
+ if key in structure.computed:
540
+ decide = structure.computed[key]
541
+ matches = tuple(y for y in structure.domain if decide(*args, y))
542
+ if len(matches) != 1:
543
+ raise ValueError(
544
+ f"model_eval: function {name!r}/{k} is not a total function "
545
+ f"in this structure — {name}{args} has {len(matches)} "
546
+ f"result(s) ({matches!r}) among this structure's computed "
547
+ f"{name!r}/{key[1]} relation, not exactly one."
548
+ )
549
+ return matches[0]
550
+
551
+ raise UninterpretedSymbol(
552
+ f"model_eval: function {name!r}/{k} is not interpreted by this "
553
+ f"structure (known: {[f'{n}/{a}' for n, a in structure.signature()]}).",
554
+ symbol=name, arity=k,
555
+ )
556
+
557
+
558
+ def _holds(structure: FiniteStructure, name: str, args: Tuple[Individual, ...]) -> bool:
559
+ """``structure.holds`` wrapped to turn its bare ``KeyError`` into
560
+ :class:`UninterpretedSymbol`."""
561
+ try:
562
+ return structure.holds(name, args)
563
+ except KeyError:
564
+ raise UninterpretedSymbol(
565
+ f"model_eval: predicate {name!r}/{len(args)} is not interpreted by "
566
+ "this structure (known: "
567
+ f"{[f'{n}/{a}' for n, a in structure.signature()]}).",
568
+ symbol=name, arity=len(args),
569
+ ) from None
570
+
571
+
572
+ #: The comparison predicates, with the integer relation each denotes when its
573
+ #: operands are NUMERIC. ``=``/``≠`` appear here as well as in the identity
574
+ #: path below: which reading applies is decided by the operands, never by the
575
+ #: symbol alone.
576
+ _ORDER_OPS = {
577
+ "=": lambda a, b: a == b, "≠": lambda a, b: a != b,
578
+ "<": lambda a, b: a < b, ">": lambda a, b: a > b,
579
+ "≤": lambda a, b: a <= b, "≥": lambda a, b: a >= b,
580
+ }
581
+
582
+ #: Term nodes that denote a NUMBER rather than an individual. Their presence
583
+ #: in a comparison is what switches that comparison to the numeric reading.
584
+ _NUMERIC_TERMS = (Cardinality, Number)
585
+
586
+
587
+ def _numeric_value(term: Node, structure: FiniteStructure, assignment: Assignment,
588
+ bound_existentials: FrozenSet[Individual], ctx: "_Ctx") -> int:
589
+ """Evaluate a NUMERIC term to an integer.
590
+
591
+ Deliberately separate from :func:`_term_value`, which answers with
592
+ individuals: the two notions of "term value" are incompatible and are kept
593
+ apart rather than merged (see the module docstring). Only the comparison
594
+ branch of :func:`_atom_value` calls this.
595
+
596
+ ``|{v : φ}|`` is counted over the whole domain — no candidate narrowing,
597
+ because a COUNT needs every satisfying individual, not one witness, so
598
+ there is nothing to prune. Each individual costs a ``tick``, so the
599
+ evaluation budget covers counting exactly as it covers quantification.
600
+ """
601
+ if isinstance(term, Number):
602
+ return term.value
603
+ if isinstance(term, Cardinality):
604
+ name = term.variable.name
605
+ total = 0
606
+ for d in structure.domain:
607
+ ctx.tick()
608
+ if _eval(term.formula, structure, _extend(assignment, name, d),
609
+ bound_existentials, ctx):
610
+ total += 1
611
+ return total
612
+ raise UnsupportedNode(
613
+ f"model_eval: {type(term).__name__} does not denote a number, so it "
614
+ "cannot be compared with one — a comparison mixing a cardinality with "
615
+ "a domain individual has no reading here."
616
+ )
617
+
618
+
619
+ def _atom_value(atom: Atom, structure: FiniteStructure, assignment: Assignment,
620
+ bound_existentials: FrozenSet[Individual], ctx: "_Ctx") -> bool:
621
+ """Truth value of an atomic formula.
622
+
623
+ Three readings, chosen by the operands rather than by the predicate name:
624
+
625
+ * a comparison with at least one NUMERIC operand (``Cardinality`` or
626
+ ``Number``) is arithmetic — ``|{x : P(x)}| > |{y : Q(y)}|`` compares two
627
+ counts;
628
+ * ``=``/``≠`` over individuals is term identity, read directly (domain
629
+ individuals are opaque names, so identity is Python ``==``) rather than
630
+ routed through the structure, which never stores an extension for them;
631
+ * everything else, INCLUDING ``<``/``>``/``≤``/``≥`` between ordinary
632
+ terms, is an ordinary predicate looked up via :func:`_holds`. A
633
+ structure is free to interpret ``<`` as any relation it likes, and this
634
+ evaluator does not impose an order on an uninterpreted symbol.
635
+ """
636
+ if (atom.predicate in _ORDER_OPS and len(atom.args) == 2
637
+ and any(isinstance(a, _NUMERIC_TERMS) for a in atom.args)):
638
+ left = _numeric_value(atom.args[0], structure, assignment,
639
+ bound_existentials, ctx)
640
+ right = _numeric_value(atom.args[1], structure, assignment,
641
+ bound_existentials, ctx)
642
+ return _ORDER_OPS[atom.predicate](left, right)
643
+ if _is_tptp_boolean_atom(atom):
644
+ # TPTP's defined propositions, not a relation of the structure — the
645
+ # reading to_z3, the Tarski evaluator and the TPTP writers give them.
646
+ return _truth_constant_word(atom) == "$true"
647
+ if atom.predicate in ("=", "≠") and len(atom.args) == 2:
648
+ left_i = _term_value(atom.args[0], structure, assignment, ctx)
649
+ right_i = _term_value(atom.args[1], structure, assignment, ctx)
650
+ same = left_i == right_i
651
+ return same if atom.predicate == "=" else not same
652
+ args = tuple(_term_value(a, structure, assignment, ctx) for a in atom.args)
653
+ return _holds(structure, atom.predicate, args)
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Candidate generation (design decision #2 — see module docstring)
658
+ # ---------------------------------------------------------------------------
659
+
660
+ def _harvest_atoms(node: Node) -> List[Atom]:
661
+ """Atoms that are UNCONDITIONALLY required by ``node`` — reachable only
662
+ through a chain of nested ``And`` (both branches); does not descend into
663
+ ``Or``/``Not``/``Implies``/``Iff``/``Xor``/nested ``Quantifier``/``Count``.
664
+ See the module docstring's "Harvesting rule" for why this is sound."""
665
+ if isinstance(node, (And, Contrast)):
666
+ return _harvest_atoms(node.left) + _harvest_atoms(node.right)
667
+ if isinstance(node, Atom):
668
+ return [node]
669
+ return []
670
+
671
+
672
+ def _resolved_individual(
673
+ term: Node, structure: FiniteStructure, assignment: Assignment
674
+ ) -> Optional[Individual]:
675
+ """The individual an ALREADY-BOUND term denotes, or ``None`` if it is not
676
+ (yet) resolvable. Not an error path: candidate generation is a heuristic
677
+ that simply skips an atom it cannot yet use — :func:`_term_value` is what
678
+ enforces free-variable/uninterpreted-constant errors during real
679
+ evaluation.
680
+
681
+ Deliberately returns ``None`` — "not yet resolvable" — for a
682
+ :class:`Function` term such as ``f(y)``, even when ``y`` is itself bound:
683
+ this helper only ever recognises a BARE ``Constant``/``Variable``, never
684
+ evaluates a ``Function`` to find its value. That keeps candidate
685
+ generation SOUND with no extra reasoning needed here — a variable that
686
+ occurs only inside a ``Function`` argument (``rel(f(x), y)``, or a unary
687
+ atom ``p(f(x))``) is simply never narrowed by :func:`_variable_candidates`
688
+ (its own ``isinstance(t, Variable)`` checks reject a ``Function``-wrapped
689
+ occurrence the same way they reject any other non-bare term), so such a
690
+ variable always falls back to the full-domain scan documented in
691
+ :func:`_variable_candidates`'s own docstring — correct, just not indexed.
692
+ Peering INSIDE a ``Function`` argument to narrow the candidate set would
693
+ need its own soundness argument (the indexing heuristic was designed and
694
+ proven sound only for predicate atoms over bare terms — see the module
695
+ docstring) and is not attempted here."""
696
+ if isinstance(term, Constant) and term.name in structure.constants:
697
+ return structure.constants[term.name]
698
+ if isinstance(term, Variable) and term.name in assignment:
699
+ return assignment[term.name]
700
+ return None
701
+
702
+
703
+ def _reverse_neighbors(
704
+ structure: FiniteStructure, name: str, individual: Individual, ctx: "_Ctx"
705
+ ) -> Tuple[Individual, ...]:
706
+ """The ``x`` with ``name(x, individual)`` true — the REVERSE direction of
707
+ :meth:`FiniteStructure.neighbors` (which only indexes forward). Built from
708
+ the STORED extension when the relation is stored (cheap: a single pass
709
+ over an already-materialised set, cached in ``ctx`` for the rest of this
710
+ evaluation call). A ``computed`` binary predicate has no extension to
711
+ index — there is no way to invert an opaque callable without evaluating
712
+ it on every pair, so that one case falls back to an O(|domain|) scan of
713
+ :meth:`FiniteStructure.holds`. This is a deliberate, honest, documented
714
+ limitation (see the module docstring), not a silent unsoundness: the
715
+ result is still the exact reverse-neighbour set, just not indexed."""
716
+ key = (name, 2)
717
+ if key in structure.extensions:
718
+ index = ctx.reverse_cache.get(key)
719
+ if index is None:
720
+ index = {}
721
+ for a, b in structure.extensions[key]:
722
+ index.setdefault(b, set()).add(a)
723
+ ctx.reverse_cache[key] = index
724
+ members = index.get(individual, ())
725
+ return tuple(x for x in structure.domain if x in members)
726
+ if key in structure.computed:
727
+ decide = structure.computed[key]
728
+ return tuple(x for x in structure.domain if decide(x, individual))
729
+ raise UninterpretedSymbol(
730
+ f"model_eval: predicate {name!r}/2 is not interpreted by this structure "
731
+ f"(known: {[f'{n}/{a}' for n, a in structure.signature()]}).",
732
+ symbol=name, arity=2,
733
+ )
734
+
735
+
736
+ def _variable_candidates(
737
+ var_name: str, body: Node, structure: FiniteStructure, assignment: Assignment,
738
+ ctx: "_Ctx",
739
+ ) -> Tuple[Individual, ...]:
740
+ """A SOUND (superset) candidate set for ``var_name``, in ``structure``
741
+ domain order: the intersection of every unary/binary constraint on it
742
+ harvested from ``body`` (see :func:`_harvest_atoms`). Falls back to the
743
+ WHOLE domain if nothing usable was found — still correct (every candidate
744
+ is fully re-checked by evaluating the whole body), just not narrowed.
745
+
746
+ This heuristic was designed and proven sound only for a harvested atom
747
+ whose argument is a BARE ``Variable``/``Constant`` — the ``isinstance(t,
748
+ Variable)`` checks below simply do not match a ``Function``-wrapped
749
+ occurrence (``p(f(x))``, ``rel(f(x), y)``), so such an atom contributes
750
+ NO narrowing for ``x`` (see :func:`_resolved_individual`'s own docstring
751
+ for the fuller argument) and ``x`` falls through to the documented
752
+ full-domain-scan fallback above instead — still sound (never narrows
753
+ WRONG), just not indexed. Extending the heuristic to peer inside a
754
+ ``Function`` argument is deliberately NOT attempted here."""
755
+ harvested = ctx.harvest_cache.get(body)
756
+ if harvested is None:
757
+ harvested = _harvest_atoms(body)
758
+ ctx.harvest_cache[body] = harvested
759
+ found: Optional[set] = None
760
+ for atom in harvested:
761
+ if atom.predicate in ("=", "≠"):
762
+ continue # (dis)equality carries no domain-membership information
763
+ if len(atom.args) == 1:
764
+ (t,) = atom.args
765
+ if isinstance(t, Variable) and t.name == var_name:
766
+ try:
767
+ members = set(structure.individuals_with(atom.predicate))
768
+ except KeyError:
769
+ raise UninterpretedSymbol(
770
+ f"model_eval: predicate {atom.predicate!r}/1 is not "
771
+ "interpreted by this structure (known: "
772
+ f"{[f'{n}/{a}' for n, a in structure.signature()]}).",
773
+ symbol=atom.predicate, arity=1,
774
+ ) from None
775
+ found = members if found is None else (found & members)
776
+ elif len(atom.args) == 2:
777
+ t0, t1 = atom.args
778
+ is_v0 = isinstance(t0, Variable) and t0.name == var_name
779
+ is_v1 = isinstance(t1, Variable) and t1.name == var_name
780
+ if is_v0 and not is_v1:
781
+ other = _resolved_individual(t1, structure, assignment)
782
+ if other is not None:
783
+ members = set(_reverse_neighbors(structure, atom.predicate, other, ctx))
784
+ found = members if found is None else (found & members)
785
+ elif is_v1 and not is_v0:
786
+ other = _resolved_individual(t0, structure, assignment)
787
+ if other is not None:
788
+ try:
789
+ members = set(structure.neighbors(atom.predicate, other))
790
+ except KeyError:
791
+ raise UninterpretedSymbol(
792
+ f"model_eval: predicate {atom.predicate!r}/2 is not "
793
+ "interpreted by this structure (known: "
794
+ f"{[f'{n}/{a}' for n, a in structure.signature()]}).",
795
+ symbol=atom.predicate, arity=2,
796
+ ) from None
797
+ found = members if found is None else (found & members)
798
+ # both/neither position is var_name: this atom carries no usable
799
+ # constraint yet (rel(x,x) self-pairs, or both ends still unbound).
800
+ # arity 0 or > 2: not a per-individual membership constraint, skip.
801
+ if found is None:
802
+ return structure.domain
803
+ return tuple(d for d in structure.domain if d in found)
804
+
805
+
806
+ def _peel_exists_chain(node: Quantifier) -> Tuple[List[str], Node]:
807
+ """Peel a maximal run of adjacent ``∃`` layers starting at ``node`` (which
808
+ must itself be existential); return the bound variable names in outer-to-
809
+ inner order and the innermost non-quantifier matrix."""
810
+ names = [node.variable.name]
811
+ body = node.formula
812
+ while isinstance(body, Quantifier) and body.type in _EXISTS:
813
+ names.append(body.variable.name)
814
+ body = body.formula
815
+ return names, body
816
+
817
+
818
+ def _pick_most_constrained(
819
+ remaining: List[str], matrix: Node, structure: FiniteStructure,
820
+ assignment: Assignment, bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
821
+ ) -> Tuple[str, Tuple[Individual, ...]]:
822
+ """The most-constrained-variable heuristic: among ``remaining``, the
823
+ variable whose CURRENT candidate set (given what is bound so far) is
824
+ smallest — recomputed at every call, since a bond-neighbour constraint
825
+ only becomes usable once the other end of the bond is bound. Ties keep
826
+ the first (leftmost) variable with the smallest set seen so far."""
827
+ best_name, best_cands = None, None
828
+ for name in remaining:
829
+ cands = _variable_candidates(name, matrix, structure, assignment, ctx)
830
+ if ctx.all_different:
831
+ cands = tuple(c for c in cands if c not in bound_existentials)
832
+ if best_cands is None or len(cands) < len(best_cands):
833
+ best_name, best_cands = name, cands
834
+ if not best_cands:
835
+ break # an empty candidate set cannot be beaten
836
+ return best_name, best_cands
837
+
838
+
839
+ def _search_exists(
840
+ remaining: List[str], matrix: Node, structure: FiniteStructure,
841
+ assignment: Assignment, bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
842
+ ) -> bool:
843
+ """Backtracking search for a satisfying binding of ``remaining`` existential
844
+ variables over ``matrix``, dynamically reordered by
845
+ :func:`_pick_most_constrained` at every step (design decision #2)."""
846
+ ctx.tick()
847
+ if not remaining:
848
+ return _eval(matrix, structure, assignment, bound_existentials, ctx)
849
+ name, cands = _pick_most_constrained(
850
+ remaining, matrix, structure, assignment, bound_existentials, ctx)
851
+ # Remove exactly the ONE occurrence of `name` that was just picked, not
852
+ # every occurrence of that name — a chain can reuse a variable name
853
+ # across layers (`∃x∃x∃x φ`, each layer legitimately shadowing the last),
854
+ # and `[n for n in remaining if n != name]` used to drop all of them at
855
+ # once, collapsing an N-deep same-named chain into a single quantifier
856
+ # (see the module docstring's `all_different` bullet: this used to let
857
+ # `all_different=True` silently require only 1 witness instead of N).
858
+ # `_pick_most_constrained` always resolves ties by keeping the FIRST
859
+ # (leftmost) `remaining` entry with the smallest candidate set, and two
860
+ # occurrences of the same name always have IDENTICAL candidate sets at
861
+ # this point (same matrix, same assignment, same bound_existentials), so
862
+ # the leftmost remaining occurrence of `name` is always the one that was
863
+ # just picked — exactly what `list.remove` (first-occurrence) deletes.
864
+ rest = list(remaining)
865
+ rest.remove(name)
866
+ for c in cands:
867
+ if _search_exists(rest, matrix, structure, _extend(assignment, name, c),
868
+ bound_existentials | {c}, ctx):
869
+ return True
870
+ return False
871
+
872
+
873
+ def _search_exists_witness(
874
+ remaining: List[str], matrix: Node, structure: FiniteStructure,
875
+ assignment: Assignment, bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
876
+ ) -> Optional[Dict[str, Individual]]:
877
+ """Like :func:`_search_exists` but returns the satisfying binding (or
878
+ ``None``) instead of just whether one exists — used by :func:`_find_witness`."""
879
+ ctx.tick()
880
+ if not remaining:
881
+ return {} if _eval(matrix, structure, assignment, bound_existentials, ctx) else None
882
+ name, cands = _pick_most_constrained(
883
+ remaining, matrix, structure, assignment, bound_existentials, ctx)
884
+ # See the identical `rest` line in _search_exists just above for why this
885
+ # must remove exactly one (the first-occurring, i.e. just-picked)
886
+ # occurrence of `name` rather than every occurrence of that name.
887
+ rest = list(remaining)
888
+ rest.remove(name)
889
+ for c in cands:
890
+ sub = _search_exists_witness(rest, matrix, structure, _extend(assignment, name, c),
891
+ bound_existentials | {c}, ctx)
892
+ if sub is not None:
893
+ merged = {name: c}
894
+ merged.update(sub)
895
+ return merged
896
+ return None
897
+
898
+
899
+ # ---------------------------------------------------------------------------
900
+ # Quantifier / Count evaluation
901
+ # ---------------------------------------------------------------------------
902
+
903
+ def _eval_quantifier(
904
+ node: Quantifier, structure: FiniteStructure, assignment: Assignment,
905
+ bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
906
+ ) -> bool:
907
+ if node.type in _FORALL:
908
+ # ∀ iterates the whole domain — see the module docstring: this
909
+ # evaluator's optimisations target ∃/Count (the bottlenecks that
910
+ # actually bite), not ∀, which has no analogous candidate-narrowing
911
+ # opportunity without also inspecting the (possibly absent) guard
912
+ # implicit in an implication body.
913
+ name = node.variable.name
914
+ for d in structure.domain:
915
+ if not _eval(node.formula, structure, _extend(assignment, name, d),
916
+ bound_existentials, ctx):
917
+ return False
918
+ return True
919
+ if node.type in _EXISTS:
920
+ names, matrix = _peel_exists_chain(node)
921
+ return _search_exists(names, matrix, structure, assignment, bound_existentials, ctx)
922
+ raise ValueError(f"model_eval: unknown quantifier type {node.type!r}")
923
+
924
+
925
+ def _eval_count(
926
+ node: Count, structure: FiniteStructure, assignment: Assignment,
927
+ bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
928
+ ) -> bool:
929
+ """∃≥n / ∃≤n / ∃=n, counted directly over the (index-narrowed) candidate
930
+ set for the bound variable — see design decision #3 in the module
931
+ docstring. ``all_different`` does not apply here: n distinct witnesses are
932
+ already what ``Count`` means, independent of that flag."""
933
+ name = node.variable.name
934
+ n = node.n.value
935
+ cands = _variable_candidates(name, node.formula, structure, assignment, ctx)
936
+ count = 0
937
+ for d in cands:
938
+ if _eval(node.formula, structure, _extend(assignment, name, d), bound_existentials, ctx):
939
+ count += 1
940
+ if node.op == "ge" and count >= n:
941
+ return True
942
+ if node.op in ("le", "eq") and count > n:
943
+ return False
944
+ if node.op == "ge":
945
+ return count >= n
946
+ if node.op == "le":
947
+ return count <= n
948
+ if node.op == "eq":
949
+ return count == n
950
+ raise ValueError(f"model_eval: unknown Count op {node.op!r}")
951
+
952
+
953
+ # ---------------------------------------------------------------------------
954
+ # Core recursive evaluator
955
+ # ---------------------------------------------------------------------------
956
+
957
+ def _eval(
958
+ node: Node, structure: FiniteStructure, assignment: Assignment,
959
+ bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
960
+ ) -> bool:
961
+ """Evaluate ``node`` directly against ``structure`` — the single
962
+ recursive engine behind :func:`evaluate`/:func:`evaluate_detailed`. One
963
+ step is charged per call (see ``_Ctx.tick``, which may raise the internal
964
+ ``_BudgetHit`` signal). ``And``/``Or`` short-circuit via Python's own
965
+ ``and``/``or`` on the two recursive calls; ``Implies`` short-circuits
966
+ explicitly on a false antecedent."""
967
+ ctx.tick()
968
+ if isinstance(node, Atom):
969
+ return _atom_value(node, structure, assignment, bound_existentials, ctx)
970
+ if isinstance(node, Not):
971
+ return not _eval(node.formula, structure, assignment, bound_existentials, ctx)
972
+ if isinstance(node, (And, Contrast)):
973
+ # Contrast (``P Ⓒ Q``, whereas/although/but) is truth-functionally
974
+ # conjunction — concession is a DISCOURSE relation, not a
975
+ # truth-functional one. The node exists so a front-end can keep the
976
+ # contrast instead of flattening it to ∧, and every export already
977
+ # treats it as ∧; evaluating it any other way here would invent a
978
+ # semantics the node's own contract denies.
979
+ return (_eval(node.left, structure, assignment, bound_existentials, ctx)
980
+ and _eval(node.right, structure, assignment, bound_existentials, ctx))
981
+ if isinstance(node, Or):
982
+ return (_eval(node.left, structure, assignment, bound_existentials, ctx)
983
+ or _eval(node.right, structure, assignment, bound_existentials, ctx))
984
+ if isinstance(node, Xor):
985
+ return (_eval(node.left, structure, assignment, bound_existentials, ctx)
986
+ != _eval(node.right, structure, assignment, bound_existentials, ctx))
987
+ if isinstance(node, Implies):
988
+ if not _eval(node.left, structure, assignment, bound_existentials, ctx):
989
+ return True
990
+ return _eval(node.right, structure, assignment, bound_existentials, ctx)
991
+ if isinstance(node, Iff):
992
+ return (_eval(node.left, structure, assignment, bound_existentials, ctx)
993
+ == _eval(node.right, structure, assignment, bound_existentials, ctx))
994
+ if isinstance(node, Quantifier):
995
+ return _eval_quantifier(node, structure, assignment, bound_existentials, ctx)
996
+ if isinstance(node, Count):
997
+ return _eval_count(node, structure, assignment, bound_existentials, ctx)
998
+ raise UnsupportedNode(
999
+ f"model_eval: node type {type(node).__name__} is not supported by direct "
1000
+ "structural evaluation — only Atom/Not/And/Or/Xor/Implies/Iff/Quantifier/"
1001
+ "Count are (see the module docstring's 'Supported nodes'). Modal, "
1002
+ "lambda, fuzzy, second-order, sorted, and other MSFL/kit extensions are "
1003
+ "out of scope here; use the dedicated evaluator for that fragment."
1004
+ )
1005
+
1006
+
1007
+ # ---------------------------------------------------------------------------
1008
+ # Explanations (best-effort — see the module docstring)
1009
+ # ---------------------------------------------------------------------------
1010
+
1011
+ def _find_witness(
1012
+ node: Node, structure: FiniteStructure, assignment: Assignment,
1013
+ bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
1014
+ ) -> Dict[str, Individual]:
1015
+ """Best-effort variable→individual bindings that make a TRUE ``node``
1016
+ true — see the module docstring's "witness" bullet for exactly which
1017
+ node shapes contribute bindings."""
1018
+ ctx.tick()
1019
+ if isinstance(node, And):
1020
+ w = _find_witness(node.left, structure, assignment, bound_existentials, ctx)
1021
+ w2 = _find_witness(node.right, structure, assignment, bound_existentials, ctx)
1022
+ merged = dict(w)
1023
+ merged.update(w2)
1024
+ return merged
1025
+ if isinstance(node, Or):
1026
+ if _eval(node.left, structure, assignment, bound_existentials, ctx):
1027
+ return _find_witness(node.left, structure, assignment, bound_existentials, ctx)
1028
+ return _find_witness(node.right, structure, assignment, bound_existentials, ctx)
1029
+ if isinstance(node, Quantifier) and node.type in _EXISTS:
1030
+ names, matrix = _peel_exists_chain(node)
1031
+ binding = _search_exists_witness(names, matrix, structure, assignment,
1032
+ bound_existentials, ctx)
1033
+ if binding is None:
1034
+ return {} # defensive: should not happen if node evaluated True
1035
+ new_assignment = dict(assignment)
1036
+ new_assignment.update(binding)
1037
+ new_bound = bound_existentials | set(binding.values())
1038
+ w = dict(binding)
1039
+ w.update(_find_witness(matrix, structure, new_assignment, new_bound, ctx))
1040
+ return w
1041
+ return {} # Not / ∀ / Count / Implies / Iff / Xor / Atom: no natural witness
1042
+
1043
+
1044
+ def _find_blame(
1045
+ node: Node, structure: FiniteStructure, assignment: Assignment,
1046
+ bound_existentials: FrozenSet[Individual], ctx: "_Ctx",
1047
+ ) -> Node:
1048
+ """Drill through a spine of nested ``And`` (left-to-right, the same
1049
+ short-circuit order evaluation used) to the first conjunct that is
1050
+ actually false; any other node type is returned as itself (not
1051
+ decomposed — see the module docstring's "failing_conjunct" bullet)."""
1052
+ ctx.tick()
1053
+ if isinstance(node, And):
1054
+ if not _eval(node.left, structure, assignment, bound_existentials, ctx):
1055
+ return _find_blame(node.left, structure, assignment, bound_existentials, ctx)
1056
+ return _find_blame(node.right, structure, assignment, bound_existentials, ctx)
1057
+ return node
1058
+
1059
+
1060
+ # ---------------------------------------------------------------------------
1061
+ # Public API
1062
+ # ---------------------------------------------------------------------------
1063
+
1064
+ def evaluate_detailed(
1065
+ formula: Node, structure: FiniteStructure, *,
1066
+ all_different: bool = False,
1067
+ assignment: Optional[Assignment] = None,
1068
+ budget: Optional[int] = None,
1069
+ ) -> EvalResult:
1070
+ """Evaluate ``formula`` against ``structure`` and return a full
1071
+ :class:`EvalResult` (three-valued: ``holds`` is ``None`` iff the budget
1072
+ was exhausted before a definitive answer). See the module docstring for
1073
+ the semantics of ``all_different``, the budget contract, and exactly what
1074
+ ``witness``/``failing_conjunct`` can and cannot explain.
1075
+
1076
+ Raises:
1077
+ UninterpretedSymbol: ``formula`` mentions a predicate/constant
1078
+ ``structure`` does not interpret.
1079
+ UnsupportedNode: ``formula`` contains a node type outside this
1080
+ evaluator's classical first-order fragment.
1081
+ ValueError: ``formula`` has a free variable not covered by
1082
+ ``assignment``.
1083
+ """
1084
+ env: Dict[str, Individual] = dict(assignment) if assignment else {}
1085
+ ctx = _Ctx(budget, all_different)
1086
+ try:
1087
+ holds = _eval(formula, structure, env, frozenset(), ctx)
1088
+ except _BudgetHit:
1089
+ return EvalResult(holds=None, witness=None, failing_conjunct=None,
1090
+ steps=ctx.steps, exhausted=True)
1091
+
1092
+ # The core answer is already decided at this point; witness/failing_conjunct
1093
+ # extraction is a secondary, best-effort pass that reuses ctx's step budget
1094
+ # but — deliberately — cannot retroactively turn a decided answer back into
1095
+ # "exhausted": running out here just means the explanation is dropped.
1096
+ try:
1097
+ if holds:
1098
+ witness = _find_witness(formula, structure, env, frozenset(), ctx)
1099
+ failing = None
1100
+ else:
1101
+ witness = None
1102
+ failing = _find_blame(formula, structure, env, frozenset(), ctx)
1103
+ except _BudgetHit:
1104
+ witness = None
1105
+ failing = None
1106
+
1107
+ return EvalResult(holds=holds, witness=witness, failing_conjunct=failing,
1108
+ steps=ctx.steps, exhausted=False)
1109
+
1110
+
1111
+ def evaluate(
1112
+ formula: Node, structure: FiniteStructure, *,
1113
+ all_different: bool = False,
1114
+ assignment: Optional[Assignment] = None,
1115
+ budget: Optional[int] = None,
1116
+ ) -> bool:
1117
+ """Boolean-only convenience wrapper around :func:`evaluate_detailed`.
1118
+
1119
+ Raises :class:`BudgetExhausted` if ``budget`` runs out before a
1120
+ definitive answer — see the module docstring's budget contract for why
1121
+ this cannot simply return ``False``. Same other exceptions as
1122
+ :func:`evaluate_detailed`.
1123
+ """
1124
+ result = evaluate_detailed(formula, structure, all_different=all_different,
1125
+ assignment=assignment, budget=budget)
1126
+ if result.exhausted:
1127
+ raise BudgetExhausted(
1128
+ f"model_eval.evaluate: budget of {budget} steps was exhausted after "
1129
+ f"{result.steps} steps before a definitive truth value could be "
1130
+ "established — the result is UNKNOWN, not False. Raise `budget`, or "
1131
+ "call evaluate_detailed() to receive the three-valued EvalResult "
1132
+ "instead of an exception.",
1133
+ steps=result.steps,
1134
+ )
1135
+ return result.holds