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,1180 @@
1
+ """ASP-backed finite-domain refutation search via `clingo <https://potassco.org>`_.
2
+
3
+ :class:`ClingoBackend` decides ``premises ⊨ formula`` by searching for a
4
+ finite model of ``premises ∧ ¬formula`` at increasing domain sizes, exactly
5
+ the :class:`~unicode_logic_kit.atp.finite_domain.FiniteDomainProblem` contract
6
+ :mod:`~unicode_logic_kit.atp.finite_domain` was built for — see that module's
7
+ docstring for the shared problem shape, the fragment gate
8
+ (:func:`~unicode_logic_kit.atp.finite_domain.fragment_check`), the countermodel
9
+ reconstruction (:func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`),
10
+ and the re-verification safety net
11
+ (:func:`~unicode_logic_kit.atp.finite_domain.verify_model`). This module is
12
+ solver-specific: :func:`to_asp` renders a
13
+ :class:`~unicode_logic_kit.atp.finite_domain.FiniteDomainProblem` into an ASP
14
+ program, and :class:`ClingoBackend` runs it through clingo's Python API.
15
+
16
+ Why compositional (Tseitin-style) translation, not a flat constraint
17
+ ----------------------------------------------------------------------
18
+ An ASP rule body is a CONJUNCTION of literals; there is no direct way to
19
+ write "an arbitrary Boolean formula with ``∨``/``¬`` nested under other
20
+ connectives" as one rule. This module instead gives every subformula
21
+ occurrence its own DEFINED atom ``satK`` (``K`` a fresh, globally unique
22
+ counter — see ``_AspEncoder._fresh``), built bottom-up so a parent's rule
23
+ only ever references its children's already-defined atoms:
24
+
25
+ * ``And``/``Contrast`` — one rule ANDing both children's atoms.
26
+ * ``Or`` — TWO rules, one per disjunct (a predicate with two independent
27
+ defining rules is true if EITHER fires — this is exactly disjunction under
28
+ the stable-model semantics clingo implements).
29
+ * ``Not``/``Xor``/``Implies``/``Iff`` — combinations of the above plus
30
+ default negation (``not satK``). This is safe (not merely convenient):
31
+ every subformula tree here is FINITE and ACYCLIC, so negation only ever
32
+ refers to a strictly smaller child — the whole program is stratified, and
33
+ stratified negation is exactly classical two-valued evaluation, with no
34
+ extra argument needed for why the stable model coincides with the
35
+ intended truth table.
36
+ * ``Quantifier`` — ``∃`` is one rule with a fresh domain-ranging variable in
37
+ the body (a positive occurrence proves existence by construction); ``∀``
38
+ is the double-negation idiom (``violK`` holds if some individual violates
39
+ the body; the quantifier's own atom holds if NOT violK) rather than a
40
+ literal ASP ``∀`` (ASP has none — this is the standard encoding).
41
+ * ``Count``/``Cardinality`` — both map directly to a clingo ``#count``
42
+ aggregate assigned to a fresh integer variable, compared arithmetically.
43
+ This is a genuine improvement over
44
+ :meth:`~unicode_logic_kit.fol.nodes.Count._expand`'s distinct-witnesses
45
+ lowering (used by ``to_z3``/``to_prover9``/``to_tptp``): that expansion is
46
+ O(n²) constraints under n nested quantifiers and refuses n > 500; the
47
+ aggregate has no such bound, because clingo counts the guessed relation's
48
+ extension directly rather than enumerating n Skolem witnesses.
49
+
50
+ **Each subformula's OWN free variables (via
51
+ :func:`~unicode_logic_kit.fol.nodes.free_variables`, not a hand-rolled scope
52
+ stack) are exactly its ``satK`` atom's argument tuple.** This single choice
53
+ is what makes quantifier SHADOWING correct for free: ``∀x(P(x) ∧ ∃x Q(x))``
54
+ gives the inner ``∃x`` its own atom with ARITY 0 (``free_variables`` already
55
+ subtracts its own bound ``x``, so the inner atom never mentions the outer
56
+ ``x`` at all), and ASP's own per-RULE variable scoping means the outer and
57
+ inner rules can both legally use the ASP variable name ``Vx`` without
58
+ colliding — no manual alpha-renaming or explicit scope-stack bookkeeping is
59
+ needed anywhere in this module. Reusing ``free_variables`` (already
60
+ exercised elsewhere in the kit, e.g. by ``Count._expand``) also means this
61
+ encoder inherits a binder-correctness argument it did not have to make from
62
+ scratch — the rejected alternative (tracking a binder-scope stack by hand
63
+ through the recursion) is exactly the kind of bookkeeping most likely to
64
+ hide an off-by-one shadowing bug.
65
+
66
+ Comparison predicates — matching ``model_eval`` exactly
67
+ ------------------------------------------------------------
68
+ ``=``/``≠`` at arity 2 are lowered to NATIVE ASP term (in)equality on the
69
+ two operands' resolved domain-index variables — never a stored relation —
70
+ mirroring :func:`~unicode_logic_kit.semantics.model_eval.evaluate`'s own
71
+ special-case (identity via Python ``==``, never routed through the
72
+ structure). Every OTHER atom, INCLUDING ``<``/``>``/``≤``/``≥`` over plain
73
+ individuals, is lowered the same way ``model_eval`` reads it: as an ORDINARY
74
+ predicate the countermodel is free to interpret however it likes (see
75
+ ``model_eval``'s module docstring — it special-cases only ``=``/``≠``;
76
+ ``<`` etc. fall through to ``_holds``, i.e. a stored/looked-up relation,
77
+ NOT built-in arithmetic ordering — the domain individuals are opaque
78
+ labels, not numbers, so "less than" has no intrinsic meaning here). Because
79
+ :meth:`~unicode_logic_kit.fol.signature.Signature.from_formulas` deliberately
80
+ EXCLUDES the six comparison symbols from user vocabulary (they are the
81
+ kit's built-in operators — see that module's docstring), this encoder
82
+ tracks such "comparison-symbol-as-plain-relation" usages ITSELF and folds
83
+ them into a LOCAL, extended reconstruction Signature
84
+ (:attr:`_AspEncoder.reconstruction_signature`) used only for
85
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` — never
86
+ mutating or replacing ``problem.signature`` itself.
87
+
88
+ The one exception: when EITHER operand of a ``=``/``≠``/``<``/``>``/``≤``/
89
+ ``≥`` comparison is a counting term (``Cardinality`` or ``Number``), the
90
+ comparison is arithmetic over NATURAL NUMBERS (a ``#count`` aggregate result,
91
+ or a literal), not over domain individuals — this is the counting fragment
92
+ this whole design exists to close (see
93
+ :mod:`~unicode_logic_kit.atp.finite_domain`'s module docstring, §1 of the
94
+ originating design note). Mixing a counting term against a plain
95
+ individual-denoting term on the other side of the same comparison has no
96
+ coherent reading and is refused loudly (``_EncodingError``) rather than
97
+ guessed at.
98
+
99
+ **Numerals.** That arithmetic reading belongs to a COUNT: a ``Number`` is read as a number
100
+ only as the bound a ``Cardinality`` is compared with (``|{x : P(x)}| ≥ 2``; a float with a
101
+ whole value is that integer). Everywhere else a numeral is, as on every other route, a
102
+ constant identified by its value (``1`` and ``1.0`` one constant, ``1 ≠ 2`` not valid), and
103
+ this encoder has no symbol for such a constant, so a ``Number`` used as an individual, and a
104
+ comparison of numerals with no cardinality in it, are refused by name (``UNKNOWN`` /
105
+ ``"unsupported"``) — never read as a domain index or as arithmetic, which would make
106
+ ``(∀x ∀y x = y) → 1 = 2`` (valid: one element) refutable.
107
+
108
+ Counting AND function comparisons now verify: the closed gaps, and the one that remains
109
+ -------------------------------------------------------------------------------------------
110
+ This section used to list four node types this backend could ground and
111
+ solve but never verify. That list is down to one, closed in two steps.
112
+
113
+ ``Cardinality`` — and a ``Number`` compared as a counting term — used to be
114
+ on it: :func:`~unicode_logic_kit.atp.finite_domain.verify_model`'s underlying
115
+ evaluator raised ``UnsupportedNode`` for them, so a genuinely correct
116
+ countermodel was reported ``ERROR``/``"infra"`` instead of ``REFUTED``.
117
+ :func:`~unicode_logic_kit.semantics.model_eval.evaluate` reads a comparison
118
+ with a counting-term operand arithmetically (counting ``|{v :
119
+ φ}|`` over the domain — see that module's ``_numeric_value``/``_atom_value``
120
+ split), so :func:`~unicode_logic_kit.atp.finite_domain.verify_model` can check
121
+ these sentences and :meth:`ClingoBackend.decide` reports a verified
122
+ ``REFUTED`` for them like any other. Example: ``|{x : P(x)}| >
123
+ |{y : Q(y)}|`` at ``max_size=4`` comes back ``REFUTED`` with
124
+ ``countermodel={"kind": "finite_structure", ...}`` and ``detail=None`` (a
125
+ non-``None`` detail would mean verification failed or was skipped) — run
126
+ against this module directly on 2026-08-14, clingo 5.8.1.
127
+
128
+ ``Function`` was on this list too, for a DIFFERENT reason: the gate itself
129
+ used to refuse every ``Function`` node BY NAME before this encoder ever ran
130
+ (``fragment_check`` reported ``UNKNOWN``/``"unsupported"`` straight from the
131
+ gate — "we correctly declined to look", not merely "we found a countermodel
132
+ we cannot vouch for"), because :func:`~unicode_logic_kit.semantics.model_eval.evaluate`
133
+ had no case for a ``Function`` TERM. That upstream refusal is gone:
134
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check` now ADMITS
135
+ ``Function`` generally (a *sorted* ``FunctionDecl`` stays refused — a
136
+ separate, narrower decision, see that module's "Sorted function symbols"
137
+ section), and ``evaluate_in_structure``'s new ``Function`` case reads
138
+ ``f(t1,...,tk)`` off the SAME ``(name, arity+1)`` total-relation extension
139
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` already
140
+ built (:mod:`~unicode_logic_kit.semantics.model_eval`'s own docstring has the
141
+ full account), so this encoder's dormant ``Function`` branch in
142
+ :meth:`_AspEncoder._term` (already documented above — the choice-rule
143
+ total-relation encoding) is REACHABLE and its output is genuinely VERIFIED,
144
+ not merely produced. A function-bearing countermodel now goes through the
145
+ identical closure the counting fragment already got: solved by clingo,
146
+ reconstructed by :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`,
147
+ confirmed by :func:`~unicode_logic_kit.atp.finite_domain.verify_model`, and
148
+ reported ``REFUTED`` with ``detail=None``. Example: ``∀x (f(f(x)) = x)``
149
+ ("f is an involution") at ``max_size=4`` comes back ``REFUTED`` at the
150
+ hand-derivable minimal size 2 (the only total function on a 1-element domain
151
+ is forced to be the identity, so no countermodel exists there) with
152
+ ``detail=None`` — run against this module directly on 2026-09-17, clingo
153
+ 5.8.1 (see ``tests/test_clingo_backend.py``'s
154
+ ``test_involution_claim_refuted_with_verified_countermodel``). The four
155
+ arithmetic operator names (``+``/``-``/``*``/``/``) stay refused regardless
156
+ — they are
157
+ ``Function`` nodes like any other and pass ``fragment_check``, but
158
+ ``Signature.from_formulas`` never declares them in ``functions`` (the
159
+ ``_BUILTIN_FUNCS`` carve-out), so :meth:`_AspEncoder._term`'s existing
160
+ "function not declared in signature" ``_EncodingError`` catches them, with
161
+ no new special-casing needed in this module.
162
+
163
+ ``Contrast`` is the one node type left on the list, genuinely unchanged:
164
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check` still admits it
165
+ (truth-functionally ``And``, so this encoder's ``And``/``Contrast`` rule —
166
+ see above — grounds and solves it exactly like ``And``), but
167
+ :func:`~unicode_logic_kit.semantics.model_eval.evaluate` still raises
168
+ ``UnsupportedNode`` for it (a scope decision made in that module, not this
169
+ one). :func:`~unicode_logic_kit.atp.finite_domain.verify_model` therefore
170
+ still reports "could not verify" for a ``Contrast``-bearing sentence, and
171
+ :meth:`ClingoBackend.decide` still reports ``ERROR``/``"infra"`` rather than
172
+ ``REFUTED`` for it, EVEN THOUGH the countermodel this module found is
173
+ genuinely correct — the same trade this whole section used to describe for
174
+ all four node types, now narrowed to just this one. Per the ONE rule these
175
+ backends exist to protect (never hand back an unverified countermodel — see
176
+ :mod:`~unicode_logic_kit.atp.finite_domain`'s "Known verification gap"), that
177
+ trade is accepted rather than bypassed. Fixing it means extending
178
+ ``model_eval`` (owned elsewhere) or redesigning ``verify_model`` (a decision
179
+ this module does not make unilaterally) — not this module's bug to fix.
180
+ Plain classical-FOL-plus-``Count``/``Cardinality`` sentences, including
181
+ counting comparisons, now verify and report ``REFUTED`` normally.
182
+
183
+ A free variable is a parameter of the problem
184
+ -------------------------------------------------------------------
185
+ :meth:`ClingoBackend.decide` reads a free variable as ONE unknown element, the same in
186
+ every premise and in the goal (the assignment-wise consequence relation: ``Γ ⊨ φ`` iff every
187
+ structure AND assignment that satisfies ``Γ`` satisfies ``φ``). Before anything else it replaces
188
+ every free variable of ``premises`` and ``formula`` together by a constant of its own name
189
+ (:func:`~unicode_logic_kit.fol._free_parameters.parameterize`), and only then forms
190
+ ``premises ∧ ¬formula``. A premise is never closed universally: ``P(x)`` does not entail
191
+ ``P(alpha)`` (universe ``{0, 1}``, ``x`` ↦ 1, ``alpha`` ↦ 0, ``P`` = ``{1}``), and the countermodel
192
+ reports the element under the variable's name, ``constants['x']``. A free variable spelled like a
193
+ constant of the problem is refused (``UNKNOWN``/``"unsupported"``): a structure holds one entry
194
+ per name.
195
+
196
+ The parameters are replaced BEFORE the goal is negated: ``¬∀x φ(x)`` (some assignment falsifies
197
+ φ) is NOT the same formula as ``∀x ¬φ(x)``, which a closure applied after the negation would
198
+ produce. For a problem with no premise the reading coincides with the universal closure of the
199
+ goal (``φ`` holds under every assignment iff ``∀x φ`` holds); with a premise it does not, since
200
+ closing every premise universally would read ``P(x)`` as ``∀x P(x)`` and find no countermodel of
201
+ ``P(x) ⊢ P(alpha)``. Both the encoder and the independent checker
202
+ (:func:`~unicode_logic_kit.atp.finite_domain.verify_model`) are handed the identical sentences, so
203
+ they cannot disagree about what a free variable means: neither of them ever sees one.
204
+
205
+ ``all_different`` (every CONSTANT denotes a pairwise-distinct individual) concerns the constants
206
+ of the problem, never a parameter: a variable may denote the same element as a constant, so the
207
+ distinctness constraint, and its re-check on the reconstructed structure, leave the parameters out.
208
+
209
+ Many-sorted input
210
+ -------------------
211
+ :meth:`ClingoBackend.decide` builds ``sentences`` (the premises, and the goal negated,
212
+ every free variable already read as a parameter per the section above) and then, before anything else,
213
+ calls :func:`~unicode_logic_kit.atp.finite_domain.lower_msfol` on the whole
214
+ batch — a no-op for every plain-FOL caller, and for a many-sorted one a
215
+ relativisation to classical FOL (plus one non-emptiness sentence per
216
+ distinct sort name) that lets :func:`~unicode_logic_kit.atp.finite_domain.fragment_check`,
217
+ :func:`to_asp`, and :func:`~unicode_logic_kit.atp.finite_domain.verify_model`
218
+ all stay entirely sort-blind. See
219
+ :mod:`~unicode_logic_kit.atp.finite_domain`'s own "Many-sorted input" section
220
+ for the full design, including why the non-emptiness sentence is required
221
+ for soundness against :mod:`~unicode_logic_kit.semantics.modelfinder`, the
222
+ oracle this is differentially tested against.
223
+
224
+ Availability and timeout
225
+ -------------------------------
226
+ ``clingo`` (MIT, wheels bundle the solver) is a hard, non-external Python
227
+ dependency once installed — :func:`clingo_available` is pure discovery
228
+ (``importlib.util.find_spec``, mirroring
229
+ :meth:`~unicode_logic_kit.atp.cvc5_backend.Cvc5Backend.available`) and the
230
+ ``clingo`` module itself is imported lazily inside the solve step so probing
231
+ availability never pays its load cost. A size's solve runs through clingo's
232
+ ASYNC handle (``Control.solve(async_=True)``) so the remaining millisecond
233
+ budget can be enforced with ``handle.wait(remaining)`` +
234
+ ``handle.cancel()``; a cancellation that lands exactly as the solve
235
+ legitimately finished still yields the REAL (sat/unsat) answer rather than a
236
+ spurious timeout — ``handle.get()`` after ``cancel()`` returns whatever the
237
+ solver actually decided, and only a genuinely interrupted search comes back
238
+ with neither ``satisfiable`` nor ``unsatisfiable`` set (empirically verified
239
+ against clingo 5.8.1 before relying on it here).
240
+
241
+ This module never imports ``PROVED`` from
242
+ :mod:`~unicode_logic_kit.atp.protocol` — deliberately: a backend that cannot
243
+ even name the symbol cannot accidentally return it. First-order logic has
244
+ no finite model property, so "no countermodel up to ``max_size``" is
245
+ ``UNKNOWN``/``"bound_hit"``, never a validity proof (the ONE rule these
246
+ finite-domain backends exist to protect — see
247
+ :mod:`~unicode_logic_kit.atp.finite_domain`'s module docstring).
248
+ """
249
+
250
+ import importlib.util
251
+ import re
252
+ import time
253
+ from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
254
+
255
+ from ..fol._free_parameters import parameterize
256
+ from ..fol.nodes import (
257
+ Node, Variable, Constant, Number, Function,
258
+ Atom, Not, And, Or, Xor, Implies, Iff, Contrast, Quantifier,
259
+ Count, Cardinality,
260
+ free_variables,
261
+ )
262
+ from ..fol.signature import Signature, PredicateDecl
263
+ from .finite_domain import (
264
+ FiniteDomainProblem, fragment_check, free_variable_reason, lower_msfol,
265
+ structure_from_solution, verify_model,
266
+ )
267
+ from .protocol import ProverBackend, Verdict, REFUTED, UNKNOWN, ERROR
268
+
269
+ __all__ = ["ClingoBackend", "clingo_available", "to_asp"]
270
+
271
+ _FORALL = ("forall", "∀")
272
+ _EXISTS = ("exists", "∃")
273
+ _COMPARISON_SYMBOLS = frozenset({"=", "≠", "<", ">", "≤", "≥"})
274
+ _NUMERIC_ASP_OP = {"=": "=", "≠": "!=", "<": "<", ">": ">", "≤": "<=", "≥": ">="}
275
+
276
+
277
+ def clingo_available() -> bool:
278
+ """Return whether the ``clingo`` package is importable (pure discovery).
279
+
280
+ Never imports ``clingo`` itself — only checks
281
+ ``importlib.util.find_spec`` — so calling this to decide whether to try
282
+ :class:`ClingoBackend` never pays the solver binding's load cost, the
283
+ same discipline
284
+ :meth:`~unicode_logic_kit.atp.cvc5_backend.Cvc5Backend.available` follows
285
+ for its own optional dependency.
286
+ """
287
+ return importlib.util.find_spec("clingo") is not None
288
+
289
+
290
+ # =============================================================================
291
+ # Refutation-goal construction
292
+ # =============================================================================
293
+
294
+ def _universal_closure(node: Node) -> Node:
295
+ """Wrap ``node`` in a ∀ for each of its own free variables (deterministic order).
296
+
297
+ The universal closure of ONE formula: it says the formula holds under every
298
+ assignment, which is what a test of a countermodel needs when it re-evaluates a
299
+ single formula's refutation goal ``¬∀x φ(x)``. It is not how :meth:`ClingoBackend.decide`
300
+ reads a PROBLEM: a free variable there is a parameter shared by the premises and the
301
+ goal (see the module docstring's "A free variable is a parameter of the problem"),
302
+ because closing each premise on its own would read ``P(x)`` as ``∀x P(x)``.
303
+ Variable names sorted, innermost-first, as
304
+ :mod:`~unicode_logic_kit.semantics.modelfinder`'s module-private closure helper.
305
+ """
306
+ result = node
307
+ for name in sorted({v.name for v in free_variables(node)}, reverse=True):
308
+ result = Quantifier("∀", Variable(name), result)
309
+ return result
310
+
311
+
312
+ def _repeated_constant(constants, parameters: FrozenSet[str]) -> Optional[str]:
313
+ """A message naming two constants of ``constants`` that denote one individual, or ``None``.
314
+
315
+ The parameters that stand for free variables are left out: ``all_different`` is the
316
+ unique-names convention of the problem's constants, and a variable may denote the
317
+ element of a constant.
318
+ """
319
+ seen: Dict[object, str] = {}
320
+ for name in sorted(constants):
321
+ if name in parameters:
322
+ continue
323
+ value = constants[name]
324
+ if value in seen:
325
+ return (f"structure_from_solution: all_different=True requires every constant to "
326
+ f"denote a distinct individual, but {name!r} and {seen[value]!r} both "
327
+ f"denote {value}.")
328
+ seen[value] = name
329
+ return None
330
+
331
+
332
+ #: A variable name that is written ``"V" + name`` as it stands: ASCII letters,
333
+ #: digits and underscores, the first of them a letter or digit (see
334
+ #: :meth:`_AspEncoder._asp_var`).
335
+ _ASP_PLAIN_VARIABLE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_]*")
336
+
337
+
338
+ class _EncodingError(ValueError):
339
+ """A sentence passed ``fragment_check`` but this encoder still cannot lower it.
340
+
341
+ ``fragment_check`` (:mod:`~unicode_logic_kit.atp.finite_domain`) only
342
+ screens NODE TYPES — a shared, solver-agnostic gate the MiniZinc backend
343
+ also consults, so it deliberately does not know about ASP-specific
344
+ encodability. A few finer-grained shapes slip past it and are caught
345
+ here instead: a non-integer ``Number``, a comparison mixing a counting
346
+ term (``Cardinality``/``Number``) against a plain individual-denoting
347
+ term, or a symbol a sentence uses but the problem's Signature does not
348
+ declare (only reachable via a hand-built
349
+ :class:`~unicode_logic_kit.atp.finite_domain.FiniteDomainProblem` whose
350
+ ``signature=`` was supplied explicitly and does not match its
351
+ ``sentences``). :class:`ClingoBackend` catches this subclass of
352
+ ``ValueError`` and reports ``UNKNOWN``/``"unsupported"`` — the same
353
+ honest verdict ``fragment_check`` itself would have given had its
354
+ coarser gate been able to see this deep.
355
+ """
356
+
357
+
358
+ def _is_numeric_term(node: Node) -> bool:
359
+ """Whether ``node`` denotes a NATURAL NUMBER (a counting term), not an individual."""
360
+ return isinstance(node, (Cardinality, Number))
361
+
362
+
363
+ def _atom_mode(atom: Atom) -> str:
364
+ """Classify how ``atom`` must be lowered to ASP; see the module docstring's
365
+ "Comparison predicates" section for the full rationale.
366
+
367
+ Returns one of:
368
+
369
+ * ``"identity"`` — ``=``/``≠`` at arity 2 over plain individual terms:
370
+ native ASP term (in)equality, mirroring
371
+ :func:`~unicode_logic_kit.semantics.model_eval.evaluate`'s own
372
+ ``=``/``≠`` special-case exactly.
373
+ * ``"numeric"`` — any of the six comparison symbols at arity 2 with a
374
+ counting-term (``Cardinality``/``Number``) operand: arithmetic over
375
+ natural numbers via a ``#count`` aggregate and/or a literal integer.
376
+ * ``"relation"`` — any of the six comparison symbols used OUTSIDE the
377
+ two cases above (individual-mode ``<``/``>``/``≤``/``≥``, or any of
378
+ the six at an arity other than 2): an ORDINARY predicate the
379
+ countermodel is free to interpret arbitrarily, exactly how
380
+ ``model_eval`` reads them (it special-cases only 2-ary ``=``/``≠``).
381
+ * ``"predicate"`` — every other symbol: must already be declared in the
382
+ problem's Signature.
383
+
384
+ Raises:
385
+ _EncodingError: the comparison mixes a counting-term operand
386
+ against a plain individual-denoting one — no coherent reading —
387
+ or compares numerals with each other and with no cardinality:
388
+ the arithmetic reading belongs to a count compared with a
389
+ number, and a numeral that is no bound of a count is a
390
+ constant of the kit (see :meth:`_AspEncoder._term`).
391
+ """
392
+ if atom.predicate in _COMPARISON_SYMBOLS and len(atom.args) == 2:
393
+ left_numeric = _is_numeric_term(atom.args[0])
394
+ right_numeric = _is_numeric_term(atom.args[1])
395
+ if left_numeric or right_numeric:
396
+ if not (left_numeric and right_numeric):
397
+ raise _EncodingError(
398
+ f"{atom.predicate!r} compares a counting term "
399
+ "(Cardinality/Number) against a plain individual-denoting "
400
+ "term — both sides of a counting comparison must "
401
+ "themselves be counting terms."
402
+ )
403
+ if not any(isinstance(a, Cardinality) for a in atom.args):
404
+ raise _EncodingError(
405
+ f"{atom.predicate!r} compares numerals without a cardinality. A numeral is a "
406
+ "constant identified by its value, and nothing else is known about it, so "
407
+ "such a comparison is a statement about constants ('1 ≠ 2' is not valid, "
408
+ "'(∀x ∀y x = y) → 1 = 2' is), which this encoder does not state: it reads a "
409
+ "number only as the bound a count |{v : φ}| is compared with, and a "
410
+ "refutation found under the arithmetic reading would be wrong for the "
411
+ "constants. Use a solver that reads numerals as constants (z3, the finite "
412
+ "model finder), or compare a cardinality.")
413
+ return "numeric"
414
+ if atom.predicate in ("=", "≠"):
415
+ return "identity"
416
+ return "relation"
417
+ return "relation" if atom.predicate in _COMPARISON_SYMBOLS else "predicate"
418
+
419
+
420
+ # =============================================================================
421
+ # The encoder — shared by to_asp() and ClingoBackend.decide()
422
+ # =============================================================================
423
+
424
+ class _AspEncoder:
425
+ """Lowers one :class:`FiniteDomainProblem` to ASP text, and back again.
426
+
427
+ Built once per (sentences, size) pair. :meth:`render` produces the
428
+ program text (what :func:`to_asp` returns); :meth:`decode_model` and
429
+ :attr:`reconstruction_signature` are the matching REVERSE direction,
430
+ used only by :class:`ClingoBackend` (never by :func:`to_asp`, which has
431
+ no solver to hand a model back from) to turn clingo's shown atoms into
432
+ the ``(name, args)`` shape
433
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
434
+ expects. Kept as ONE class, not two free functions sharing module
435
+ globals, so the symbol-name maps built while rendering are exactly what
436
+ decoding reads back — a mismatch between the two directions cannot occur
437
+ by construction, not by discipline.
438
+
439
+ Deliberately rebuilt from scratch for every domain size a caller tries
440
+ (see :meth:`ClingoBackend.decide`), even though the symbol-name
441
+ assignments themselves do not depend on ``size`` — ``size`` is coupled
442
+ into :class:`FiniteDomainProblem` itself, and re-deriving a few small
443
+ dicts per size is cheap next to actually grounding and solving; the
444
+ alternative (splitting a size-independent "skeleton" out for reuse
445
+ across sizes) is a real optimisation this module deliberately does not
446
+ attempt, in favour of one obviously-correct code path.
447
+ """
448
+
449
+ def __init__(self, problem: FiniteDomainProblem, parameters: FrozenSet[str] = frozenset()):
450
+ """``parameters`` names the constants that stand for a free variable: the
451
+ problem's ``all_different`` (every CONSTANT denotes a pairwise-distinct
452
+ individual) leaves them out, since a variable may denote the element of a
453
+ constant."""
454
+ if not isinstance(problem, FiniteDomainProblem):
455
+ raise TypeError(
456
+ f"_AspEncoder: problem must be a FiniteDomainProblem, got "
457
+ f"{type(problem).__name__}."
458
+ )
459
+ self.problem = problem
460
+ self._parameters = frozenset(parameters)
461
+ self.sig: Signature = problem.signature
462
+ self._lines: List[str] = []
463
+ self._fresh_n = 0
464
+ self._pred_asp: Dict[Tuple[str, int], str] = {}
465
+ self._func_asp: Dict[str, str] = {}
466
+ self._const_asp: Dict[str, str] = {}
467
+ self._built = False
468
+
469
+ # -- naming -------------------------------------------------------
470
+
471
+ def _fresh(self, prefix: str) -> str:
472
+ """A globally unique name ``{prefix}{n}`` — see the class docstring's
473
+ naming-collision argument in the module docstring (no two calls,
474
+ regardless of prefix, ever share a counter value)."""
475
+ self._fresh_n += 1
476
+ return f"{prefix}{self._fresh_n}"
477
+
478
+ @staticmethod
479
+ def _asp_var(kit_name: str) -> str:
480
+ """Map a kit variable name to an ASP variable name; two names never share one.
481
+
482
+ An ASP variable is an upper-case letter followed by ASCII letters, digits
483
+ and underscores. A name of ASCII letters, digits and underscores that
484
+ starts with a letter or digit (``x``, ``x0``, ``x_0``: what the grammar
485
+ and the kit's own fresh names give) is written ``"V" + name``, as it
486
+ always was. Every other name is written ``"V_" + code``, where the code
487
+ keeps ASCII letters and digits and turns each other character into an
488
+ underscore, its code point in lower-case hexadecimal and an underscore
489
+ (``é`` is ``V__e9_``, ``x-1`` is ``V_x_2d_1``). The VARIABLE terminal
490
+ admits any Unicode letter, so ``∀é P(é)`` reaches this encoder from
491
+ text; ``Vé`` is no ASP variable, and clingo's parser, handed that text,
492
+ ended the call with an error of the decoder instead of a verdict.
493
+
494
+ The two forms never meet (the second character is a letter or digit in
495
+ the first, an underscore in the second), and the code reads back one way
496
+ only (a letter or digit stands for itself, every underscore opens one
497
+ ``_<hex>_`` group), so two variable names always get two ASP variables.
498
+ The names this encoder mints itself start with ``C`` or ``F``.
499
+
500
+ Raises:
501
+ _EncodingError: the name is not a string.
502
+ """
503
+ if not isinstance(kit_name, str):
504
+ raise _EncodingError(
505
+ f"_AspEncoder: the variable name {kit_name!r} is of type "
506
+ f"{type(kit_name).__name__}, not a string, so it has no ASP variable.")
507
+ if _ASP_PLAIN_VARIABLE.fullmatch(kit_name):
508
+ return f"V{kit_name}"
509
+ return "V_" + "".join(
510
+ ch if ch.isascii() and ch.isalnum() else f"_{ord(ch):x}_" for ch in kit_name)
511
+
512
+ @staticmethod
513
+ def _format_atom(name: str, args: Sequence[str]) -> str:
514
+ """Render an applied atom, or a bare name for an empty arg list.
515
+
516
+ clingo does not accept ``name()`` for a nullary atom, so the empty
517
+ case omits the parentheses entirely rather than emitting them empty.
518
+ """
519
+ return name if not args else f"{name}({','.join(args)})"
520
+
521
+ def _call(self, asp_name: str, kit_var_names: Sequence[str]) -> str:
522
+ return self._format_atom(asp_name, [self._asp_var(n) for n in kit_var_names])
523
+
524
+ def _fv_tuple(self, node: Node) -> List[str]:
525
+ """The sorted kit variable NAMES free in ``node`` — see the module
526
+ docstring's shadowing argument for why sorted-by-name determinism
527
+ plus per-rule ASP scoping is sufficient, with no scope stack."""
528
+ return sorted({v.name for v in free_variables(node) if isinstance(v, Variable)})
529
+
530
+ def _dom_prefix(self, kit_var_names: Sequence[str]) -> List[str]:
531
+ return [f"dom({self._asp_var(n)})" for n in kit_var_names]
532
+
533
+ def _rule(self, head: str, body: List[str]) -> None:
534
+ if body:
535
+ self._lines.append(f"{head} :- {', '.join(body)}.")
536
+ else:
537
+ self._lines.append(f"{head}.")
538
+
539
+ # -- declarations ---------------------------------------------------
540
+
541
+ def _discover_comparison_relations(self) -> Set[Tuple[str, int]]:
542
+ """Every ``(symbol, arity)`` among the six comparison predicates that
543
+ must become an ORDINARY guessed relation (see :func:`_atom_mode`'s
544
+ ``"relation"`` case) — walked up front so its choice rule can be
545
+ emitted alongside every other predicate's, in one deterministic pass.
546
+ """
547
+ found: Set[Tuple[str, int]] = set()
548
+ for sentence in self.problem.sentences:
549
+ for node in sentence.walk():
550
+ if isinstance(node, Atom) and node.predicate in _COMPARISON_SYMBOLS:
551
+ if _atom_mode(node) == "relation":
552
+ found.add((node.predicate, len(node.args)))
553
+ return found
554
+
555
+ def _assign_names(self) -> None:
556
+ sig_keys = {(name, decl.arity) for name, decl in self.sig.predicates.items()}
557
+ for i, key in enumerate(sorted(sig_keys | self._discover_comparison_relations())):
558
+ self._pred_asp[key] = f"pred{i}"
559
+ for name, _decl in sorted(self.sig.functions.items()):
560
+ self._func_asp[name] = f"func{len(self._func_asp)}"
561
+ for name in sorted(self.sig.constants):
562
+ self._const_asp[name] = f"const{len(self._const_asp)}"
563
+
564
+ @staticmethod
565
+ def _choice_rule(asp_name: str, arity: int) -> str:
566
+ """A free choice over a relation's WHOLE extension.
567
+
568
+ The standard ASP idiom for "the countermodel may interpret this
569
+ predicate however it likes": ``{ p(X,Y) : dom(X), dom(Y) }``.
570
+ """
571
+ if arity == 0:
572
+ return f"{{ {asp_name} }}."
573
+ xs = [f"X{i}" for i in range(arity)]
574
+ conds = ",".join(f"dom({x})" for x in xs)
575
+ return f"{{ {asp_name}({','.join(xs)}) : {conds} }}."
576
+
577
+ @staticmethod
578
+ def _function_rule(asp_name: str, arity: int) -> str:
579
+ """The total-relation encoding: exactly one result per input tuple,
580
+ via a bounded cardinality choice ``1 { … } 1`` — enforces BOTH
581
+ functionality and totality at once (see
582
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`'s
583
+ own docstring for why both matter to the reconstruction step)."""
584
+ if arity == 0:
585
+ return f"1 {{ {asp_name}(Y) : dom(Y) }} 1."
586
+ xs = [f"X{i}" for i in range(arity)]
587
+ conds = ",".join(f"dom({x})" for x in xs)
588
+ return f"1 {{ {asp_name}({','.join(xs)},Y) : dom(Y) }} 1 :- {conds}."
589
+
590
+ @staticmethod
591
+ def _const_rule(asp_name: str) -> str:
592
+ """A constant is the arity-0 case of the function idiom above: exactly
593
+ one denotation, chosen freely."""
594
+ return f"1 {{ {asp_name}(Y) : dom(Y) }} 1."
595
+
596
+ # -- term translation -------------------------------------------------
597
+
598
+ def _term(self, node: Node) -> Tuple[str, List[str]]:
599
+ """Translate an INDIVIDUAL-denoting term to ``(asp_term, extra_body_literals)``.
600
+
601
+ ``extra_body_literals`` must be conjoined into whichever rule body
602
+ is being built around this term (a fresh constant/function-result
603
+ variable is meaningless without the literal that binds it).
604
+ """
605
+ if isinstance(node, Variable):
606
+ return self._asp_var(node.name), []
607
+ if isinstance(node, Constant):
608
+ if node.name not in self._const_asp:
609
+ raise _EncodingError(
610
+ f"_AspEncoder: constant {node.name!r} is used in a sentence "
611
+ "but is not declared in the problem's signature."
612
+ )
613
+ v = self._fresh("Cst")
614
+ return v, [f"{self._const_asp[node.name]}({v})"]
615
+ if isinstance(node, Function):
616
+ if node.name not in self._func_asp:
617
+ raise _EncodingError(
618
+ f"_AspEncoder: function {node.name!r} is used in a sentence "
619
+ "but is not declared in the problem's signature."
620
+ )
621
+ arg_terms: List[str] = []
622
+ extra: List[str] = []
623
+ for a in node.args:
624
+ t, ex = self._term(a)
625
+ arg_terms.append(t)
626
+ extra.extend(ex)
627
+ v = self._fresh("Fn")
628
+ extra.append(self._format_atom(self._func_asp[node.name], arg_terms + [v]))
629
+ return v, extra
630
+ if isinstance(node, Number):
631
+ raise _EncodingError(
632
+ f"_AspEncoder: the numeral {node.value!r} is used as an individual. A numeral "
633
+ "is a constant identified by its value (1 and 1.0 are one constant), and this "
634
+ "encoder has no symbol for it: it reads a number only as the bound a count "
635
+ "|{v : φ}| is compared with, never as a domain element, because the "
636
+ "index reading would make 1 and 2 two different elements that the kit's "
637
+ "numerals need not be. Use a solver that reads numerals as constants (z3, the "
638
+ "finite model finder), or name the individual with a constant.")
639
+ raise _EncodingError(
640
+ f"_AspEncoder: no individual-term translation for node type "
641
+ f"{type(node).__name__} (fragment_check should have rejected this "
642
+ "sentence before it reached the encoder)."
643
+ )
644
+
645
+ def _numeric_term(self, node: Node) -> Tuple[str, List[str]]:
646
+ """Translate a COUNTING term (``Cardinality`` or ``Number``) to
647
+ ``(asp_expr, extra_body_literals)`` — a natural-number VALUE, not an
648
+ individual. See :func:`_atom_mode`'s ``"numeric"`` case."""
649
+ if isinstance(node, Number):
650
+ value = node.value
651
+ if isinstance(value, float) and value.is_integer():
652
+ value = int(value) # one numeral per value: 2.0 is the bound 2
653
+ if isinstance(value, bool) or not isinstance(value, int):
654
+ raise _EncodingError(
655
+ f"_AspEncoder: Number({node.value!r}) is not a plain "
656
+ "non-negative integer — the counting fragment only "
657
+ "supports integer bounds."
658
+ )
659
+ return str(value), []
660
+ if isinstance(node, Cardinality):
661
+ inner_call = self._translate_formula(node.formula)
662
+ v = self._asp_var(node.variable.name)
663
+ c = self._fresh("Card")
664
+ agg = f"{c} = #count {{ {v} : dom({v}), {inner_call} }}"
665
+ return c, [agg]
666
+ raise _EncodingError(
667
+ f"_AspEncoder: {type(node).__name__} is not a counting term "
668
+ "(expected Cardinality or Number)."
669
+ )
670
+
671
+ # -- atom / formula translation ----------------------------------------
672
+
673
+ def _emit_atom(self, atom: Atom, head: str, dom_lits: List[str]) -> None:
674
+ mode = _atom_mode(atom)
675
+ if mode == "identity":
676
+ t1, ex1 = self._term(atom.args[0])
677
+ t2, ex2 = self._term(atom.args[1])
678
+ op = "=" if atom.predicate == "=" else "!="
679
+ self._rule(head, dom_lits + ex1 + ex2 + [f"{t1} {op} {t2}"])
680
+ return
681
+ if mode == "numeric":
682
+ t1, ex1 = self._numeric_term(atom.args[0])
683
+ t2, ex2 = self._numeric_term(atom.args[1])
684
+ op = _NUMERIC_ASP_OP[atom.predicate]
685
+ self._rule(head, dom_lits + ex1 + ex2 + [f"{t1} {op} {t2}"])
686
+ return
687
+ # mode in ("relation", "predicate"): an ordinary looked-up relation.
688
+ key = (atom.predicate, len(atom.args))
689
+ if key not in self._pred_asp:
690
+ raise _EncodingError(
691
+ f"_AspEncoder: predicate {atom.predicate!r}/{len(atom.args)} is "
692
+ "used in a sentence but is not declared in the problem's "
693
+ "signature."
694
+ )
695
+ asp_name = self._pred_asp[key]
696
+ terms: List[str] = []
697
+ extra: List[str] = []
698
+ for a in atom.args:
699
+ t, ex = self._term(a)
700
+ terms.append(t)
701
+ extra.extend(ex)
702
+ self._rule(head, dom_lits + extra + [self._format_atom(asp_name, terms)])
703
+
704
+ def _emit_quantifier(self, node: Quantifier, head: str, fv: List[str],
705
+ dom_lits: List[str]) -> None:
706
+ inner_var = self._asp_var(node.variable.name)
707
+ inner_call = self._translate_formula(node.formula)
708
+ if node.type in _FORALL:
709
+ viol = self._fresh("viol")
710
+ viol_head = self._call(viol, fv)
711
+ self._rule(viol_head, dom_lits + [f"dom({inner_var})", f"not {inner_call}"])
712
+ self._rule(head, dom_lits + [f"not {viol_head}"])
713
+ elif node.type in _EXISTS:
714
+ self._rule(head, dom_lits + [f"dom({inner_var})", inner_call])
715
+ else:
716
+ raise _EncodingError(f"_AspEncoder: unknown quantifier type {node.type!r}.")
717
+
718
+ def _translate_formula(self, node: Node) -> str:
719
+ """Translate a FORMULA node to the ASP call for its freshly minted ``satK`` atom.
720
+
721
+ Appends the atom's defining rule(s) to this encoder's line buffer as
722
+ a side effect. See the module docstring for the per-connective
723
+ encoding and the shadowing argument.
724
+ """
725
+ fv = self._fv_tuple(node)
726
+ sat_name = self._fresh("sat")
727
+ head = self._call(sat_name, fv)
728
+ dom_lits = self._dom_prefix(fv)
729
+
730
+ if isinstance(node, Atom):
731
+ self._emit_atom(node, head, dom_lits)
732
+ elif isinstance(node, Not):
733
+ inner = self._translate_formula(node.formula)
734
+ self._rule(head, dom_lits + [f"not {inner}"])
735
+ elif isinstance(node, (And, Contrast)):
736
+ left = self._translate_formula(node.left)
737
+ right = self._translate_formula(node.right)
738
+ self._rule(head, dom_lits + [left, right])
739
+ elif isinstance(node, Or):
740
+ left = self._translate_formula(node.left)
741
+ right = self._translate_formula(node.right)
742
+ self._rule(head, dom_lits + [left])
743
+ self._rule(head, dom_lits + [right])
744
+ elif isinstance(node, Xor):
745
+ left = self._translate_formula(node.left)
746
+ right = self._translate_formula(node.right)
747
+ self._rule(head, dom_lits + [left, f"not {right}"])
748
+ self._rule(head, dom_lits + [right, f"not {left}"])
749
+ elif isinstance(node, Implies):
750
+ left = self._translate_formula(node.left)
751
+ right = self._translate_formula(node.right)
752
+ self._rule(head, dom_lits + [f"not {left}"])
753
+ self._rule(head, dom_lits + [right])
754
+ elif isinstance(node, Iff):
755
+ left = self._translate_formula(node.left)
756
+ right = self._translate_formula(node.right)
757
+ self._rule(head, dom_lits + [left, right])
758
+ self._rule(head, dom_lits + [f"not {left}", f"not {right}"])
759
+ elif isinstance(node, Quantifier):
760
+ self._emit_quantifier(node, head, fv, dom_lits)
761
+ elif isinstance(node, Count):
762
+ inner_call = self._translate_formula(node.formula)
763
+ v = self._asp_var(node.variable.name)
764
+ c = self._fresh("Card")
765
+ op = {"ge": ">=", "le": "<=", "eq": "="}[node.op]
766
+ agg = f"{c} = #count {{ {v} : dom({v}), {inner_call} }}"
767
+ self._rule(head, dom_lits + [agg, f"{c} {op} {node.n.value}"])
768
+ else:
769
+ raise _EncodingError(
770
+ f"_AspEncoder: no ASP translation rule for node type "
771
+ f"{type(node).__name__} used as a formula (fragment_check "
772
+ "should have rejected this sentence before it reached the "
773
+ "encoder, or this node type is a TERM used where a formula "
774
+ "was expected)."
775
+ )
776
+ return head
777
+
778
+ def _assert_sentence(self, sentence: Node) -> None:
779
+ """Emit the top-level integrity constraint asserting ``sentence`` holds.
780
+
781
+ Shape: ``:- dom(X)…, not satRoot(X)….`` — this ALSO auto-closes any
782
+ of ``sentence``'s own free variables universally (a free ASP
783
+ variable ranging over ``dom`` inside a constraint body IS a
784
+ ∀-reading) — see the module docstring's "Refutation goal" section.
785
+ """
786
+ fv = self._fv_tuple(sentence)
787
+ dom_lits = self._dom_prefix(fv)
788
+ call = self._translate_formula(sentence)
789
+ self._lines.append(f":- {', '.join(dom_lits + [f'not {call}'])}.")
790
+
791
+ def _emit_show(self) -> None:
792
+ self._lines.append("")
793
+ # A bare "#show." disables clingo's DEFAULT "no #show directives present
794
+ # -> show every atom" behaviour, which would otherwise leak dom/1 facts
795
+ # and every satK/violK/CardK auxiliary atom into the model whenever a
796
+ # problem happens to declare NO predicate/function/constant at all
797
+ # (e.g. a sentence built only from "="/"≠" and Count/Cardinality, which
798
+ # never register a guessed relation) — reproduced and confirmed against
799
+ # clingo 5.8.1 before adding this line. Harmless when other #show
800
+ # directives follow (verified: it only suppresses the default, never
801
+ # an explicit #show that comes after it).
802
+ self._lines.append("#show.")
803
+ for key in sorted(self._pred_asp):
804
+ name, arity = key
805
+ self._lines.append(f"#show {self._pred_asp[key]}/{arity}.")
806
+ for name, decl in sorted(self.sig.functions.items()):
807
+ self._lines.append(f"#show {self._func_asp[name]}/{decl.arity + 1}.")
808
+ for name in sorted(self._const_asp):
809
+ self._lines.append(f"#show {self._const_asp[name]}/1.")
810
+
811
+ # -- public surface -----------------------------------------------------
812
+
813
+ def render(self) -> str:
814
+ """The full ASP program text for this problem (memoised)."""
815
+ if not self._built:
816
+ n = self.problem.size
817
+ self._lines.append(
818
+ f"% finite-domain refutation search: |D| = {n}, "
819
+ f"{len(self.problem.sentences)} sentence(s)"
820
+ )
821
+ self._lines.append(f"dom(0..{n - 1}).")
822
+ self._lines.append("")
823
+
824
+ self._assign_names()
825
+ for key in sorted(self._pred_asp):
826
+ self._lines.append(self._choice_rule(self._pred_asp[key], key[1]))
827
+ for name, decl in sorted(self.sig.functions.items()):
828
+ self._lines.append(self._function_rule(self._func_asp[name], decl.arity))
829
+ for name in sorted(self._const_asp):
830
+ self._lines.append(self._const_rule(self._const_asp[name]))
831
+
832
+ distinct = [n2 for n2 in sorted(self._const_asp) if n2 not in self._parameters]
833
+ if self.problem.all_different and len(distinct) >= 2:
834
+ names = [self._const_asp[n2] for n2 in distinct]
835
+ for i in range(len(names)):
836
+ for j in range(i + 1, len(names)):
837
+ self._lines.append(f":- {names[i]}(Y), {names[j]}(Y).")
838
+
839
+ self._lines.append("")
840
+ for sentence in self.problem.sentences:
841
+ self._assert_sentence(sentence)
842
+
843
+ self._emit_show()
844
+ self._built = True
845
+ return "\n".join(self._lines)
846
+
847
+ @property
848
+ def reconstruction_signature(self) -> Signature:
849
+ """The problem's own Signature, extended with every comparison symbol
850
+ this encoder registered as an ordinary guessed relation.
851
+
852
+ See :func:`_atom_mode`'s ``"relation"`` case — this extension exists
853
+ because
854
+ :meth:`~unicode_logic_kit.fol.signature.Signature.from_formulas`
855
+ deliberately never declares ``=``/``≠``/``<``/``>``/``≤``/``≥`` as
856
+ user vocabulary (they are the kit's built-in operators), yet
857
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
858
+ needs a declaration for every symbol name it is handed. Built fresh
859
+ on every access rather than cached — it is cheap (a handful of dict
860
+ entries) and this keeps the encoder's mutable build state in one
861
+ place (:meth:`render`) rather than two.
862
+ """
863
+ if not self._built:
864
+ self.render()
865
+ preds = dict(self.sig.predicates)
866
+ seen_arity: Dict[str, int] = {name: decl.arity for name, decl in self.sig.predicates.items()}
867
+ for name, arity in self._pred_asp:
868
+ if name in seen_arity:
869
+ if seen_arity[name] != arity:
870
+ raise _EncodingError(
871
+ f"_AspEncoder: comparison predicate {name!r} is used at "
872
+ f"both arity {seen_arity[name]} and arity {arity} — a "
873
+ "Signature can only declare one arity per symbol."
874
+ )
875
+ continue
876
+ seen_arity[name] = arity
877
+ preds[name] = PredicateDecl(name, arity)
878
+ return Signature(predicates=preds, functions=self.sig.functions,
879
+ constants=self.sig.constants, sorts=self.sig.sorts)
880
+
881
+ def decode_model(self, symbols) -> List[Tuple[str, Tuple[int, ...]]]:
882
+ """Turn clingo's shown :class:`clingo.Symbol` objects into the
883
+ ``(kit_name, args)`` pairs
884
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
885
+ expects. Only ``pred*``/``func*``/``const*`` names are ever shown
886
+ (see the ``#show`` directives :meth:`render` emits), so no
887
+ ``dom``/``sat*``/``viol*``/``Card*`` auxiliary atom can reach this
888
+ method at all — the filtering
889
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`'s
890
+ own docstring asks callers to do is performed by clingo's ``#show``
891
+ machinery itself, not by a second pass here.
892
+ """
893
+ reverse_pred = {asp: name for (name, _arity), asp in self._pred_asp.items()}
894
+ reverse_func = {asp: name for name, asp in self._func_asp.items()}
895
+ reverse_const = {asp: name for name, asp in self._const_asp.items()}
896
+ atoms: List[Tuple[str, Tuple[int, ...]]] = []
897
+ for sym in symbols:
898
+ asp_name = sym.name
899
+ args = tuple(a.number for a in sym.arguments)
900
+ if asp_name in reverse_pred:
901
+ atoms.append((reverse_pred[asp_name], args))
902
+ elif asp_name in reverse_func:
903
+ atoms.append((reverse_func[asp_name], args))
904
+ elif asp_name in reverse_const:
905
+ atoms.append((reverse_const[asp_name], args))
906
+ else:
907
+ raise _EncodingError(
908
+ f"_AspEncoder: clingo returned a shown atom {asp_name!r} "
909
+ "this encoder does not recognise (internal encoder / "
910
+ "#show mismatch — this indicates a bug in this module, "
911
+ "not in the input formula)."
912
+ )
913
+ return atoms
914
+
915
+
916
+ def to_asp(problem: FiniteDomainProblem) -> str:
917
+ """Render ``problem`` to ASP program text (clingo / gringo syntax).
918
+
919
+ Pure text generation — never imports or touches ``clingo`` itself, so
920
+ this can be tested, read, and pinned as a fixture with no solver
921
+ installed. See the module docstring for the encoding this produces.
922
+
923
+ Args:
924
+ problem: the finite-domain problem to encode.
925
+
926
+ Returns:
927
+ The full program text: a ``dom/1`` domain declaration, one free-choice
928
+ or total-relation rule per declared predicate/function/constant, the
929
+ compositional ``satK``/``violK`` rules for every sentence, one
930
+ top-level integrity constraint per sentence, and ``#show`` directives
931
+ naming exactly the predicate/function/constant atoms.
932
+
933
+ Raises:
934
+ TypeError: ``problem`` is not a
935
+ :class:`~unicode_logic_kit.atp.finite_domain.FiniteDomainProblem`.
936
+ ValueError: a sentence, though accepted by
937
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check`, still
938
+ cannot be lowered to ASP by this encoder (see
939
+ :class:`_EncodingError`'s docstring for the exact cases); or a
940
+ sentence has a free variable
941
+ (:func:`~unicode_logic_kit.atp.finite_domain.free_variable_reason`:
942
+ the program would read it as "every element", one sentence at a
943
+ time, which is not what a free variable means on any route of
944
+ this kit; :meth:`ClingoBackend.decide` replaces it by a parameter
945
+ before it writes).
946
+ """
947
+ encoder = _AspEncoder(problem)
948
+ reason = free_variable_reason(problem.sentences)
949
+ if reason is not None:
950
+ raise ValueError(f"to_asp: {reason}")
951
+ return encoder.render()
952
+
953
+
954
+ # =============================================================================
955
+ # Solving
956
+ # =============================================================================
957
+
958
+ def _solve(program_text: str, remaining_seconds: Optional[float]):
959
+ """Ground and solve ``program_text``, stopping at the first model.
960
+
961
+ Returns ``("sat", shown_symbols)``, ``("unsat", None)``, or
962
+ ``("timeout", None)``. ``remaining_seconds=None`` waits indefinitely
963
+ (``clingo.SolveHandle.wait(None)`` blocks until the solve genuinely
964
+ finishes — verified directly against clingo 5.8.1 rather than assumed).
965
+ A timeout cancels the async handle; ``handle.get()`` afterwards still
966
+ returns a genuine ``satisfiable``/``unsatisfiable`` result if the solve
967
+ happened to finish right around the cancellation (also verified
968
+ empirically), so only a truly interrupted search (``result.unknown``)
969
+ reports ``"timeout"``.
970
+ """
971
+ import clingo
972
+
973
+ ctl = clingo.Control(logger=lambda code, msg: None)
974
+ ctl.configuration.solve.models = 1
975
+ ctl.add("base", [], program_text)
976
+ ctl.ground([("base", [])])
977
+
978
+ captured: List[list] = []
979
+
980
+ def on_model(model) -> None:
981
+ captured.append(list(model.symbols(shown=True)))
982
+
983
+ with ctl.solve(on_model=on_model, async_=True) as handle:
984
+ finished = handle.wait(remaining_seconds)
985
+ if not finished:
986
+ handle.cancel()
987
+ result = handle.get()
988
+
989
+ if result.satisfiable:
990
+ return "sat", (captured[0] if captured else [])
991
+ if result.unsatisfiable:
992
+ return "unsat", None
993
+ return "timeout", None
994
+
995
+
996
+ # =============================================================================
997
+ # ClingoBackend
998
+ # =============================================================================
999
+
1000
+ class ClingoBackend(ProverBackend):
1001
+ """Bounded finite-model search over classical FOL plus counting, via
1002
+ clingo — refutation-only (see the module docstring's ONE-rule note).
1003
+ Many-sorted input is accepted too: :func:`~unicode_logic_kit.atp.finite_domain.lower_msfol`
1004
+ relativises it to plain classical FOL before anything else in
1005
+ :meth:`decide` runs (see the module docstring's "Many-sorted input"
1006
+ section).
1007
+
1008
+ Registered automatically by ``atp/protocol.py`` (this module does not
1009
+ touch the registry itself — see that module's bottom-of-registration
1010
+ import block).
1011
+ """
1012
+
1013
+ name = "clingo"
1014
+ logics = frozenset({"fol"})
1015
+ external = False # pip package (optional extra), not a spawned binary
1016
+
1017
+ def available(self) -> bool:
1018
+ """Pure discovery: is the ``clingo`` package importable? (No import.)"""
1019
+ return clingo_available()
1020
+
1021
+ def decide(self, formula: Node, premises: Sequence[Node] = (),
1022
+ timeout: int = 10000, **options) -> Verdict:
1023
+ """Search for a finite countermodel of ``premises ∧ ¬formula``.
1024
+
1025
+ Args:
1026
+ formula: the goal.
1027
+ premises: entailment premises (``⊨ formula`` when empty). A free
1028
+ variable of the premises and the goal is a PARAMETER, one unknown
1029
+ element shared by all of them (see the module docstring), reported
1030
+ by a countermodel as the constant of the variable's own name; a free
1031
+ variable spelled like a constant of the problem is ``UNKNOWN`` /
1032
+ ``"unsupported"``.
1033
+ timeout: milliseconds for the WHOLE search (across every domain
1034
+ size tried); ``0``/negative disables the limit, matching
1035
+ :meth:`~unicode_logic_kit.atp.cvc5_backend.Cvc5Backend.decide`'s
1036
+ own convention for the same parameter.
1037
+ **options: ``max_size`` (default ``6``) — the largest domain
1038
+ size tried, sizes ``1 … max_size`` in turn (matches
1039
+ :func:`~unicode_logic_kit.semantics.modelfinder.find_countermodel`'s
1040
+ own parameter name; a larger default than that function's
1041
+ ``4`` is reasonable here since ASP grounding/solving is far
1042
+ faster per size than plain Python enumeration). ``all_different``
1043
+ (default ``False``) — forwarded to
1044
+ :class:`~unicode_logic_kit.atp.finite_domain.FiniteDomainProblem`
1045
+ (every constant denotes a pairwise-distinct individual).
1046
+ ``verify`` (default ``True``) — whether to run every
1047
+ candidate countermodel through
1048
+ :func:`~unicode_logic_kit.atp.finite_domain.verify_model`
1049
+ before reporting ``REFUTED``; ``False`` exists ONLY for
1050
+ measuring verification overhead (see
1051
+ :mod:`~unicode_logic_kit.atp.finite_domain`'s module
1052
+ docstring) — an unverified countermodel is still marked as
1053
+ such in the returned ``Verdict.detail``, never silently.
1054
+
1055
+ Returns:
1056
+ A :class:`~unicode_logic_kit.atp.protocol.Verdict` with ``status``
1057
+ in ``{"refuted", "unknown", "error"}`` — NEVER ``"proved"`` (see
1058
+ the module docstring). ``REFUTED`` carries
1059
+ ``countermodel={"kind": "finite_structure", "data": structure.to_dict()}``.
1060
+ ``UNKNOWN`` reasons: ``"unsupported"`` (fragment gate or a
1061
+ deeper encoding-time rejection), ``"bound_hit"`` (no
1062
+ countermodel up to ``max_size``), ``"timeout"`` (the millisecond
1063
+ budget expired first). ``ERROR``/``"infra"`` covers a clingo/
1064
+ grounding failure, a solver output ``structure_from_solution``
1065
+ rejected, or (per the ONE rule) a candidate countermodel that
1066
+ failed re-verification.
1067
+ """
1068
+ max_size = options.pop("max_size", 6)
1069
+ if isinstance(max_size, bool) or not isinstance(max_size, int) or max_size < 1:
1070
+ raise ValueError(f"clingo: max_size must be an int >= 1, got {max_size!r}.")
1071
+ all_different = bool(options.pop("all_different", False))
1072
+ verify = bool(options.pop("verify", True))
1073
+
1074
+ # A free variable is a PARAMETER of the problem (see the module docstring): every
1075
+ # one is replaced HERE, once, in the premises and the goal together, by a
1076
+ # constant of its own name, and only then is the goal negated (¬∀x φ(x) is not
1077
+ # ∀x ¬φ(x)). The ASP encoding reads a free variable in a constraint as
1078
+ # implicitly ∀-bound but `evaluate_in_structure` raises for a variable with no
1079
+ # assignment, so encoder and checker must see the same parameter-free sentences;
1080
+ # this is where that is guaranteed. No premise is closed universally.
1081
+ try:
1082
+ read, parameters = parameterize(list(premises) + [formula], after_variables=True)
1083
+ except NotImplementedError as exc:
1084
+ return Verdict(UNKNOWN, self.name, reason="unsupported", detail=str(exc))
1085
+ goal = Not(read[-1])
1086
+ sentences = tuple(read[:-1]) + (goal,)
1087
+ parameter_names = frozenset(parameters)
1088
+ # Many-sorted input (SortedQuantifier/SortedConstant/SortedCount/
1089
+ # SortedCardinality) is relativised to plain classical FOL HERE, once,
1090
+ # before fragment_check ever sees it -- see
1091
+ # finite_domain.lower_msfol's own docstring and finite_domain's module
1092
+ # docstring "Many-sorted input" section. A no-op for every
1093
+ # unsorted-only caller (the pre-existing test suite): returns
1094
+ # `sentences` untouched when nothing sorted is present.
1095
+ sentences = lower_msfol(sentences)
1096
+
1097
+ problem_msg = fragment_check(sentences)
1098
+ if problem_msg is not None:
1099
+ return Verdict(UNKNOWN, self.name, reason="unsupported", detail=problem_msg)
1100
+
1101
+ try:
1102
+ signature = Signature.from_formulas(sentences)
1103
+ except (TypeError, ValueError) as exc:
1104
+ return Verdict(UNKNOWN, self.name, reason="unsupported",
1105
+ detail=f"signature inference failed: {exc}")
1106
+
1107
+ start = time.perf_counter()
1108
+ deadline = None if timeout is None or timeout <= 0 else start + timeout / 1000.0
1109
+
1110
+ for size in range(1, max_size + 1):
1111
+ if deadline is not None:
1112
+ remaining = deadline - time.perf_counter()
1113
+ if remaining <= 0:
1114
+ return Verdict(UNKNOWN, self.name, reason="timeout",
1115
+ wall_time=time.perf_counter() - start,
1116
+ detail=f"no countermodel found up to size {size - 1} "
1117
+ f"before the {timeout}ms budget expired")
1118
+ else:
1119
+ remaining = None
1120
+
1121
+ try:
1122
+ problem = FiniteDomainProblem(sentences, size, signature=signature,
1123
+ all_different=all_different)
1124
+ encoder = _AspEncoder(problem, parameter_names)
1125
+ program_text = encoder.render()
1126
+ except _EncodingError as exc:
1127
+ return Verdict(UNKNOWN, self.name, reason="unsupported",
1128
+ wall_time=time.perf_counter() - start, detail=str(exc))
1129
+
1130
+ try:
1131
+ outcome, symbols = _solve(program_text, remaining)
1132
+ except Exception as exc: # noqa: BLE001 - clingo raises plain RuntimeError/etc.
1133
+ return Verdict(ERROR, self.name, reason="infra",
1134
+ wall_time=time.perf_counter() - start,
1135
+ detail=f"{type(exc).__name__}: {exc}")
1136
+
1137
+ if outcome == "timeout":
1138
+ return Verdict(UNKNOWN, self.name, reason="timeout",
1139
+ wall_time=time.perf_counter() - start,
1140
+ detail=f"clingo did not finish size {size} within the "
1141
+ f"{timeout}ms budget")
1142
+ if outcome == "unsat":
1143
+ continue
1144
+
1145
+ # outcome == "sat"
1146
+ try:
1147
+ atoms = encoder.decode_model(symbols)
1148
+ # the distinctness of the CONSTANTS is checked here when a parameter is
1149
+ # among them: a variable may denote the element of a constant
1150
+ structure = structure_from_solution(
1151
+ encoder.reconstruction_signature, atoms, size,
1152
+ all_different=all_different and not parameter_names)
1153
+ if all_different and parameter_names:
1154
+ clash = _repeated_constant(structure.constants, parameter_names)
1155
+ if clash is not None:
1156
+ raise ValueError(clash)
1157
+ except (TypeError, ValueError) as exc:
1158
+ return Verdict(ERROR, self.name, reason="infra",
1159
+ wall_time=time.perf_counter() - start,
1160
+ detail=f"could not reconstruct clingo's own model at "
1161
+ f"size {size}: {exc}")
1162
+
1163
+ if verify:
1164
+ unverified = verify_model(structure, sentences)
1165
+ if unverified is not None:
1166
+ return Verdict(ERROR, self.name, reason="infra",
1167
+ wall_time=time.perf_counter() - start,
1168
+ detail=f"unverified countermodel at size {size}: "
1169
+ f"{unverified}")
1170
+ detail = None
1171
+ else:
1172
+ detail = "model not independently verified (verify=False)"
1173
+
1174
+ return Verdict(REFUTED, self.name, wall_time=time.perf_counter() - start,
1175
+ countermodel={"kind": "finite_structure", "data": structure.to_dict()},
1176
+ detail=detail)
1177
+
1178
+ return Verdict(UNKNOWN, self.name, reason="bound_hit",
1179
+ wall_time=time.perf_counter() - start,
1180
+ detail=f"no countermodel found up to size {max_size}")