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,1402 @@
1
+ """MiniZinc/CP finite-domain refutation search — the second finite-domain backend.
2
+
3
+ :class:`MinizincBackend` decides ``premises ⊨ φ`` the same way
4
+ :class:`~unicode_logic_kit.atp.clingo_backend.ClingoBackend` does (built
5
+ alongside this module, not in it): both ground ``premises ∧ ¬φ`` over a
6
+ bounded domain ``0 … size-1`` and search for a satisfying assignment through
7
+ :mod:`~unicode_logic_kit.atp.finite_domain`'s shared problem/verification
8
+ layer. A model at some size REFUTES ``φ``; no model up to ``max_size``
9
+ is UNKNOWN/``"bound_hit"`` — see :mod:`atp.finite_domain`'s module docstring
10
+ for the one rule neither backend may soften: **this search never returns
11
+ PROVED**. Every reconstructed countermodel is re-checked by
12
+ :func:`~unicode_logic_kit.atp.finite_domain.verify_model` before it is allowed
13
+ to leave :meth:`MinizincBackend.decide` as REFUTED — and what
14
+ :mod:`atp.finite_domain`'s module docstring used to call a "Known
15
+ verification gap" for ``Cardinality``/``Function`` is CLOSED for both now,
16
+ the same way, in two steps. ``verify_model``'s own evaluator counts
17
+ ``Cardinality`` comparisons arithmetically, so the counting fragment is
18
+ CLOSED end to end: a REFUTED verdict on e.g. ``|{x : P(x)}| > |{y : Q(y)}|``
19
+ carries a countermodel this backend has genuinely re-verified, not one that
20
+ downgrades to ERROR/``"infra"`` for want of a checker. ``Function`` is
21
+ CLOSED too, by the identical mechanism rather than by staying refused:
22
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check` — consulted by
23
+ :func:`to_minizinc` before it emits a single declaration — now ADMITS a
24
+ ``Function`` node generally (a *sorted* ``FunctionDecl`` stays refused, but
25
+ separately — see
26
+ :mod:`~unicode_logic_kit.atp.finite_domain`'s "Sorted function symbols"
27
+ section, not this gate), and ``verify_model``'s evaluator now reads
28
+ ``f(t1,...,tk)`` off the SAME ``(name, arity+1)`` total-relation extension
29
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` already
30
+ reconstructs, so a function-bearing REFUTED verdict is genuinely
31
+ re-verified too. This module's OWN function-encoding machinery — the array
32
+ declarations in :func:`to_minizinc`, :func:`_term`'s ``Function`` branch,
33
+ :func:`_atoms_from_solution`'s function-decoding loop — is consequently
34
+ REACHABLE from :meth:`MinizincBackend.decide` now, not merely kept in place
35
+ as a second line of defence — see "Encoding — arrays and generators" below,
36
+ which used to document this as dormant and now documents it as live.
37
+
38
+ Many-sorted input
39
+ -------------------
40
+ :meth:`MinizincBackend.decide` builds ``sentences = tuple(premises) +
41
+ (Not(formula),)`` and then, before ``Signature.from_formulas`` or
42
+ :func:`to_minizinc`'s own :func:`~unicode_logic_kit.atp.finite_domain.fragment_check`
43
+ call ever see it, runs the whole batch through
44
+ :func:`~unicode_logic_kit.atp.finite_domain.lower_msfol` — a no-op for every
45
+ plain-FOL caller, and for a many-sorted one a relativisation to classical
46
+ FOL (plus one non-emptiness sentence per distinct sort name) that needs no
47
+ support from this module's own renderer or output parser at all: a sort
48
+ name is, after lowering, an ordinary unary predicate like any other. See
49
+ :mod:`~unicode_logic_kit.atp.finite_domain`'s own "Many-sorted input" section
50
+ for the full design, including why the non-emptiness sentence is required
51
+ for soundness against :mod:`~unicode_logic_kit.semantics.modelfinder`, the
52
+ oracle this is differentially tested against (offline here, since MiniZinc
53
+ itself is not installed in this environment — see below).
54
+
55
+ External binary, not a Python package
56
+ --------------------------------------
57
+ The originating design note for this backend (§6) mentions ``pip install
58
+ minizinc`` — the *Python* MiniZinc bindings. This module does NOT use that
59
+ package. Per the task this module was built under, discovery instead follows
60
+ the exact convention :class:`~unicode_logic_kit.atp.protocol.Prover9Backend`
61
+ and :class:`~unicode_logic_kit.atp.protocol.VampireBackend` already use for
62
+ their own external binaries: ``$UFK_MINIZINC``, then ``PATH``
63
+ (:func:`_minizinc_binary`). The generated ``.mzn`` text is written to a temp
64
+ file and handed to the ``minizinc`` CLI directly via ``subprocess`` — one
65
+ less runtime dependency, and one fewer translation layer between this
66
+ module's own encoding and what actually gets solved. MiniZinc is NOT
67
+ installed in this repository's development environment and will not be, so
68
+ :meth:`MinizincBackend.decide`'s live subprocess path is exercised only
69
+ through :func:`minizinc_available` returning ``False`` here; what IS fully
70
+ exercised and pinned by fixtures is everything this module can control
71
+ without a solver: :func:`to_minizinc` (the renderer) and the output parser
72
+ (:func:`_parse_minizinc_solution` / :func:`_atoms_from_solution`) — the same
73
+ split :mod:`atp.eprover_backend` / :mod:`atp.twee_backend` use for tools this
74
+ environment cannot run either.
75
+
76
+ Encoding — arrays and generators, not the ASP boolean-relation reading
77
+ --------------------------------------------------------------------------
78
+ This section documents the renderer's OWN convention for a function symbol —
79
+ LIVE now, on every ordinary search that mentions one, per the module
80
+ docstring's opening: :func:`fragment_check` admits ``Function`` generally,
81
+ so :meth:`MinizincBackend.decide` reaches this code on a genuine
82
+ function-bearing sentence today, not merely on the hand-built-problem
83
+ second-line-of-defence case the sorted/arithmetic refusals below still
84
+ describe.
85
+ :mod:`atp.finite_domain`'s module docstring describes the shared "function =
86
+ total relation + functionality constraint" reconstruction that
87
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution` expects
88
+ BACK from a solver (a ``(k+1)``-tuple per function atom). That is the shape
89
+ this module's OUTPUT must produce, not necessarily the shape its INPUT
90
+ encoding has to take to produce it — and for MiniZinc the natural CP
91
+ encoding of a function symbol ``f`` of arity ``k`` is
92
+ ``array[DOM, …, DOM] of var DOM: f_name;`` (``k`` copies of ``DOM``): a
93
+ MiniZinc array is by construction both TOTAL (every index combination in its
94
+ declared index set has some value — there is no way to leave a cell
95
+ unassigned and still have a solution) and FUNCTIONAL (each cell holds
96
+ exactly one value), so it reaches the identical total-relation semantics the
97
+ ASP encoding reaches via an explicit boolean array plus an explicit
98
+ "exactly one true per input tuple" constraint, but through a solver-native
99
+ type rather than a constraint that would be redundant in this language —
100
+ exactly the reason CP is in this design at all (§11: "alldifferent for the
101
+ distinctness convention rather than the pairwise-≠ expansion"; the same
102
+ argument applies to functions, not just constants). A predicate ``p`` of
103
+ arity ``k`` is the boolean counterpart, ``array[DOM, …, DOM] of var bool:
104
+ p_name;``; a constant is ``var DOM: k_name;`` (the 0-ary case of the same
105
+ "exactly one value" reading, likewise needing no explicit "exactly one"
106
+ constraint because ``var DOM`` already ranges over exactly one value).
107
+ :func:`_atoms_from_solution` is what actually produces the ``(k+1)``-tuple
108
+ shape :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
109
+ requires, by pairing every ``DOM^k`` index tuple with the array's value
110
+ there — so the two modules' conventions meet at the atom boundary, not
111
+ inside the ``.mzn`` text itself.
112
+
113
+ Arithmetic function symbols (``+``, ``-``, ``*``, ``/``) are refused
114
+ ------------------------------------------------------------------------
115
+ ``+``/``-``/``*``/``/`` count as ``Function`` nodes, and
116
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check` now ADMITS
117
+ ``Function`` generally (see the module docstring's opening) — but these four
118
+ NAMES stay refused regardless, LIVE, on the exact path the rest of this
119
+ module's ``Function`` support now takes: :func:`_term`'s ``Function`` branch
120
+ checks ``node.name in _BUILTIN_ARITH_FUNCS`` before it ever consults
121
+ ``ctx.functions``, so an arithmetic-operator sentence is stopped inside
122
+ :func:`to_minizinc`'s own rendering loop, ``NotImplementedError``, before a
123
+ single array is declared — no longer merely a second line of defence for a
124
+ hand-built problem :func:`fragment_check` never saw, now the FIRST and only
125
+ line of defence for these four names specifically (an ordinary declared
126
+ function ``f(x)`` no longer shares this fate — see "Encoding — arrays and
127
+ generators" above). This section states, and argues below, WHICH reading of
128
+ ``+`` this module gives it now that ``Function`` support is no longer merely
129
+ hypothetical.
130
+ :meth:`~unicode_logic_kit.fol.nodes.Function.to_z3` treats ``+`` as an
131
+ UNINTERPRETED function symbol named ``"+"`` (``env.get_func(self.name,
132
+ len(self.args))`` — the same call for every function name; genuine
133
+ arithmetic only exists in the separate, unrelated
134
+ :mod:`~unicode_logic_kit.atp.z3_arith` pipeline), and
135
+ :meth:`~unicode_logic_kit.fol.signature.Signature.from_formulas` deliberately
136
+ EXCLUDES these four names from the ``functions`` section it builds (the
137
+ ``_BUILTIN_FUNCS`` split, mirroring ``eval.validate``'s identical carve-out)
138
+ — so a :class:`~unicode_logic_kit.atp.finite_domain.FiniteDomainProblem`'s
139
+ auto-derived signature never declares an array for them. Two readings were
140
+ available here: (a) silently declare one anyway, duplicating a symbol the
141
+ signature layer deliberately keeps out of user vocabulary, or (b) give ``+``
142
+ literal MiniZinc integer-arithmetic semantics (tempting, since domain
143
+ individuals ARE the integers ``0 … n-1`` already) — but that would be a
144
+ DIFFERENT reading of the identical AST node than every other export in this
145
+ kit gives it, decided unilaterally inside one backend. Per the kit's own
146
+ discipline against exactly this kind of silent semantic substitution (see
147
+ e.g. :mod:`atp.finite_domain`'s ``all_different`` footnote), this module
148
+ instead REFUSES: :func:`_term` raises ``NotImplementedError`` for these four
149
+ names — reached LIVE now through :meth:`MinizincBackend.decide` whenever a
150
+ searched sentence names one of them, since :func:`fragment_check` no longer
151
+ stops a ``Function`` node on the way in; the caller sees the identical
152
+ ``UNKNOWN``/``"unsupported"`` outcome either way, just raised one call
153
+ frame deeper than before, from inside :func:`to_minizinc`'s rendering loop
154
+ rather than at the gate — an honest gap either way, not a guess. (A
155
+ :class:`~unicode_logic_kit.fol.nodes.Number` is refused too, for its own reason —
156
+ see the next section.)
157
+
158
+ A ``Number`` is the bound of a count, never an individual
159
+ -----------------------------------------------------------
160
+ A numeral (:class:`~unicode_logic_kit.fol.nodes.Number`) has two readings in the
161
+ kit. On every route that was not asked for arithmetic it is a CONSTANT
162
+ identified by its value (``Number(1)`` and ``Number(1.0)`` are one node, one
163
+ constant; ``1 ≠ 2`` is not valid, because two constants may denote one
164
+ element), as :meth:`~unicode_logic_kit.fol.nodes.Number.to_z3` writes it. The
165
+ documented counting fragment gives it the other one: the number a cardinality
166
+ is compared with (``|{v : φ}| ≥ 3`` — a raw count, unrelated to any domain
167
+ individual). This backend's domain individuals are the integers ``0 … n-1``,
168
+ so the bare literal ``k`` would be a DOMAIN ELEMENT, the element number ``k``
169
+ (or no element at all when ``k`` is not below the size), and the kit's
170
+ numerals need not be that: ``(∀x ∀y x = y) → 1 = 2`` is valid (one element,
171
+ so ``1`` and ``2`` denote the same thing), and the index reading refuted it.
172
+
173
+ So a ``Number`` is read as a number ONLY as an operand of a comparison with a
174
+ :class:`~unicode_logic_kit.fol.nodes.Cardinality` (:func:`_number_int`: the
175
+ bound of the count, a float with a whole value being that integer). Anywhere
176
+ else — as a term of a predicate, of a function, of ``=``, or in a comparison
177
+ of numerals that has no cardinality in it — it is refused by name with
178
+ ``NotImplementedError``, which :meth:`MinizincBackend.decide` reports as
179
+ ``UNKNOWN``/``"unsupported"``: this backend has no symbol for a numeral, and
180
+ it never reads one as a domain index or as arithmetic. The same holds for a
181
+ comparison that sets a cardinality against a plain individual (the count
182
+ against the element number of a constant has no coherent reading). The
183
+ :class:`~unicode_logic_kit.atp.clingo_backend.ClingoBackend` draws the same line.
184
+
185
+ Identifier scheme — role-prefixed, ASCII-only, injective
186
+ ---------------------------------------------------------
187
+ Every declared symbol is rendered under a role prefix — predicate ``p``,
188
+ function ``f``, constant ``k``, bound variable ``v`` — rather than its
189
+ bare kit-level name, for two independent reasons. First, MiniZinc reserves a
190
+ long list of lower-case keywords (``output``, ``function``, ``predicate``,
191
+ ``array``, ``where``, ``let``, …), and while this kit's own grammar (see
192
+ ``fol/grammars/terminals.lark``) happens to keep bound VARIABLE names to a
193
+ single letter plus digits (so they can never collide with a multi-letter
194
+ keyword), it places NO such restriction on a function/constant NAME token —
195
+ a formula naming a function ``output`` would otherwise silently break the
196
+ renderer's own ``output`` item. Prefixing removes the possibility outright
197
+ rather than relying on the grammar's current shape staying that way forever.
198
+ Second, it keeps predicates, functions and constants in three textually
199
+ disjoint MiniZinc namespaces, matching how the kit's own
200
+ :class:`~unicode_logic_kit.fol.signature.Signature` already keeps them apart
201
+ conceptually.
202
+
203
+ A MiniZinc identifier is an ASCII letter followed by ASCII letters, digits and
204
+ underscores, and is not a keyword. A kit-level name need not be one: it may be
205
+ non-ASCII (Greek, e.g. ``θ``, or any other script the widened FOL grammar
206
+ accepts, e.g. ``świątek``), and since a constant may be written in quotes, and a
207
+ TPTP file may quote a predicate or a function, it may hold a space, a hyphen, a
208
+ quote, a ``+``, or any other character (``'a b'``, ``'G-910'``, ``'C++'``). So a
209
+ name is written in one of two forms, and every identifier of a model is one of
210
+ them:
211
+
212
+ * **The plain form** ``<role>_<name>``, for a name that consists only of ASCII
213
+ letters, digits and underscores once the kit's own
214
+ :func:`~unicode_logic_kit.fol._fol_nodes.constant_name_to_ascii` has folded it
215
+ (the transliteration :meth:`~unicode_logic_kit.fol.nodes.Constant.to_prover9` /
216
+ ``to_tptp`` already use: ``θ`` becomes ``theta``, another non-ASCII
217
+ character a ``uXXXX`` escape). It is the form this module always wrote, and it
218
+ stays exactly as it was for every name it was legal for, so a model that was
219
+ written before is written again byte for byte (the files under
220
+ ``tests/fixtures/minizinc/``).
221
+ * **The escaped form** ``<role>x_<code>``, for every other name. ``<code>`` is
222
+ the name with each ASCII letter and digit kept and every other character
223
+ replaced by an underscore, the code point of the character in lower-case
224
+ hexadecimal, and an underscore: ``a b`` is ``kx_a_20_b``, ``G-910`` is
225
+ ``kx_G_2d_910``, ``C++`` is ``kx_C_2b__2b_``.
226
+
227
+ The escaped form cannot meet any other identifier of a model, for three reasons.
228
+ (1) Every plain identifier has an underscore as its second character and every
229
+ escaped one has an ``x`` there (``k_a`` against ``kx_a``), so the two forms never
230
+ meet, in one role or across roles. (2) The model's own names are ``n``, ``DOM``
231
+ and the index variables ``i0``, ``i1``, … of the ``output`` item, none of which
232
+ holds an underscore, and no MiniZinc keyword holds one either, while an escaped
233
+ identifier always has one in its third position. (3) Within the escaped form the
234
+ code cannot be read two ways: a letter or digit stands for itself, and every
235
+ underscore opens exactly one ``_<hex>_`` group (an underscore of the name is
236
+ written ``_5f_``, so none appears bare), so two different names give two
237
+ different codes.
238
+
239
+ The plain form is NOT injective in general (a literal ``theta`` and the Greek
240
+ ``θ`` both fold to ``theta``; a literal ``u03d1`` and the theta symbol ``ϑ``
241
+ fold alike), and it
242
+ cannot be changed for those names without changing the identifier of a name that
243
+ was legal. :func:`to_minizinc` therefore runs the exact same collision guard
244
+ :func:`~unicode_logic_kit.atp._tptp_problem.generate_tptp_problem` already runs
245
+ for its own (different) folding function, separately for the predicate,
246
+ function, constant and variable namespaces, refusing with
247
+ ``NotImplementedError`` naming both colliding kit-level names rather than
248
+ silently merging two distinct symbols into one MiniZinc identifier. (The
249
+ variable namespace needs the guard for the same reason: two bound variables
250
+ that share an identifier would make an inner quantifier capture the outer one's
251
+ occurrences.)
252
+
253
+ Output — a hand-written wire format, not ``--output-mode json``
254
+ ---------------------------------------------------------------------
255
+ MiniZinc 2.5+ can auto-serialise a solution to JSON
256
+ (``--output-mode json``), and that would ordinarily be the easy choice. It
257
+ is deliberately NOT used here: its exact behaviour when a model has no
258
+ explicit ``output`` item — which decision variables get included, and in
259
+ what shape a multi-dimensional array is emitted — is a MiniZinc-VERSION
260
+ detail this repository has no installed binary to confirm against (see the
261
+ top of this docstring). Instead :func:`to_minizinc` writes its OWN ``output``
262
+ item using only ``show()`` and string concatenation — base-language MiniZinc
263
+ features stable across every 2.x release — that flattens every array
264
+ (predicate or function, any arity) to a ONE-DIMENSIONAL list via an explicit
265
+ index-generator comprehension (``[ f_name[i0,i1] | i0 in DOM, i1 in DOM ]``,
266
+ varying the LAST generator fastest — the same nesting order
267
+ :func:`itertools.product` produces in Python, which is what
268
+ :func:`_atoms_from_solution` uses to reverse the flattening), and tags each
269
+ line ``UFK <mzn_name> <arity> <show(...)>`` between ``UFK-SOLUTION-BEGIN`` /
270
+ ``UFK-SOLUTION-END`` sentinels. The whole format is invented, owned, and
271
+ parsed by this module alone — nothing about it depends on a MiniZinc version
272
+ or an installed solver, so it can be pinned by hand-written fixtures
273
+ (:func:`_parse_minizinc_solution`) and reasoned through line by line without
274
+ ever running ``minizinc``.
275
+ """
276
+
277
+ import itertools
278
+ import os
279
+ import re
280
+ import shutil
281
+ import subprocess
282
+ import tempfile
283
+ import time
284
+ from dataclasses import dataclass
285
+ from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
286
+
287
+ from ..fol._fol_nodes import constant_name_to_ascii
288
+ from ..fol.nodes import (
289
+ Node, Variable, Constant, Number, Function,
290
+ Atom, Not, And, Or, Xor, Implies, Iff, Contrast, Quantifier,
291
+ Count, Cardinality,
292
+ )
293
+ from ..fol.signature import Signature
294
+ from ..fol._free_parameters import parameterize
295
+ from .finite_domain import (
296
+ FiniteDomainProblem, fragment_check, free_variable_reason, lower_msfol,
297
+ structure_from_solution, verify_model,
298
+ )
299
+ from .protocol import (
300
+ BackendUnavailable, ERROR, ProverBackend, REFUTED, UNKNOWN, Verdict,
301
+ _native_command_exists,
302
+ )
303
+
304
+ __all__ = ["MinizincBackend", "minizinc_available", "to_minizinc"]
305
+
306
+
307
+ # =============================================================================
308
+ # Binary discovery
309
+ # =============================================================================
310
+
311
+ def _minizinc_binary() -> Optional[str]:
312
+ """Discover the ``minizinc`` CLI: ``$UFK_MINIZINC``, then ``PATH``.
313
+
314
+ Mirrors :meth:`~unicode_logic_kit.atp.protocol.Prover9Backend._binary` /
315
+ :meth:`~unicode_logic_kit.atp.protocol.VampireBackend._binary` exactly:
316
+ the env var is read fresh on every call (never cached, so repointing it
317
+ mid-process takes effect immediately) and there is no WSL bridging —
318
+ unlike :mod:`atp.eprover_backend`'s Linux-only tools, MiniZinc ships
319
+ native Windows/macOS/Linux installers, so there is no "only reachable
320
+ inside WSL" case to handle here.
321
+ """
322
+ return os.environ.get("UFK_MINIZINC") or shutil.which("minizinc")
323
+
324
+
325
+ def minizinc_available() -> bool:
326
+ """Pure discovery: is a ``minizinc`` binary reachable right now?
327
+
328
+ Checks ``$UFK_MINIZINC`` then ``PATH`` (see :func:`_minizinc_binary`);
329
+ never imports or spawns anything, so it is safe to call speculatively.
330
+ """
331
+ return _minizinc_binary() is not None
332
+
333
+
334
+ # =============================================================================
335
+ # Identifier scheme
336
+ # =============================================================================
337
+
338
+ # What may follow the role prefix of a plain identifier: ASCII letters, digits and
339
+ # underscores, written out as ranges so that a Unicode letter or digit never matches.
340
+ _MZN_PLAIN_TAIL = re.compile(r"[A-Za-z0-9_]*")
341
+
342
+
343
+ def _mzn_escape(name: str) -> str:
344
+ """The code of ``name`` in an escaped identifier: ASCII letters and digits stay, any other
345
+ character becomes an underscore, its code point in lower-case hexadecimal, an underscore.
346
+
347
+ Injective: a letter or digit stands for itself, and every underscore of the result
348
+ opens exactly one ``_<hex>_`` group (an underscore of ``name`` is the group ``_5f_``),
349
+ so the result reads back one way only. The result holds only ASCII letters, digits and
350
+ underscores. See the module docstring's "Identifier scheme" section.
351
+ """
352
+ return "".join(
353
+ ch if ch.isascii() and ch.isalnum() else f"_{ord(ch):x}_"
354
+ for ch in name
355
+ )
356
+
357
+
358
+ def _mzn_identifier(role: str, name: str, kind: str) -> str:
359
+ """The MiniZinc identifier of the ``kind`` symbol ``name``, written under the one-letter ``role``.
360
+
361
+ The plain form ``<role>_<name>`` when the name, folded by
362
+ :func:`~unicode_logic_kit.fol._fol_nodes.constant_name_to_ascii`, is made of ASCII letters,
363
+ digits and underscores only (the form this module always wrote, so such a name keeps its
364
+ identifier); the escaped form ``<role>x_<code>`` for every other name (see
365
+ :func:`_mzn_escape`). The two forms never meet: a plain identifier has an underscore as
366
+ its second character, an escaped one an ``x``. See the module docstring's "Identifier
367
+ scheme" section for that argument and for the one case this does not settle (two names
368
+ that fold to one plain identifier), which :func:`_mzn_names_or_raise` refuses.
369
+
370
+ Raises:
371
+ NotImplementedError: ``name`` is not a string, so it has no identifier.
372
+ """
373
+ if not isinstance(name, str):
374
+ raise NotImplementedError(
375
+ f"to_minizinc: the {kind} name {name!r} is of type {type(name).__name__}, not a "
376
+ "string, so it has no MiniZinc identifier."
377
+ )
378
+ folded = constant_name_to_ascii(name)
379
+ if _MZN_PLAIN_TAIL.fullmatch(folded):
380
+ return f"{role}_{folded}"
381
+ return f"{role}x_{_mzn_escape(name)}"
382
+
383
+
384
+ def _mzn_pred_name(name: str) -> str:
385
+ """Predicate ``name`` -> its MiniZinc identifier, ``p_<name>`` or the escaped ``px_<code>``.
386
+
387
+ A predicate NAME token is not restricted to ASCII (see ``fol/grammars/terminals.lark``:
388
+ PREDICATE admits any Unicode letter with ``str.isupper()``), and a TPTP file may quote
389
+ one that holds a space or a hyphen; MiniZinc identifiers are ASCII letters, digits and
390
+ underscores, so the name goes through :func:`_mzn_identifier`. This function does not
391
+ check for two names that share an identifier (it has no visibility into sibling
392
+ predicates); :func:`to_minizinc` does.
393
+ """
394
+ return _mzn_identifier("p", name, "predicate")
395
+
396
+
397
+ def _mzn_func_name(name: str) -> str:
398
+ """Function ``name`` -> its MiniZinc identifier, ``f_<name>`` or the escaped ``fx_<code>``.
399
+
400
+ Same reasoning and the same collision caveat as :func:`_mzn_pred_name` — see
401
+ the module docstring's "Identifier scheme" section.
402
+ """
403
+ return _mzn_identifier("f", name, "function")
404
+
405
+
406
+ def _mzn_const_name(name: str) -> str:
407
+ """Constant ``name`` -> its MiniZinc identifier, ``k_<name>`` or the escaped ``kx_<code>``.
408
+
409
+ A constant may be written in quotes, so its name may be any text (``'a b'``,
410
+ ``'G-910'``, ``'C++'``); see :func:`_mzn_identifier` and the module docstring's
411
+ "Identifier scheme" section. Two names that fold to one plain identifier (``theta`` and
412
+ ``θ``) are refused by :func:`to_minizinc`, which sees all the constants at once; this
413
+ function does not.
414
+ """
415
+ return _mzn_identifier("k", name, "constant")
416
+
417
+
418
+ def _mzn_var_name(name: str) -> str:
419
+ """Bound variable ``name`` -> its MiniZinc identifier, ``v_<name>`` or the escaped ``vx_<code>``.
420
+
421
+ The VARIABLE terminal (``fol/grammars/terminals.lark``) admits any Unicode letter, so a
422
+ raw non-ASCII variable name (e.g. ``ą``) would otherwise reach the generated ``.mzn``
423
+ text verbatim — not a legal MiniZinc identifier — and a hand-built variable may be named
424
+ anything. A bound variable's identifier is used only inside the ``forall``, ``exists`` or
425
+ comprehension generator that binds it, but two distinct variable names that fold to one
426
+ identifier would let an inner binder capture the outer one's occurrences, so
427
+ :func:`to_minizinc` runs the collision guard over the variable names too.
428
+ """
429
+ return _mzn_identifier("v", name, "variable")
430
+
431
+
432
+ # The four arithmetic operators are legal Function names, and fragment_check
433
+ # now admits Function generally (see the module docstring's opening) — but
434
+ # these four names are excluded from Signature.from_formulas's `functions`
435
+ # section (the `_BUILTIN_FUNCS` split) regardless, so _term checks this set
436
+ # BEFORE consulting ctx.functions and refuses them LIVE, reachable through
437
+ # decide() on an ordinary search now (see the module docstring's "Arithmetic
438
+ # function symbols" section) — this module still refuses to guess a reading
439
+ # for them on its own rather than pick one silently.
440
+ _BUILTIN_ARITH_FUNCS = frozenset({"+", "-", "*", "/"})
441
+
442
+ # The six built-in comparison predicates: never declared in a Signature
443
+ # (mirroring `_BUILTIN_PREDS` in fol.signature / eval.validate), always
444
+ # rendered as a direct MiniZinc infix comparison instead of an array lookup.
445
+ _COMPARISON_OPS: Dict[str, str] = {
446
+ "=": "=", "≠": "!=", "<": "<", ">": ">", "≤": "<=", "≥": ">=",
447
+ }
448
+
449
+ _COUNT_OP_TO_MZN = {"ge": ">=", "le": "<=", "eq": "="}
450
+
451
+
452
+ def _number_int(node: Number) -> int:
453
+ """Return the value of ``node``, the bound a cardinality is compared with, as a plain ``int``.
454
+
455
+ Only :func:`_formula`'s comparison branch calls this, for the ``Number`` operand of a
456
+ comparison with a :class:`~unicode_logic_kit.fol.nodes.Cardinality` (see the module
457
+ docstring's "A ``Number`` is the bound of a count, never an individual" section). A
458
+ :class:`~unicode_logic_kit.fol.nodes.Number` stores a float with a whole value as the
459
+ integer it equals, so ``2.0`` is the bound ``2``; a count cannot equal ``2.5``, so a
460
+ fractional ``Number`` — legal per the dataclass's own ``Union[int, float]`` type,
461
+ unlike :class:`~unicode_logic_kit.fol.nodes.Count`'s own ``n`` field — has no reading
462
+ and is refused rather than silently truncated.
463
+
464
+ Raises:
465
+ NotImplementedError: ``node.value`` is not a plain (non-bool) ``int``.
466
+ """
467
+ if isinstance(node.value, bool) or not isinstance(node.value, int):
468
+ raise NotImplementedError(
469
+ f"to_minizinc: Number({node.value!r}) is not an integer — a count |{{v : φ}}| "
470
+ "is never equal to a fraction, so a comparison bound that is not an integer "
471
+ "has no reading in a finite domain of individuals 0..n-1."
472
+ )
473
+ return node.value
474
+
475
+
476
+ # =============================================================================
477
+ # Rendering context
478
+ # =============================================================================
479
+
480
+ @dataclass(frozen=True)
481
+ class _Ctx:
482
+ """The name maps a single :func:`to_minizinc` call threads through rendering.
483
+
484
+ Built once per call from ``problem.signature`` (see :func:`to_minizinc`)
485
+ so :func:`_formula` / :func:`_term` never recompute a prefix or re-run
486
+ the constant collision check per node — every kit-level symbol name that
487
+ can legally appear in ``problem.sentences`` (per
488
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check`, already
489
+ consulted by :func:`to_minizinc` before this is built) has exactly one
490
+ entry here.
491
+ """
492
+
493
+ predicates: Dict[str, str]
494
+ functions: Dict[str, str]
495
+ constants: Dict[str, str]
496
+
497
+
498
+ def _term(node: Node, ctx: _Ctx) -> str:
499
+ """Render ``node`` as a MiniZinc ``int``-typed expression (a TERM position).
500
+
501
+ Dispatches on node type; every branch either returns a self-contained
502
+ expression string or raises ``NotImplementedError`` naming exactly why
503
+ (an arithmetic function symbol, a ``Number`` used as an individual, an
504
+ undeclared symbol, or a node type with no term-position reading at all — the last
505
+ should be unreachable for a ``sentences`` tuple that already passed
506
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check`, but is
507
+ checked explicitly rather than assumed).
508
+ """
509
+ if isinstance(node, Variable):
510
+ return _mzn_var_name(node.name)
511
+ if isinstance(node, Constant):
512
+ mzn = ctx.constants.get(node.name)
513
+ if mzn is None:
514
+ raise NotImplementedError(
515
+ f"to_minizinc: constant {node.name!r} is not declared in the "
516
+ "problem's signature."
517
+ )
518
+ return mzn
519
+ if isinstance(node, Number):
520
+ raise NotImplementedError(
521
+ f"to_minizinc: the numeral {node.value!r} is used as an individual. A numeral "
522
+ "is a constant identified by its value (1 and 1.0 are one constant), and this "
523
+ "backend has no symbol for it: it reads a number only as the bound a count "
524
+ "|{v : φ}| is compared with, never as a domain element, because the index "
525
+ "reading would make 1 and 2 two different elements that the kit's numerals need "
526
+ "not be. Use a solver that reads numerals as constants (z3, the finite model "
527
+ "finder), or name the individual with a constant."
528
+ )
529
+ if isinstance(node, Function):
530
+ if node.name in _BUILTIN_ARITH_FUNCS:
531
+ raise NotImplementedError(
532
+ f"to_minizinc: the arithmetic function symbol {node.name!r} "
533
+ "carries no declared arity/extension here — "
534
+ "Signature.from_formulas excludes it as a built-in, and this "
535
+ "backend refuses to guess between an uninterpreted-function "
536
+ "reading and a literal-arithmetic one rather than picking "
537
+ "either silently; see the module docstring."
538
+ )
539
+ mzn = ctx.functions.get(node.name)
540
+ if mzn is None:
541
+ raise NotImplementedError(
542
+ f"to_minizinc: function {node.name!r} is not declared in the "
543
+ "problem's signature."
544
+ )
545
+ if not node.args:
546
+ return mzn
547
+ args = ", ".join(_term(a, ctx) for a in node.args)
548
+ return f"{mzn}[{args}]"
549
+ if isinstance(node, Cardinality):
550
+ var = _mzn_var_name(node.variable.name)
551
+ body = _formula(node.formula, ctx)
552
+ return f"sum({var} in DOM)(bool2int({body}))"
553
+ raise NotImplementedError(
554
+ f"to_minizinc: {type(node).__name__} has no term-position rendering."
555
+ )
556
+
557
+
558
+ def _comparison_operands(atom: Atom, ctx: _Ctx) -> Tuple[str, str]:
559
+ """Render the two operands of the comparison ``atom`` (``= ≠ < > ≤ ≥`` at arity 2).
560
+
561
+ A comparison with a :class:`~unicode_logic_kit.fol.nodes.Cardinality` operand is a
562
+ counting comparison: both operands are then counting terms, a ``Cardinality`` (its
563
+ ``sum`` over the domain) or a ``Number`` (the bound, :func:`_number_int`). Any other
564
+ comparison compares individuals, and a ``Number`` among them is refused by
565
+ :func:`_term`.
566
+
567
+ Raises:
568
+ NotImplementedError: a numeral is compared with no cardinality in the comparison
569
+ (a statement about constants, not about counts: ``1 = 2`` is not valid, and
570
+ ``(∀x ∀y x = y) → 1 = 2`` is); a cardinality is compared with a plain
571
+ individual (no coherent reading); or an operand is refused by :func:`_term` /
572
+ :func:`_number_int`.
573
+ """
574
+ has_cardinality = any(isinstance(a, Cardinality) for a in atom.args)
575
+ has_numeral = any(isinstance(a, Number) for a in atom.args)
576
+ if not has_cardinality and not has_numeral:
577
+ return _term(atom.args[0], ctx), _term(atom.args[1], ctx)
578
+ if not has_cardinality:
579
+ raise NotImplementedError(
580
+ f"to_minizinc: {atom.predicate!r} compares a numeral without a cardinality. A numeral "
581
+ "is a constant identified by its value, and nothing else is known about it, so such "
582
+ "a comparison is a statement about constants ('1 ≠ 2' is not valid, "
583
+ "'(∀x ∀y x = y) → 1 = 2' is), which this backend does not state: it reads a number "
584
+ "only as the bound a count |{v : φ}| is compared with, and a refutation found under "
585
+ "the index reading would be wrong for the constants. Use a solver that reads numerals "
586
+ "as constants (z3, the finite model finder), or compare a cardinality."
587
+ )
588
+ if not all(isinstance(a, (Cardinality, Number)) for a in atom.args):
589
+ raise NotImplementedError(
590
+ f"to_minizinc: {atom.predicate!r} compares a cardinality against a plain "
591
+ "individual-denoting term — both sides of a counting comparison must themselves be "
592
+ "counting terms (a Cardinality or a Number)."
593
+ )
594
+ left, right = (str(_number_int(a)) if isinstance(a, Number) else _term(a, ctx) for a in atom.args)
595
+ return left, right
596
+
597
+
598
+ def _formula(node: Node, ctx: _Ctx) -> str:
599
+ """Render ``node`` as a MiniZinc ``bool``-typed expression (a FORMULA position).
600
+
601
+ Mirrors :func:`_term`'s dispatch/refusal discipline; see that function's
602
+ docstring for the shared conventions (undeclared symbols, unreachable
603
+ node types).
604
+ """
605
+ if isinstance(node, Atom):
606
+ if node.predicate in _COMPARISON_OPS:
607
+ if len(node.args) != 2:
608
+ raise NotImplementedError(
609
+ f"to_minizinc: comparison predicate {node.predicate!r} "
610
+ f"used with arity {len(node.args)}, expected 2."
611
+ )
612
+ left, right = _comparison_operands(node, ctx)
613
+ return f"({left} {_COMPARISON_OPS[node.predicate]} {right})"
614
+ mzn = ctx.predicates.get(node.predicate)
615
+ if mzn is None:
616
+ raise NotImplementedError(
617
+ f"to_minizinc: predicate {node.predicate!r} is not declared "
618
+ "in the problem's signature."
619
+ )
620
+ if not node.args:
621
+ return mzn
622
+ args = ", ".join(_term(a, ctx) for a in node.args)
623
+ return f"{mzn}[{args}]"
624
+ if isinstance(node, Not):
625
+ return f"(not {_formula(node.formula, ctx)})"
626
+ if isinstance(node, And):
627
+ return f"({_formula(node.left, ctx)} /\\ {_formula(node.right, ctx)})"
628
+ if isinstance(node, Or):
629
+ return f"({_formula(node.left, ctx)} \\/ {_formula(node.right, ctx)})"
630
+ if isinstance(node, Xor):
631
+ return f"({_formula(node.left, ctx)} xor {_formula(node.right, ctx)})"
632
+ if isinstance(node, Implies):
633
+ return f"({_formula(node.left, ctx)} -> {_formula(node.right, ctx)})"
634
+ if isinstance(node, Iff):
635
+ return f"({_formula(node.left, ctx)} <-> {_formula(node.right, ctx)})"
636
+ if isinstance(node, Contrast):
637
+ # Truth-functionally And — see Contrast's own docstring in _fol_nodes.py.
638
+ return f"({_formula(node.left, ctx)} /\\ {_formula(node.right, ctx)})"
639
+ if isinstance(node, Quantifier):
640
+ var = _mzn_var_name(node.variable.name)
641
+ body = _formula(node.formula, ctx)
642
+ if node.type in ("forall", "∀"):
643
+ return f"forall({var} in DOM)({body})"
644
+ if node.type in ("exists", "∃"):
645
+ return f"exists({var} in DOM)({body})"
646
+ raise NotImplementedError(f"to_minizinc: unknown quantifier type {node.type!r}.")
647
+ if isinstance(node, Count):
648
+ var = _mzn_var_name(node.variable.name)
649
+ body = _formula(node.formula, ctx)
650
+ count_expr = f"sum({var} in DOM)(bool2int({body}))"
651
+ return f"({count_expr} {_COUNT_OP_TO_MZN[node.op]} {node.n.value})"
652
+ raise NotImplementedError(
653
+ f"to_minizinc: {type(node).__name__} has no formula-position "
654
+ "rendering (fragment_check should have refused this before "
655
+ "to_minizinc was reached)."
656
+ )
657
+
658
+
659
+ # =============================================================================
660
+ # to_minizinc — the renderer
661
+ # =============================================================================
662
+
663
+ def _flatten_expr(mzn_name: str, arity: int) -> str:
664
+ """Return a MiniZinc expression flattening ``mzn_name`` to one dimension.
665
+
666
+ Arity 0 is the bare variable itself; arity >= 1 is an explicit
667
+ index-generator comprehension ``[ mzn_name[i0, i1, …] | i0 in DOM, i1 in
668
+ DOM, … ]`` that varies the LAST generator fastest — the same order
669
+ :func:`itertools.product` iterates in Python, which is what
670
+ :func:`_atoms_from_solution` relies on to invert this flattening. See the
671
+ module docstring's "Output" section for why the ``output`` item is
672
+ written this way instead of via ``--output-mode json``.
673
+ """
674
+ if arity == 0:
675
+ return f"show({mzn_name})"
676
+ idx_vars = [f"i{k}" for k in range(arity)]
677
+ generators = ", ".join(f"{v} in DOM" for v in idx_vars)
678
+ index = ", ".join(idx_vars)
679
+ return f"show([{mzn_name}[{index}] | {generators}])"
680
+
681
+
682
+ def _mzn_names_or_raise(
683
+ names: Iterable[str], namer: Callable[[str], str], kind: str,
684
+ ) -> Dict[str, str]:
685
+ """Map each raw ``name`` in ``names`` to ``namer(name)``, refusing a same-namespace
686
+ collision (two distinct kit-level ``kind`` symbols that get the same MiniZinc identifier).
687
+
688
+ Shared by :func:`to_minizinc`'s predicate, function, constant and variable
689
+ namespaces. The only names that can collide are two that fold to one plain
690
+ identifier (``theta`` and ``θ``; see the module docstring's "Identifier scheme"
691
+ section): an escaped identifier is different for every name and never equals a
692
+ plain one.
693
+ """
694
+ out: Dict[str, str] = {}
695
+ seen: Dict[str, str] = {}
696
+ for name in sorted(names):
697
+ mzn = namer(name)
698
+ prior = seen.get(mzn)
699
+ if prior is not None and prior != name:
700
+ # NotImplementedError, not ValueError: this mirrors
701
+ # atp._tptp_problem.generate_tptp_problem's IDENTICAL collision
702
+ # guard for the (differently-folded) TPTP export, which raises
703
+ # the same type for the same reason -- an export whose folding
704
+ # is not injective for this particular pair of names is an
705
+ # EXPORT LIMITATION, not a malformed FiniteDomainProblem, and
706
+ # using the same exception type lets MinizincBackend.decide's
707
+ # existing "fragment this backend cannot encode" catch site
708
+ # handle it without a second, redundant except clause.
709
+ raise NotImplementedError(
710
+ f"to_minizinc: distinct {kind}s {prior!r} and {name!r} "
711
+ f"would both render as the MiniZinc identifier {mzn!r} "
712
+ "(the ASCII folding of a name, constant_name_to_ascii, is not injective) — refusing to "
713
+ "silently merge two distinct symbols; rename one of them "
714
+ "before exporting this problem. Mirrors "
715
+ "atp._tptp_problem.generate_tptp_problem's identical "
716
+ "collision guard for the TPTP export."
717
+ )
718
+ seen[mzn] = name
719
+ out[name] = mzn
720
+ return out
721
+
722
+
723
+ def _bound_variable_names(sentences: Iterable[Node]) -> List[str]:
724
+ """Every variable name that ``sentences`` write: occurrences, and the variable a
725
+ quantifier, a count or a cardinality binds (a binder is written even when its body
726
+ does not use the variable)."""
727
+ names = set()
728
+ for sentence in sentences:
729
+ for node in sentence.walk():
730
+ if isinstance(node, Variable):
731
+ names.add(node.name)
732
+ elif isinstance(node, (Quantifier, Count, Cardinality)):
733
+ names.add(node.variable.name)
734
+ return sorted(names)
735
+
736
+
737
+ def to_minizinc(problem: FiniteDomainProblem) -> str:
738
+ """Render ``problem`` as a complete MiniZinc model — the text of an ``mzn`` file.
739
+
740
+ The model declares ``DOM = 0..size-1``, one array (predicate/function) or
741
+ scalar (constant) per declared symbol in ``problem.signature``, an
742
+ ``alldifferent`` constraint over the constants iff ``problem.all_different``
743
+ and at least one constant is declared, one ``constraint`` per sentence in
744
+ ``problem.sentences``, ``solve satisfy;``, and a custom ``output`` item
745
+ (see the module docstring's "Output" section) that
746
+ :func:`_parse_minizinc_solution` / :func:`_atoms_from_solution` read back
747
+ on the other side of a solver run. Deterministic: symbols are declared
748
+ in sorted-name order within each section, so two calls on
749
+ structurally-equal problems byte-for-byte agree — the property a
750
+ checked-in fixture comparison in a test suite relies on.
751
+
752
+ Args:
753
+ problem: the search problem; ``problem.sentences`` must already pass
754
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check` (this
755
+ function calls it itself, first, so a caller does not have to
756
+ remember to).
757
+
758
+ Returns:
759
+ The model text, newline-terminated.
760
+
761
+ Raises:
762
+ TypeError: ``problem`` is not a
763
+ :class:`~unicode_logic_kit.atp.finite_domain.FiniteDomainProblem`.
764
+ NotImplementedError: ``problem.sentences`` fails
765
+ :func:`~unicode_logic_kit.atp.finite_domain.fragment_check`; a
766
+ sentence has a free variable
767
+ (:func:`~unicode_logic_kit.atp.finite_domain.free_variable_reason`;
768
+ :meth:`MinizincBackend.decide` replaces it by a parameter before
769
+ it writes); a
770
+ sentence uses a node this renderer itself cannot place (an
771
+ arithmetic function symbol, a
772
+ :class:`~unicode_logic_kit.fol.nodes.Number` that is not the bound of a
773
+ comparison with a cardinality or is not an integer, or a symbol absent
774
+ from ``problem.signature`` — see :func:`_term` / :func:`_formula`);
775
+ a symbol name is not a string; or two distinct predicates,
776
+ functions, constants or bound variables would get the same
777
+ MiniZinc identifier (two names that fold to one plain identifier,
778
+ ``theta`` and ``θ``; see the module docstring's "Identifier
779
+ scheme" section — raised as ``NotImplementedError``, not
780
+ ``ValueError``, to match
781
+ :func:`~unicode_logic_kit.atp._tptp_problem.generate_tptp_problem`'s
782
+ identical collision guard and so it is caught by the same
783
+ "cannot encode this fragment" site in
784
+ :meth:`MinizincBackend.decide`). A name that is no MiniZinc
785
+ identifier by itself (a quoted constant ``'a b'``) is not a
786
+ reason to raise: it is written in its escaped form.
787
+ """
788
+ if not isinstance(problem, FiniteDomainProblem):
789
+ raise TypeError(
790
+ f"to_minizinc: expected a FiniteDomainProblem, got "
791
+ f"{type(problem).__name__}."
792
+ )
793
+ reason = fragment_check(problem.sentences)
794
+ if reason is None:
795
+ # A free variable would be written as an identifier the model never declares.
796
+ reason = free_variable_reason(problem.sentences)
797
+ if reason is not None:
798
+ raise NotImplementedError(f"to_minizinc: {reason}")
799
+
800
+ sig = problem.signature
801
+
802
+ pred_names = _mzn_names_or_raise(sig.predicates, _mzn_pred_name, "predicate")
803
+ func_names = _mzn_names_or_raise(sig.functions, _mzn_func_name, "function")
804
+ const_names = _mzn_names_or_raise(sig.constants, _mzn_const_name, "constant")
805
+ # The identifier of a bound variable is not stored (see _term), so only the guard runs.
806
+ _mzn_names_or_raise(_bound_variable_names(problem.sentences), _mzn_var_name, "variable")
807
+
808
+ needs_alldifferent = bool(problem.all_different and const_names)
809
+
810
+ lines: List[str] = []
811
+ if needs_alldifferent:
812
+ lines.append('include "alldifferent.mzn";')
813
+ lines.append("")
814
+ lines.append(
815
+ "% Generated by unicode_logic_kit.atp.minizinc_backend.to_minizinc --"
816
+ )
817
+ lines.append(
818
+ f"% bounded refutation search, |D| = {problem.size}, "
819
+ f"{len(problem.sentences)} sentence(s) to satisfy simultaneously."
820
+ )
821
+ lines.append(
822
+ "% REFUTATION-ONLY: SATISFIABLE here witnesses a finite countermodel;"
823
+ )
824
+ lines.append(
825
+ "% UNSATISFIABLE proves nothing about validity at a larger domain size."
826
+ )
827
+ lines.append("")
828
+ lines.append(f"int: n = {problem.size};")
829
+ lines.append("set of int: DOM = 0..n-1;")
830
+ lines.append("")
831
+
832
+ for name in sorted(sig.predicates):
833
+ decl = sig.predicates[name]
834
+ mzn = pred_names[name]
835
+ if decl.arity == 0:
836
+ lines.append(f"var bool: {mzn};")
837
+ else:
838
+ dims = ", ".join(["DOM"] * decl.arity)
839
+ lines.append(f"array[{dims}] of var bool: {mzn};")
840
+ if sig.predicates:
841
+ lines.append("")
842
+
843
+ for name in sorted(sig.functions):
844
+ decl = sig.functions[name]
845
+ mzn = func_names[name]
846
+ if decl.arity == 0:
847
+ lines.append(f"var DOM: {mzn};")
848
+ else:
849
+ dims = ", ".join(["DOM"] * decl.arity)
850
+ lines.append(f"array[{dims}] of var DOM: {mzn};")
851
+ if sig.functions:
852
+ lines.append("")
853
+
854
+ for name in sorted(const_names):
855
+ lines.append(f"var DOM: {const_names[name]};")
856
+ if const_names:
857
+ lines.append("")
858
+
859
+ if needs_alldifferent:
860
+ names = [const_names[n] for n in sorted(const_names)]
861
+ lines.append(f"constraint alldifferent([{', '.join(names)}]);")
862
+ lines.append("")
863
+
864
+ ctx = _Ctx(predicates=pred_names, functions=func_names, constants=const_names)
865
+ for i, sentence in enumerate(problem.sentences, start=1):
866
+ lines.append(f"constraint {_formula(sentence, ctx)}; % sentence {i}")
867
+ lines.append("")
868
+ lines.append("solve satisfy;")
869
+ lines.append("")
870
+
871
+ output_parts: List[str] = ['"UFK-SOLUTION-BEGIN\\n"']
872
+ for name in sorted(sig.predicates):
873
+ decl = sig.predicates[name]
874
+ mzn = pred_names[name]
875
+ expr = _flatten_expr(mzn, decl.arity)
876
+ output_parts.append(f'"UFK {mzn} {decl.arity} " ++ {expr} ++ "\\n"')
877
+ for name in sorted(sig.functions):
878
+ decl = sig.functions[name]
879
+ mzn = func_names[name]
880
+ expr = _flatten_expr(mzn, decl.arity)
881
+ output_parts.append(f'"UFK {mzn} {decl.arity} " ++ {expr} ++ "\\n"')
882
+ for name in sorted(const_names):
883
+ mzn = const_names[name]
884
+ output_parts.append(f'"UFK {mzn} 0 " ++ show({mzn}) ++ "\\n"')
885
+ output_parts.append('"UFK-SOLUTION-END\\n"')
886
+
887
+ lines.append("output [")
888
+ lines.append(" " + ",\n ".join(output_parts))
889
+ lines.append("];")
890
+ lines.append("")
891
+
892
+ return "\n".join(lines) + "\n"
893
+
894
+
895
+ # =============================================================================
896
+ # Output parsing — the other half of the fixture-testable pair
897
+ # =============================================================================
898
+
899
+ _MznValue = Union[bool, int, list]
900
+
901
+
902
+ def _parse_mzn_value(text: str) -> _MznValue:
903
+ """Parse one ``show()``-rendered MiniZinc scalar or flat list.
904
+
905
+ Handles exactly the three shapes :func:`to_minizinc`'s ``output`` item
906
+ can ever produce: ``"true"``/``"false"`` (a bool scalar — a nullary
907
+ predicate, or one entry of a flattened predicate array), a bare integer
908
+ (an int scalar — a constant, a nullary function, or one entry of a
909
+ flattened function array), or a bracketed comma-separated list of either
910
+ (``"[true, false, true]"`` / ``"[0, 1, 1]"``) — the flattened form
911
+ :func:`_flatten_expr` emits for arity >= 1. Never recurses into nested
912
+ brackets: :func:`to_minizinc` never emits one (every array is flattened
913
+ to exactly one dimension before ``show()`` sees it), so a nested bracket
914
+ here would mean the text did not come from this module's own renderer.
915
+
916
+ Raises:
917
+ ValueError: ``text`` is not one of the three recognised shapes.
918
+ """
919
+ if text == "true":
920
+ return True
921
+ if text == "false":
922
+ return False
923
+ if text.startswith("[") and text.endswith("]"):
924
+ inner = text[1:-1].strip()
925
+ if not inner:
926
+ return []
927
+ return [_parse_mzn_value(part.strip()) for part in inner.split(",")]
928
+ try:
929
+ return int(text)
930
+ except ValueError:
931
+ raise ValueError(f"_parse_mzn_value: unrecognised MiniZinc literal {text!r}.")
932
+
933
+
934
+ _UFK_BEGIN = "UFK-SOLUTION-BEGIN"
935
+ _UFK_END = "UFK-SOLUTION-END"
936
+ _UFK_PREFIX = "UFK "
937
+
938
+
939
+ def _parse_minizinc_solution(stdout: str) -> Optional[Dict[str, _MznValue]]:
940
+ """Extract this module's own UFK-tagged solution block from ``stdout``.
941
+
942
+ Returns ``None`` when no ``UFK-SOLUTION-BEGIN`` / ``UFK-SOLUTION-END``
943
+ pair is present at all — the caller's signal that ``minizinc`` did not
944
+ reach the ``output`` item (an ``=====UNSATISFIABLE=====`` /
945
+ ``=====UNKNOWN=====`` run, or an infra failure the caller checks for
946
+ separately). Otherwise returns ``{mzn_name: parsed_value}`` for every
947
+ ``UFK <mzn_name> <arity> <value>`` line between the sentinels (the
948
+ ``<arity>`` field is not otherwise used here — :func:`_atoms_from_solution`
949
+ already knows each symbol's arity from ``signature`` itself — but is kept
950
+ in the wire format as a cheap, human-readable sanity anchor when reading
951
+ a captured transcript by eye).
952
+
953
+ Args:
954
+ stdout: the ``minizinc`` subprocess's captured standard output.
955
+
956
+ Returns:
957
+ The parsed solution dict, or ``None`` if no solution block is present.
958
+ """
959
+ if _UFK_BEGIN not in stdout or _UFK_END not in stdout:
960
+ return None
961
+ block = stdout[stdout.index(_UFK_BEGIN):stdout.index(_UFK_END)]
962
+ solution: Dict[str, _MznValue] = {}
963
+ for line in block.splitlines():
964
+ line = line.strip()
965
+ if not line.startswith(_UFK_PREFIX):
966
+ continue
967
+ rest = line[len(_UFK_PREFIX):]
968
+ mzn_name, _, tail = rest.partition(" ")
969
+ _arity_str, _, value_str = tail.partition(" ")
970
+ solution[mzn_name] = _parse_mzn_value(value_str.strip())
971
+ return solution
972
+
973
+
974
+ def _atoms_from_solution(
975
+ signature: Signature, solution: Dict[str, _MznValue], size: int,
976
+ ) -> List[Tuple[str, Tuple[int, ...]]]:
977
+ """Translate a parsed ``UFK`` solution dict into the shared atom shape.
978
+
979
+ The counterpart of :func:`to_minizinc`'s declaration section: for every
980
+ predicate/function/constant ``signature`` declares, looks up its MiniZinc
981
+ identifier (via the same :func:`_mzn_pred_name` / :func:`_mzn_func_name`
982
+ / :func:`_mzn_const_name` helpers :func:`to_minizinc` used, so the two
983
+ directions cannot silently drift apart), and expands a flattened array
984
+ value back into ``(index_tuple, value)`` pairs via
985
+ ``itertools.product(range(size), repeat=arity)`` — the identical
986
+ generator order :func:`_flatten_expr`'s comprehension emits in the
987
+ ``.mzn`` text (last generator fastest), so the ``k``-th list entry lines
988
+ up with the ``k``-th index tuple.
989
+
990
+ Produces exactly the ``(symbol_name, args)`` pairs
991
+ :func:`~unicode_logic_kit.atp.finite_domain.structure_from_solution`
992
+ expects, INCLUDING the ``(k+1)``-tuple total-relation shape for a
993
+ function of arity ``k`` — this function does the pairing;
994
+ ``structure_from_solution`` does the functionality/totality validation
995
+ (deliberately not duplicated here — see that function's own docstring).
996
+
997
+ Args:
998
+ signature: the problem's declared vocabulary.
999
+ solution: the dict :func:`_parse_minizinc_solution` produced.
1000
+ size: the domain size the solution was searched at.
1001
+
1002
+ Returns:
1003
+ The atom list, in predicate/function/constant, then sorted-name,
1004
+ order (order is not semantically significant to the caller, but
1005
+ deterministic order keeps this function's own tests reproducible).
1006
+
1007
+ Raises:
1008
+ ValueError: a declared symbol's MiniZinc identifier is missing from
1009
+ ``solution``, or its value has the wrong shape (not a bool/list
1010
+ of bools for a predicate, not an int/list of ints for a
1011
+ function/constant, or a list of the wrong length for ``size``
1012
+ and the symbol's arity).
1013
+ """
1014
+ atoms: List[Tuple[str, Tuple[int, ...]]] = []
1015
+
1016
+ def _expect(mzn_name: str) -> _MznValue:
1017
+ if mzn_name not in solution:
1018
+ raise ValueError(
1019
+ f"_atoms_from_solution: the minizinc solution is missing "
1020
+ f"{mzn_name!r} (declared by the problem's signature)."
1021
+ )
1022
+ return solution[mzn_name]
1023
+
1024
+ for name in sorted(signature.predicates):
1025
+ decl = signature.predicates[name]
1026
+ mzn = _mzn_pred_name(name)
1027
+ value = _expect(mzn)
1028
+ if decl.arity == 0:
1029
+ if not isinstance(value, bool):
1030
+ raise ValueError(
1031
+ f"_atoms_from_solution: predicate {name!r} (arity 0) "
1032
+ f"expects a bool, got {value!r}."
1033
+ )
1034
+ if value:
1035
+ atoms.append((name, ()))
1036
+ continue
1037
+ if not isinstance(value, list):
1038
+ raise ValueError(
1039
+ f"_atoms_from_solution: predicate {name!r} (arity "
1040
+ f"{decl.arity}) expects a flat list, got {value!r}."
1041
+ )
1042
+ index_tuples = list(itertools.product(range(size), repeat=decl.arity))
1043
+ if len(value) != len(index_tuples):
1044
+ raise ValueError(
1045
+ f"_atoms_from_solution: predicate {name!r} expects "
1046
+ f"{len(index_tuples)} entries (size={size}, arity="
1047
+ f"{decl.arity}), got {len(value)}."
1048
+ )
1049
+ for idx_tuple, truth in zip(index_tuples, value):
1050
+ if not isinstance(truth, bool):
1051
+ raise ValueError(
1052
+ f"_atoms_from_solution: predicate {name!r} entry "
1053
+ f"{idx_tuple} expects a bool, got {truth!r}."
1054
+ )
1055
+ if truth:
1056
+ atoms.append((name, idx_tuple))
1057
+
1058
+ for name in sorted(signature.functions):
1059
+ decl = signature.functions[name]
1060
+ mzn = _mzn_func_name(name)
1061
+ value = _expect(mzn)
1062
+ if decl.arity == 0:
1063
+ if not isinstance(value, int) or isinstance(value, bool):
1064
+ raise ValueError(
1065
+ f"_atoms_from_solution: function {name!r} (arity 0) "
1066
+ f"expects an int, got {value!r}."
1067
+ )
1068
+ atoms.append((name, (value,)))
1069
+ continue
1070
+ if not isinstance(value, list):
1071
+ raise ValueError(
1072
+ f"_atoms_from_solution: function {name!r} (arity "
1073
+ f"{decl.arity}) expects a flat list, got {value!r}."
1074
+ )
1075
+ index_tuples = list(itertools.product(range(size), repeat=decl.arity))
1076
+ if len(value) != len(index_tuples):
1077
+ raise ValueError(
1078
+ f"_atoms_from_solution: function {name!r} expects "
1079
+ f"{len(index_tuples)} entries (size={size}, arity="
1080
+ f"{decl.arity}), got {len(value)}."
1081
+ )
1082
+ for idx_tuple, result in zip(index_tuples, value):
1083
+ if not isinstance(result, int) or isinstance(result, bool):
1084
+ raise ValueError(
1085
+ f"_atoms_from_solution: function {name!r} entry "
1086
+ f"{idx_tuple} expects an int result, got {result!r}."
1087
+ )
1088
+ atoms.append((name, idx_tuple + (result,)))
1089
+
1090
+ for name in sorted(signature.constants):
1091
+ mzn = _mzn_const_name(name)
1092
+ value = _expect(mzn)
1093
+ if not isinstance(value, int) or isinstance(value, bool):
1094
+ raise ValueError(
1095
+ f"_atoms_from_solution: constant {name!r} expects an int, "
1096
+ f"got {value!r}."
1097
+ )
1098
+ atoms.append((name, (value,)))
1099
+
1100
+ return atoms
1101
+
1102
+
1103
+ # =============================================================================
1104
+ # Subprocess plumbing
1105
+ # =============================================================================
1106
+
1107
+ def _environment_for(binary: str) -> Optional[Dict[str, str]]:
1108
+ """The environment to run ``binary`` in: the caller's, with the binary's own
1109
+ folder (and its ``bin`` subfolder) put first on ``PATH``.
1110
+
1111
+ MiniZinc starts its solver as a second program (``bin/fzn-gecode``), and on
1112
+ Windows that program loads libraries that lie next to ``minizinc.exe``. An
1113
+ installer puts that folder on ``PATH``; a binary reached only through
1114
+ ``$UFK_MINIZINC`` has no such entry, and the bundled default solver then
1115
+ ends at once with ``=====ERROR=====`` and an empty error stream (measured
1116
+ with MiniZinc 2.8.4: Gecode fails that way, Chuffed does not). ``None``
1117
+ (inherit the environment unchanged) when ``binary`` is a bare command
1118
+ name, which the shell resolved through ``PATH`` already.
1119
+ """
1120
+ folder = os.path.dirname(binary)
1121
+ if not folder:
1122
+ return None
1123
+ env = dict(os.environ)
1124
+ own = [folder, os.path.join(folder, "bin")]
1125
+ env["PATH"] = os.pathsep.join(own + [env.get("PATH", "")])
1126
+ return env
1127
+
1128
+
1129
+ def _run_minizinc(
1130
+ model_path: str, binary: str, solver: str, time_limit_ms: int,
1131
+ ) -> Tuple[str, str, bool]:
1132
+ """Run ``minizinc`` on ``model_path``; return ``(stdout, stderr, timed_out)``.
1133
+
1134
+ ``--time-limit`` (milliseconds) is MiniZinc's OWN search budget, so a
1135
+ solver that hits it exits cleanly with ``=====UNKNOWN=====`` in its
1136
+ stdout rather than being killed — the Python-side ``subprocess.run``
1137
+ timeout is set ten seconds beyond that as a hard backstop against a
1138
+ solver that ignores its own budget (mirrors
1139
+ :mod:`atp.eprover_backend`'s ``_run_tptp_prover`` doing the identical
1140
+ thing for E's ``--cpu-limit``), not the primary timeout mechanism.
1141
+ """
1142
+ args = [binary, "--solver", solver, "--time-limit", str(time_limit_ms), model_path]
1143
+ try:
1144
+ result = subprocess.run(
1145
+ args, capture_output=True, text=True,
1146
+ timeout=(time_limit_ms / 1000.0) + 10,
1147
+ env=_environment_for(binary),
1148
+ )
1149
+ return result.stdout or "", result.stderr or "", False
1150
+ except subprocess.TimeoutExpired:
1151
+ return "", "", True
1152
+
1153
+
1154
+ # =============================================================================
1155
+ # MinizincBackend
1156
+ # =============================================================================
1157
+
1158
+ class MinizincBackend(ProverBackend):
1159
+ """Bounded CP finite-domain refutation search via the ``minizinc`` CLI.
1160
+
1161
+ Registry name ``"minizinc"``. Refutation-only, per the module docstring
1162
+ and :mod:`~unicode_logic_kit.atp.finite_domain`'s own "ONE RULE" — searches
1163
+ domain sizes ``1 … max_size`` in turn for a model of ``premises ∧ ¬φ``;
1164
+ the first one found is independently re-verified
1165
+ (:func:`~unicode_logic_kit.atp.finite_domain.verify_model`) and returned as
1166
+ REFUTED (or, if verification fails, ERROR/``"infra"`` — never a
1167
+ countermodel this module could not confirm itself). Exhausting every
1168
+ size without a model is UNKNOWN/``"bound_hit"``; running out of the
1169
+ ``timeout`` budget partway through the size sweep is UNKNOWN/``"timeout"``
1170
+ — the two are DELIBERATELY different ``reason`` values (see
1171
+ :mod:`~unicode_logic_kit.atp.protocol`'s module docstring on why
1172
+ ``bound_hit`` and ``timeout`` must never be conflated): only the former
1173
+ means "the whole search space up to max_size was actually covered".
1174
+ """
1175
+
1176
+ name = "minizinc"
1177
+ logics = frozenset({"fol"})
1178
+ external = True
1179
+
1180
+ def available(self) -> bool:
1181
+ """Pure discovery: see :func:`minizinc_available`."""
1182
+ return minizinc_available()
1183
+
1184
+ def available_for(self, options: dict) -> bool:
1185
+ """Whether the binary the run will use is there: ``minizinc_path=`` when the
1186
+ call names one (it must resolve to something the run can start, as an explicit
1187
+ path or as a name on ``PATH``; on Windows the installed ``minizinc.exe`` may be
1188
+ named without its extension, as ``subprocess`` starts it), else the discovery
1189
+ of :func:`minizinc_available` (``$UFK_MINIZINC``, then ``PATH``).
1190
+ :meth:`decide` reads ``minizinc_path=`` before it looks anywhere else, so a
1191
+ call that names a working binary is answered even where nothing is
1192
+ discoverable, and a call that names a missing one is refused here, by name,
1193
+ instead of failing inside the run."""
1194
+ path = options.get("minizinc_path")
1195
+ if path:
1196
+ return _native_command_exists(path)
1197
+ return self.available()
1198
+
1199
+ def decide(self, formula: Node, premises: Sequence[Node] = (),
1200
+ timeout: int = 10000, **options) -> Verdict:
1201
+ """Decide ``premises ⊨ formula`` by CP finite-domain refutation search.
1202
+
1203
+ Args:
1204
+ formula: the goal.
1205
+ premises: entailment premises (validity search when empty). A
1206
+ free variable of the premises or the goal is a parameter:
1207
+ one unknown element, the same in all of them, as in
1208
+ ``ClingoBackend.decide``. With ``all_different=True`` such
1209
+ a problem is answered ``unknown`` / ``unsupported``.
1210
+ timeout: milliseconds, the TOTAL wall-clock budget across every
1211
+ domain size attempted (not per-size — a size that starts
1212
+ with little budget left gets little budget, and the search
1213
+ stops rather than overrunning; see the class docstring's
1214
+ ``bound_hit`` vs ``timeout`` distinction).
1215
+ **options: ``max_size`` (default ``4``, matching
1216
+ :class:`~unicode_logic_kit.atp.protocol.ModelFinderBackend`'s
1217
+ own default so a differential test between the two can use
1218
+ matching bounds out of the box) — the largest domain size
1219
+ tried; ``solver`` (default ``"gecode"``, MiniZinc's bundled
1220
+ default) — the ``--solver`` id passed to the CLI;
1221
+ ``all_different`` (default ``False``) — forwarded to
1222
+ :class:`~unicode_logic_kit.atp.finite_domain.FiniteDomainProblem`;
1223
+ ``minizinc_path`` overrides binary discovery (see
1224
+ :func:`_minizinc_binary`).
1225
+
1226
+ Returns:
1227
+ A :class:`~unicode_logic_kit.atp.protocol.Verdict`. ``status`` is
1228
+ NEVER ``"proved"`` (see the class docstring). REFUTED carries
1229
+ ``countermodel = {"kind": "finite_structure", "data":
1230
+ structure.to_dict()}`` (round-trippable via
1231
+ :func:`~unicode_logic_kit.semantics.structures.structure_from_dict`
1232
+ — fixing the bare-``repr`` weakness
1233
+ :class:`~unicode_logic_kit.atp.protocol.ModelFinderBackend` has).
1234
+
1235
+ Raises:
1236
+ BackendUnavailable: no ``minizinc`` binary is reachable (see
1237
+ :func:`_minizinc_binary`) — mirrors
1238
+ :class:`~unicode_logic_kit.atp.protocol.Prover9Backend` /
1239
+ :class:`~unicode_logic_kit.atp.protocol.VampireBackend`
1240
+ raising this directly from ``decide()`` as a defensive
1241
+ re-check, not only from :func:`~unicode_logic_kit.atp.protocol.run_backend`.
1242
+ """
1243
+ binary = options.pop("minizinc_path", None) or _minizinc_binary()
1244
+ if binary is None:
1245
+ raise BackendUnavailable(
1246
+ "minizinc: no binary found (set $UFK_MINIZINC or put "
1247
+ "'minizinc' on PATH) — see "
1248
+ "unicode_logic_kit/atp/minizinc_backend.py for the acquisition "
1249
+ "path."
1250
+ )
1251
+ max_size = options.pop("max_size", 4)
1252
+ solver = options.pop("solver", "gecode")
1253
+ all_different = options.pop("all_different", False)
1254
+
1255
+ # A free variable is a parameter of the problem: one unknown element, the same in
1256
+ # the premises and the goal. Each is replaced here, in all of them together, by a
1257
+ # constant of its own name, and only then is the goal negated (¬∀x φ(x) is not
1258
+ # ∀x ¬φ(x)); a countermodel reports the element under the variable's name. No
1259
+ # premise is closed universally: P(x) does not entail P(alpha).
1260
+ try:
1261
+ read, parameters = parameterize(list(premises) + [formula], after_variables=True)
1262
+ except NotImplementedError as exc:
1263
+ return Verdict(UNKNOWN, self.name, reason="unsupported", detail=str(exc))
1264
+ if all_different and parameters:
1265
+ # The constants are pairwise distinct under this option, a parameter may equal
1266
+ # any of them, and the model states distinctness over every constant it declares.
1267
+ return Verdict(
1268
+ UNKNOWN, self.name, reason="unsupported",
1269
+ detail=("minizinc: all_different=True with the free variable"
1270
+ f"{'s' if len(parameters) > 1 else ''} "
1271
+ f"{', '.join(repr(name) for name in sorted(parameters))}: a parameter "
1272
+ "is not one of the pairwise distinct constants, and this route cannot "
1273
+ "leave it out of the distinctness constraint. Bind the variable or "
1274
+ "drop all_different."))
1275
+ sentences: Tuple[Node, ...] = tuple(read[:-1]) + (Not(read[-1]),)
1276
+ # Many-sorted input is relativised to plain classical FOL HERE, once,
1277
+ # before Signature.from_formulas / to_minizinc's own fragment_check
1278
+ # call ever see it -- see finite_domain.lower_msfol's own docstring
1279
+ # and this module's "Many-sorted input" section. A no-op for every
1280
+ # unsorted-only caller (the pre-existing test suite): returns
1281
+ # `sentences` untouched when nothing sorted is present.
1282
+ sentences = lower_msfol(sentences)
1283
+
1284
+ try:
1285
+ signature = Signature.from_formulas(sentences)
1286
+ except (TypeError, ValueError) as exc:
1287
+ return Verdict(UNKNOWN, self.name, reason="unsupported",
1288
+ detail=f"could not derive a signature: {exc}")
1289
+
1290
+ start = time.perf_counter()
1291
+ budget_s = max(0.001, timeout / 1000.0)
1292
+
1293
+ for size in range(1, max_size + 1):
1294
+ elapsed = time.perf_counter() - start
1295
+ remaining_s = budget_s - elapsed
1296
+ if remaining_s <= 0:
1297
+ return Verdict(
1298
+ UNKNOWN, self.name, reason="timeout", wall_time=elapsed,
1299
+ detail=f"time budget exhausted before size {size} "
1300
+ f"(max_size={max_size})",
1301
+ )
1302
+
1303
+ try:
1304
+ problem = FiniteDomainProblem(
1305
+ sentences, size, signature=signature,
1306
+ all_different=all_different,
1307
+ )
1308
+ except (TypeError, ValueError) as exc:
1309
+ return Verdict(UNKNOWN, self.name, reason="unsupported",
1310
+ wall_time=time.perf_counter() - start,
1311
+ detail=str(exc))
1312
+
1313
+ try:
1314
+ model_text = to_minizinc(problem)
1315
+ except NotImplementedError as exc:
1316
+ # The same NotImplementedError would recur at every size (it
1317
+ # depends only on `sentences`, never on `size`), so there is
1318
+ # no point looping further — return immediately.
1319
+ return Verdict(UNKNOWN, self.name, reason="unsupported",
1320
+ wall_time=time.perf_counter() - start, detail=str(exc))
1321
+
1322
+ time_limit_ms = max(1, int(remaining_s * 1000))
1323
+ tmp_path = None
1324
+ try:
1325
+ with tempfile.NamedTemporaryFile(
1326
+ mode="w", suffix=".mzn", delete=False, encoding="utf-8",
1327
+ ) as tmp:
1328
+ tmp.write(model_text)
1329
+ tmp_path = tmp.name
1330
+ stdout, stderr, timed_out = _run_minizinc(
1331
+ tmp_path, binary, solver, time_limit_ms)
1332
+ except OSError as exc:
1333
+ return Verdict(ERROR, self.name, reason="infra",
1334
+ wall_time=time.perf_counter() - start,
1335
+ detail=f"{type(exc).__name__}: {exc}")
1336
+ finally:
1337
+ if tmp_path is not None:
1338
+ try:
1339
+ os.unlink(tmp_path)
1340
+ except OSError:
1341
+ pass
1342
+
1343
+ if timed_out:
1344
+ return Verdict(
1345
+ UNKNOWN, self.name, reason="timeout",
1346
+ wall_time=time.perf_counter() - start,
1347
+ detail=f"minizinc did not finish size {size} within the "
1348
+ "time budget",
1349
+ )
1350
+ if "=====UNSATISFIABLE=====" in stdout:
1351
+ continue
1352
+ if "=====UNKNOWN=====" in stdout:
1353
+ return Verdict(
1354
+ UNKNOWN, self.name, reason="timeout",
1355
+ wall_time=time.perf_counter() - start,
1356
+ detail=f"minizinc's own --time-limit expired searching "
1357
+ f"size {size} without determining satisfiability",
1358
+ )
1359
+
1360
+ solution = _parse_minizinc_solution(stdout)
1361
+ if solution is None:
1362
+ return Verdict(
1363
+ ERROR, self.name, reason="infra",
1364
+ wall_time=time.perf_counter() - start,
1365
+ detail="minizinc produced no recognised status or "
1366
+ f"solution at size {size}: stdout="
1367
+ f"{stdout[-500:]!r} stderr={stderr[-500:]!r}",
1368
+ )
1369
+ try:
1370
+ atoms = _atoms_from_solution(problem.signature, solution, size)
1371
+ structure = structure_from_solution(
1372
+ problem.signature, atoms, size,
1373
+ all_different=problem.all_different,
1374
+ )
1375
+ except (TypeError, ValueError) as exc:
1376
+ return Verdict(
1377
+ ERROR, self.name, reason="infra",
1378
+ wall_time=time.perf_counter() - start,
1379
+ detail="could not reconstruct a structure from "
1380
+ f"minizinc's solution: {exc}",
1381
+ )
1382
+ verify_reason = verify_model(structure, sentences)
1383
+ if verify_reason is not None:
1384
+ return Verdict(
1385
+ ERROR, self.name, reason="infra",
1386
+ wall_time=time.perf_counter() - start,
1387
+ detail="minizinc reported a solution that failed "
1388
+ "independent verification -- refusing to report "
1389
+ f"REFUTED: {verify_reason}",
1390
+ )
1391
+ return Verdict(
1392
+ REFUTED, self.name, wall_time=time.perf_counter() - start,
1393
+ countermodel={"kind": "finite_structure", "data": structure.to_dict()},
1394
+ detail=f"finite countermodel of size {size} found by "
1395
+ f"minizinc (solver={solver})",
1396
+ )
1397
+
1398
+ return Verdict(
1399
+ UNKNOWN, self.name, reason="bound_hit",
1400
+ wall_time=time.perf_counter() - start,
1401
+ detail=f"no countermodel up to size {max_size}",
1402
+ )