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,668 @@
1
+ """Exact column generation for ``nilsson.entailment_bounds(strategy="column_generation")``.
2
+
3
+ :func:`~unicode_logic_kit.prob.nilsson.entailment_bounds`'s default (``"direct"``)
4
+ strategy materialises one Z3 ``Real`` per possible world — ``2^n`` of them for
5
+ ``n`` distinct atoms — which is exact but explicitly bounded by ``max_atoms``
6
+ (12 by default) precisely because that blow-up is real. This module answers
7
+ the SAME linear program without ever building that array: it grows a small
8
+ ``columns`` list of worlds ON DEMAND, deciding which world to add next by
9
+ SOLVING a small optimization problem (the "pricing subproblem") rather than
10
+ by enumerating all ``2^n`` candidates and checking each — so its cost per
11
+ iteration does not grow with ``2^n`` for a wide problem (a Z3 Boolean-SAT
12
+ search over the ``n`` atoms), and it never runs into ``max_atoms`` at all. It
13
+ is bounded instead by ``max_columns`` (500 by default): if optimality is not
14
+ certified within that many columns, :func:`solve` raises ``ValueError``
15
+ rather than ever returning an unproven bound, exactly the kit's "refuse
16
+ loudly" contract for every other bounded search.
17
+
18
+ **The LP, in row form.** Every :class:`~unicode_logic_kit.prob.nilsson.ProbConstraint`
19
+ becomes two ``≥`` rows over the SAME variables ``nilsson.entailment_bounds``'s
20
+ direct path uses (one nonnegative ``p_w`` per world ``w``, ``Σ p_w = 1``):
21
+ an unconditional ``lower ≤ P(φ) ≤ upper`` becomes
22
+ ``Σ_w 1{w⊨φ}·p_w ≥ lower`` and ``Σ_w (−1{w⊨φ})·p_w ≥ −upper``; a conditional
23
+ ``lower ≤ P(φ|ψ) ≤ upper`` (Nilsson's linear form, see ``nilsson``'s module
24
+ docstring) becomes ``Σ_w (1{w⊨φ∧ψ} − lower·1{w⊨ψ})·p_w ≥ 0`` and
25
+ ``Σ_w (upper·1{w⊨ψ} − 1{w⊨φ∧ψ})·p_w ≥ 0``. Every row is thus a *linear
26
+ functional of the world* ``w`` — represented here as an :class:`_Row`, which
27
+ carries that functional TWICE: once as a Python closure over an exact
28
+ :class:`~fractions.Fraction` valuation (``coeff_of``, used to add a *known*
29
+ world as a new LP column and to evaluate any world EXACTLY) and once as a Z3
30
+ expression over per-atom :func:`z3.Bool` variables (``z3_coeff``, used by the
31
+ pricing subproblem to search for an *unknown* world maximizing a linear
32
+ combination of these functionals — see :func:`_price`).
33
+
34
+ **What is trusted: nothing the optimiser says.** Z3's ``Optimize`` is not a
35
+ certificate: on a pricing objective built from ``If`` terms it has been
36
+ measured to return a world that is not a maximiser, with a value that is not
37
+ the value of the world it returned, and different answers for identical
38
+ inputs in one process. Every stop of the loops below therefore rests on
39
+ something that is checked exactly, never on an optimiser's report:
40
+
41
+ * a world handed back by pricing is a column only if its reduced cost,
42
+ recomputed with :class:`~fractions.Fraction` arithmetic from ``coeff_of``,
43
+ is positive (the optimiser's own value is not read at all);
44
+ * "no world improves" is established by exhaustive exact evaluation of every
45
+ world when ``n`` is at most :data:`_EXHAUSTIVE_PRICING_MAX_ATOMS`, and
46
+ otherwise by an UNSATISFIABLE :class:`z3.Solver` query "some world has a
47
+ positive reduced cost" — a decision procedure, which can answer ``unsat``
48
+ only when no such world exists. The optimiser only proposes a world to try
49
+ first; when the proposal does not improve, the solver query is asked and
50
+ either supplies an improving world or proves there is none;
51
+ * the dual ``(y, λ)`` the optimiser returns is re-checked exactly against
52
+ every column (``y ≥ 0`` and every dual row satisfied), and the primal
53
+ witness is re-checked exactly (nonnegative, sums to 1, every row holds);
54
+ * a bound is returned only when the exact value of the primal witness EQUALS
55
+ the exact objective of a dual that no world violates (below); an
56
+ optimiser's answer that fails any of these checks ends in ``ValueError``,
57
+ never in a bound.
58
+
59
+ **Two-phase column generation, and its termination/optimality proof.**
60
+ :func:`solve` runs PHASE 1 once (find any world-set whose convex hull meets
61
+ every row, or PROVE none exists — the ``ValueError`` today's direct method
62
+ also raises) and then PHASE 2 twice (minimize, then maximize, ``P(conclusion)``
63
+ over that now-feasible restricted master, each free to add further columns).
64
+ Both phases share one mechanism:
65
+
66
+ 1. *Restricted primal*: the SAME LP :func:`~unicode_logic_kit.prob.nilsson.entailment_bounds`
67
+ solves, but only over the CURRENT ``columns`` (a genuine feasible point of
68
+ the FULL LP too — the other worlds implicitly have ``p_w = 0``). Solved
69
+ with :class:`z3.Optimize`, exactly like ``nilsson._solve``; the distribution
70
+ it returns is re-verified, and its value is recomputed, exactly.
71
+ 2. *Explicit dual*: Z3's ``Optimize`` exposes no shadow-price/dual accessor
72
+ (verified: ``dir(z3.Optimize())`` has none), so the dual LP of that SAME
73
+ restricted primal is derived BY HAND (standard LP duality: one dual
74
+ variable ``y_i ≥ 0`` per ``≥`` row, one free dual ``λ`` for the ``Σp=1``
75
+ row) and solved as its OWN small :class:`z3.Optimize` call — see
76
+ :func:`_solve_dual`. Whatever it returns is a dual-FEASIBLE point for the
77
+ columns once the exact re-check passes.
78
+ 3. *Pricing subproblem*: does some world ``w*`` — not necessarily already in
79
+ ``columns`` — violate ``(y, λ)``'s dual feasibility, i.e. is
80
+ ``Σ_i y_i·coeff_i(w*) + λ − cost(w*) > 0``? If such a ``w*`` exists, its
81
+ column improves the restricted primal (the classical "positive reduced
82
+ cost" test) and is added. ``columns`` has grown by exactly one WORLD NOT
83
+ ALREADY PRESENT: the dual was just verified feasible for every column, so
84
+ no column in ``columns`` has a positive reduced cost.
85
+ 4. *Termination*: because ``columns`` only grows and there are only finitely
86
+ many worlds (``2^n``), the loop cannot run forever — it must reach either
87
+ ``max_columns`` (raise, per the brake above) or a state where NO world
88
+ violates ``(y, λ)``.
89
+ 5. *Optimality, when pricing finds nothing*: ``(y, λ)`` is then dual-feasible
90
+ for the FULL problem too (every world, not just ``columns``, satisfies its
91
+ constraint) — so by WEAK duality, its dual objective ``Σ_i b_i·y_i + λ`` is
92
+ a valid bound on the TRUE (all-``2^n``-columns) primal optimum: for any
93
+ feasible distribution ``p``, ``cost(p) ≥ Σ_w (Σ_i y_i·coeff_i(w) + λ)·p_w
94
+ ≥ Σ_i y_i·b_i + λ``. The restricted primal's witness is ALSO a feasible
95
+ point of the true primal, so its value bounds the optimum from the other
96
+ side. The two numbers are compared EXACTLY: when they are equal, the common
97
+ value is the true (``2^n``-column) primal optimum, with no gap, and the
98
+ witness attains it. (By STRONG duality of the restricted LP they ARE equal
99
+ whenever the optimiser solved both restricted programs correctly; when
100
+ they differ, the optimiser erred on one of them and the programs are
101
+ solved again, a bounded number of times, before :func:`solve` raises
102
+ ``ValueError`` — it never returns a value it could not certify.) This is
103
+ the proof obligation :func:`solve`'s own docstring, and
104
+ ``tests/test_nilsson_colgen.py``, hold this module to.
105
+
106
+ **Feasibility (phase 1) via a Farkas certificate.** The restricted primal can
107
+ itself be infeasible for a small seed ``columns`` (a single arbitrary world
108
+ essentially never satisfies every constraint's exact bounds on its own).
109
+ Phase 1 asks a :class:`z3.Solver` whether the rows can be met by some
110
+ distribution over ``columns``, and accepts a model only after checking it
111
+ exactly. If not, it solves the dual of the phase-1 LP (one nonnegative
112
+ artificial ``s_i`` relaxing each row, hence ``0 ≤ y_i ≤ 1``, cost 0 for every
113
+ ``p_w``): a vector ``(y, λ)`` with ``Σ_i y_i·coeff_i(w) + λ ≤ 0`` for every
114
+ column and ``Σ_i b_i·y_i + λ > 0`` (checked exactly). Such a vector shows the
115
+ RESTRICTED rows infeasible; pricing then looks for a world that violates the
116
+ vector, and if there is none (the exhaustive or the unsatisfiable-query check
117
+ above) the very same vector shows that NO distribution over ANY set of worlds
118
+ meets every row (Farkas): for a feasible ``p``, ``Σ_i b_i·y_i + λ ≤ Σ_w
119
+ (Σ_i y_i·coeff_i(w) + λ)·p_w ≤ 0``, contradicting ``> 0``. The constraint set is
120
+ then probabilistically inconsistent, and :func:`solve` raises the identical
121
+ ``ValueError`` :func:`~unicode_logic_kit.prob.nilsson._infeasible_error` builds
122
+ for the direct strategy. An optimiser's word that "nothing is feasible" is
123
+ never what that verdict rests on.
124
+
125
+ Internal module (leading underscore): nothing here is re-exported from
126
+ :mod:`unicode_logic_kit.prob`. Reached only through
127
+ ``entailment_bounds(..., strategy="column_generation")`` — see that
128
+ function's docstring for the public contract — or directly, by
129
+ ``tests/test_nilsson_colgen.py``, for white-box checks (the explicit witness
130
+ distribution, the dual construction) the public API does not expose.
131
+ """
132
+
133
+ from dataclasses import dataclass
134
+ from fractions import Fraction
135
+ from itertools import product
136
+ from typing import Callable, Dict, List, Optional, Sequence, Tuple
137
+
138
+ import z3
139
+
140
+ from ..fol.nodes import And, Node
141
+ from .nilsson import (
142
+ ProbConstraint,
143
+ _eval,
144
+ _infeasible_error,
145
+ _to_z3_bool,
146
+ _z3_to_fraction,
147
+ )
148
+
149
+ __all__: List[str] = [] # internal module; see this file's own module docstring
150
+
151
+ #: Up to this many atoms the pricing step evaluates every world exactly (at most
152
+ #: ``2**6 = 64`` of them); beyond it a world is searched for with Z3 and the absence of an
153
+ #: improving world is proved by an unsatisfiable solver query. Read at call time.
154
+ _EXHAUSTIVE_PRICING_MAX_ATOMS = 6
155
+
156
+ #: How often the restricted LPs are solved again when the exact primal value and the exact
157
+ #: dual objective disagree (they cannot, if the optimiser solved both correctly).
158
+ _CERTIFICATION_ATTEMPTS = 3
159
+
160
+ _World = Dict[str, bool]
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Rows: one linear functional of a world, represented two ways at once.
165
+ # ---------------------------------------------------------------------------
166
+
167
+ @dataclass(frozen=True)
168
+ class _Row:
169
+ """One ``≥`` row of the LP: ``Σ_w coeff_of(w)·p_w ≥ rhs``.
170
+
171
+ ``coeff_of`` evaluates the row's coefficient at a CONCRETE world (a
172
+ ``Dict[str, bool]`` valuation) — used when a world already chosen (by
173
+ :func:`_price`, or the phase-1 seed) is added to the restricted master or
174
+ its dual, and to evaluate any world exactly. ``z3_coeff`` builds the SAME
175
+ coefficient as a Z3 rational expression over per-atom :func:`z3.Bool`
176
+ variables — used only inside :func:`_price`, which searches for an UNKNOWN
177
+ world maximizing a linear combination of these, without enumerating any.
178
+ """
179
+
180
+ coeff_of: Callable[[_World], Fraction]
181
+ z3_coeff: Callable[[Dict[str, "z3.BoolRef"]], "z3.ArithRef"]
182
+ rhs: Fraction
183
+ label: str
184
+
185
+
186
+ def _indicator(formula: Node, valuation: _World) -> Fraction:
187
+ """``1`` if ``valuation ⊨ formula`` else ``0``, as an exact Fraction."""
188
+ return Fraction(1) if _eval(formula, valuation) else Fraction(0)
189
+
190
+
191
+ def _z3_indicator(formula: Node, atom_vars: Dict[str, "z3.BoolRef"]) -> "z3.ArithRef":
192
+ """The Z3-expression counterpart of :func:`_indicator`: ``If(formula, 1, 0)``."""
193
+ return z3.If(_to_z3_bool(formula, atom_vars), z3.RealVal(1), z3.RealVal(0))
194
+
195
+
196
+ def _build_rows(constraints: Sequence[ProbConstraint]) -> List[_Row]:
197
+ """Turn every :class:`ProbConstraint` into its two ``≥`` rows (see the module docstring)."""
198
+ rows: List[_Row] = []
199
+ for c in constraints:
200
+ if c.given is None:
201
+ formula, lower, upper = c.formula, c.lower, c.upper
202
+ rows.append(_Row(
203
+ coeff_of=lambda val, f=formula: _indicator(f, val),
204
+ z3_coeff=lambda av, f=formula: _z3_indicator(f, av),
205
+ rhs=lower,
206
+ label=f"P({formula.to_unicode_str()}) >= {lower}",
207
+ ))
208
+ rows.append(_Row(
209
+ coeff_of=lambda val, f=formula: -_indicator(f, val),
210
+ z3_coeff=lambda av, f=formula: -_z3_indicator(f, av),
211
+ rhs=-upper,
212
+ label=f"P({formula.to_unicode_str()}) <= {upper}",
213
+ ))
214
+ else:
215
+ conj = And(c.formula, c.given)
216
+ given, lower, upper = c.given, c.lower, c.upper
217
+ rows.append(_Row(
218
+ coeff_of=lambda val, conj=conj, given=given, lower=lower: (
219
+ _indicator(conj, val) - lower * _indicator(given, val)),
220
+ z3_coeff=lambda av, conj=conj, given=given, lower=lower: (
221
+ _z3_indicator(conj, av) - z3.RealVal(lower) * _z3_indicator(given, av)),
222
+ rhs=Fraction(0),
223
+ label=f"P({c.formula.to_unicode_str()}|{given.to_unicode_str()}) >= {lower} (conditional)",
224
+ ))
225
+ rows.append(_Row(
226
+ coeff_of=lambda val, conj=conj, given=given, upper=upper: (
227
+ upper * _indicator(given, val) - _indicator(conj, val)),
228
+ z3_coeff=lambda av, conj=conj, given=given, upper=upper: (
229
+ z3.RealVal(upper) * _z3_indicator(given, av) - _z3_indicator(conj, av)),
230
+ rhs=Fraction(0),
231
+ label=f"P({c.formula.to_unicode_str()}|{given.to_unicode_str()}) <= {upper} (conditional)",
232
+ ))
233
+ return rows
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Exact arithmetic on what the optimiser returns.
238
+ # ---------------------------------------------------------------------------
239
+
240
+ def _unverified_error(what: str) -> ValueError:
241
+ """The ``ValueError`` raised when an answer of Z3 fails the exact re-check made on it.
242
+
243
+ No bound is returned in that case: a value that could not be certified is refused, in
244
+ the same way every other bounded search of the kit refuses.
245
+ """
246
+ return ValueError(
247
+ "entailment_bounds: column generation could not certify its answer — "
248
+ f"{what}. No bound is returned rather than an unverified one; retry, or use "
249
+ "strategy='direct' when the number of atoms allows it."
250
+ )
251
+
252
+
253
+ def _reduced_cost(rows: Sequence[_Row], y: Sequence[Fraction], lam: Fraction,
254
+ cost_of: Callable[[_World], Fraction], world: _World) -> Fraction:
255
+ """``Σ_i y_i·coeff_i(w) + λ − cost(w)``, computed exactly (positive: the dual is violated at ``w``)."""
256
+ total = lam - cost_of(world)
257
+ for yi, row in zip(y, rows):
258
+ if yi != 0:
259
+ total += yi * row.coeff_of(world)
260
+ return total
261
+
262
+
263
+ def _dual_value(rows: Sequence[_Row], y: Sequence[Fraction], lam: Fraction) -> Fraction:
264
+ """The dual objective ``Σ_i rhs_i·y_i + λ``, exactly."""
265
+ return lam + sum((row.rhs * yi for yi, row in zip(y, rows)), Fraction(0))
266
+
267
+
268
+ def _distribution_is_exact(rows: Sequence[_Row], columns: Sequence[_World],
269
+ weights: Sequence[Fraction]) -> bool:
270
+ """``True`` iff ``weights`` (one per column) are, exactly, a distribution meeting every row."""
271
+ if any(p < 0 for p in weights) or sum(weights, Fraction(0)) != 1:
272
+ return False
273
+ for row in rows:
274
+ total = sum((row.coeff_of(w) * p for w, p in zip(columns, weights) if p != 0), Fraction(0))
275
+ if total < row.rhs:
276
+ return False
277
+ return True
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # The restricted master's dual, and the pricing subproblem.
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def _solve_dual(rows: Sequence[_Row], columns: Sequence[_World],
285
+ cost_of: Callable[[_World], Fraction],
286
+ y_upper_bound: Optional[Fraction] = None) -> Tuple[List[Fraction], Fraction]:
287
+ """Solve the EXPLICIT dual of the restricted primal over ``columns``.
288
+
289
+ Restricted primal: ``min Σ_w cost_of(w)·p_w`` s.t. ``Σ_w coeff_i(w)·p_w
290
+ ≥ rhs_i`` for each row (dual ``y_i ≥ 0``), ``Σ_w p_w = 1`` (dual ``λ``
291
+ free), ``p_w ≥ 0``, over ``w`` in ``columns`` only. Its dual: ``max
292
+ Σ_i rhs_i·y_i + λ`` s.t. ``Σ_i coeff_i(w)·y_i + λ ≤ cost_of(w)`` for
293
+ every ``w`` in ``columns`` (never the full ``2^n`` — that is exactly what
294
+ makes this the RESTRICTED dual), ``y_i ≥ 0`` [``≤ y_upper_bound`` when
295
+ given — phase 1's artificial variables each contribute exactly this
296
+ extra per-row dual bound, see :func:`_phase1`], ``λ`` free.
297
+
298
+ A fresh :class:`z3.Optimize` every call, per this module's own docstring
299
+ (point 2). ``y = 0, λ = 0`` is always a dual-feasible point (``0 ≤
300
+ cost_of(w)`` — true since every objective in this module is a nonnegative
301
+ combination of indicators), and the restricted primal is feasible and
302
+ bounded, so the dual is feasible and bounded too.
303
+
304
+ What comes back is checked exactly before it is returned: every ``y_i``
305
+ within its bounds and every dual row satisfied at every column, in
306
+ :class:`~fractions.Fraction` arithmetic. An answer that fails the check
307
+ (or no answer at all) ends in ``ValueError``; the dual is never trusted on
308
+ the optimiser's word.
309
+ """
310
+ m = len(rows)
311
+ y = [z3.Real(f"nilsson_cg!y_{i}") for i in range(m)]
312
+ lam = z3.Real("nilsson_cg!lambda")
313
+ coefficients = [[row.coeff_of(w) for row in rows] for w in columns]
314
+ costs = [cost_of(w) for w in columns]
315
+ opt = z3.Optimize()
316
+ for yi in y:
317
+ opt.add(yi >= 0)
318
+ if y_upper_bound is not None:
319
+ opt.add(yi <= z3.RealVal(y_upper_bound))
320
+ for cs, cost in zip(coefficients, costs):
321
+ lhs = lam
322
+ for yi, coeff in zip(y, cs):
323
+ if coeff != 0:
324
+ lhs = lhs + yi * z3.RealVal(coeff)
325
+ opt.add(lhs <= z3.RealVal(cost))
326
+ objective = lam
327
+ for yi, row in zip(y, rows):
328
+ if row.rhs != 0:
329
+ objective = objective + z3.RealVal(row.rhs) * yi
330
+ opt.maximize(objective)
331
+ if opt.check() != z3.sat:
332
+ raise _unverified_error("the restricted dual LP was reported unsatisfiable, "
333
+ "although y = 0, lambda = 0 satisfies it")
334
+ model = opt.model()
335
+ y_vals = [_z3_to_fraction(model.eval(yi, model_completion=True)) for yi in y]
336
+ lam_val = _z3_to_fraction(model.eval(lam, model_completion=True))
337
+ if any(v < 0 or (y_upper_bound is not None and v > y_upper_bound) for v in y_vals):
338
+ raise _unverified_error("the dual returned by the optimiser leaves its bounds")
339
+ for cs, cost in zip(coefficients, costs):
340
+ if lam_val + sum((yi * coeff for yi, coeff in zip(y_vals, cs)), Fraction(0)) > cost:
341
+ raise _unverified_error("the dual returned by the optimiser violates one of its own rows")
342
+ return y_vals, lam_val
343
+
344
+
345
+ def _z3_reduced_cost(atom_vars: Dict[str, "z3.BoolRef"], rows: Sequence[_Row], y: Sequence[Fraction],
346
+ lam: Fraction, cost_z3_of: Callable[[Dict[str, "z3.BoolRef"]], "z3.ArithRef"]
347
+ ) -> "z3.ArithRef":
348
+ """The Z3 expression of :func:`_reduced_cost` over per-atom Boolean variables."""
349
+ objective = z3.RealVal(lam)
350
+ for yi, row in zip(y, rows):
351
+ if yi == 0:
352
+ continue
353
+ objective = objective + z3.RealVal(yi) * row.z3_coeff(atom_vars)
354
+ return objective - cost_z3_of(atom_vars)
355
+
356
+
357
+ def _world_of(model: "z3.ModelRef", atom_list: Sequence[str],
358
+ atom_vars: Dict[str, "z3.BoolRef"]) -> _World:
359
+ """The world a Z3 model assigns, total over ``atom_list`` (``model_completion=True``,
360
+ since an atom the objective does not mention would otherwise be a don't-care)."""
361
+ return {a: bool(z3.is_true(model.eval(atom_vars[a], model_completion=True))) for a in atom_list}
362
+
363
+
364
+ def _propose_world(atom_list: Sequence[str], rows: Sequence[_Row], y: Sequence[Fraction], lam: Fraction,
365
+ cost_z3_of: Callable[[Dict[str, "z3.BoolRef"]], "z3.ArithRef"]) -> Optional[_World]:
366
+ """A world Z3's optimiser PROPOSES as the maximiser of the reduced cost — a guess only.
367
+
368
+ One fresh :func:`z3.Bool` per atom and one :class:`z3.Optimize` call over the SAME
369
+ ``_Row.z3_coeff`` expressions the master uses. Neither the world nor any value the
370
+ optimiser reports is believed: :func:`_price` evaluates the world itself, exactly, and
371
+ asks a solver for another one when this one does not improve.
372
+ """
373
+ atom_vars = {a: z3.Bool(f"nilsson_cg!atom_{i}") for i, a in enumerate(atom_list)}
374
+ opt = z3.Optimize()
375
+ opt.maximize(_z3_reduced_cost(atom_vars, rows, y, lam, cost_z3_of))
376
+ if opt.check() != z3.sat:
377
+ return None
378
+ return _world_of(opt.model(), atom_list, atom_vars)
379
+
380
+
381
+ def _find_violating_world(atom_list: Sequence[str], rows: Sequence[_Row], y: Sequence[Fraction],
382
+ lam: Fraction, cost_z3_of: Callable[[Dict[str, "z3.BoolRef"]], "z3.ArithRef"]
383
+ ) -> Optional[_World]:
384
+ """A world whose reduced cost is positive, or ``None`` if the solver PROVES there is none.
385
+
386
+ A :class:`z3.Solver` query ``reduced cost > 0`` over the per-atom Booleans: ``unsat`` is
387
+ the certificate that the dual is feasible for every one of the ``2^n`` worlds. ``unknown``
388
+ is not an answer and ends in ``ValueError``.
389
+ """
390
+ atom_vars = {a: z3.Bool(f"nilsson_cg!atom_{i}") for i, a in enumerate(atom_list)}
391
+ solver = z3.Solver()
392
+ solver.add(_z3_reduced_cost(atom_vars, rows, y, lam, cost_z3_of) > 0)
393
+ result = solver.check()
394
+ if result == z3.unsat:
395
+ return None
396
+ if result != z3.sat:
397
+ raise _unverified_error("the solver could not decide whether some world violates the dual")
398
+ return _world_of(solver.model(), atom_list, atom_vars)
399
+
400
+
401
+ def _price(atom_list: Sequence[str], rows: Sequence[_Row], y: Sequence[Fraction], lam: Fraction,
402
+ cost_of: Callable[[_World], Fraction],
403
+ cost_z3_of: Callable[[Dict[str, "z3.BoolRef"]], "z3.ArithRef"]
404
+ ) -> Tuple[bool, Optional[_World], Fraction]:
405
+ """The pricing subproblem: is there a world whose reduced cost ``Σ_i y_i·coeff_i(w) + λ − cost(w)`` is positive?
406
+
407
+ Returns ``(found, world, value)``. ``found`` is ``True`` only for a world whose reduced
408
+ cost, recomputed here with :class:`~fractions.Fraction` arithmetic, is positive;
409
+ ``value`` is that exact number. ``found`` is ``False`` (``world`` ``None``, ``value`` 0)
410
+ only when it is PROVED that no world has a positive reduced cost:
411
+
412
+ * with at most :data:`_EXHAUSTIVE_PRICING_MAX_ATOMS` atoms, by evaluating every world
413
+ exactly (the world with the largest reduced cost is returned; among equals the first
414
+ in the order of ``itertools.product((False, True), ...)`` over ``atom_list`` — no
415
+ randomness);
416
+ * with more atoms, by Z3: the optimiser proposes a world (:func:`_propose_world`) and
417
+ the proposal is used if it improves; otherwise a :class:`z3.Solver` query asks for any
418
+ world with a positive reduced cost, and its ``unsat`` is the proof that none exists.
419
+
420
+ Whatever Z3 returns is re-evaluated exactly: a world it claims improves but that does
421
+ not is a fault of the translation and ends in ``ValueError`` rather than a wrong column.
422
+ """
423
+ if len(atom_list) <= _EXHAUSTIVE_PRICING_MAX_ATOMS:
424
+ best_world: Optional[_World] = None
425
+ best = Fraction(0)
426
+ for values in product((False, True), repeat=len(atom_list)):
427
+ world = dict(zip(atom_list, values))
428
+ value = _reduced_cost(rows, y, lam, cost_of, world)
429
+ if value > best:
430
+ best, best_world = value, world
431
+ return best_world is not None, best_world, best
432
+
433
+ proposal = _propose_world(atom_list, rows, y, lam, cost_z3_of)
434
+ if proposal is not None:
435
+ value = _reduced_cost(rows, y, lam, cost_of, proposal)
436
+ if value > 0:
437
+ return True, proposal, value
438
+ found_world = _find_violating_world(atom_list, rows, y, lam, cost_z3_of)
439
+ if found_world is None:
440
+ return False, None, Fraction(0)
441
+ value = _reduced_cost(rows, y, lam, cost_of, found_world)
442
+ if value <= 0:
443
+ raise _unverified_error("the solver reported a world that violates the dual, "
444
+ "but evaluating that world exactly shows it does not")
445
+ return True, found_world, value
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # The restricted PRIMAL, solved directly (for the final value + witness).
450
+ # ---------------------------------------------------------------------------
451
+
452
+ def _solve_restricted_primal(rows: Sequence[_Row], columns: Sequence[_World],
453
+ cost_of: Callable[[_World], Fraction]
454
+ ) -> Tuple[Fraction, List[Tuple[_World, Fraction]]]:
455
+ """Solve ``min Σ_w cost_of(w)·p_w`` over the current ``columns``.
456
+
457
+ Returns ``(value, witness)`` where ``witness`` is the explicit distribution the optimum
458
+ is attained by: every ``(world, p_w)`` pair from the optimal model with ``p_w`` nonzero.
459
+ The distribution is re-verified exactly (nonnegative, sums to 1, every row holds) and
460
+ ``value`` is the exact value OF THAT DISTRIBUTION, ``Σ_w cost_of(w)·p_w`` — not a number
461
+ the optimiser reported. It is a genuine feasible point of the FULL (all-``2^n``-columns)
462
+ problem too (the other worlds are implicitly at ``p_w = 0``), which is exactly what
463
+ licenses calling the value "attained" rather than merely "achievable in the limit" — the
464
+ property ``tests/test_nilsson_colgen.py`` re-checks independently by recomputing every
465
+ constraint's probability and the objective directly from ``witness`` via
466
+ :func:`~unicode_logic_kit.prob.nilsson._eval`. Whether the value is also the LEAST one is
467
+ settled by the caller against a dual (module docstring, point 5).
468
+ """
469
+ opt = z3.Optimize()
470
+ p = [z3.Real(f"nilsson_cg!p_{i}") for i in range(len(columns))]
471
+ for pi in p:
472
+ opt.add(pi >= 0)
473
+ opt.add(z3.Sum(p) == z3.RealVal(1))
474
+ for row in rows:
475
+ terms = [z3.RealVal(row.coeff_of(w)) * pi for w, pi in zip(columns, p) if row.coeff_of(w) != 0]
476
+ lhs = z3.Sum(terms) if terms else z3.RealVal(0)
477
+ opt.add(lhs >= z3.RealVal(row.rhs))
478
+ obj_terms = [z3.RealVal(cost_of(w)) * pi for w, pi in zip(columns, p) if cost_of(w) != 0]
479
+ objective = z3.Sum(obj_terms) if obj_terms else z3.RealVal(0)
480
+ opt.minimize(objective)
481
+ if opt.check() != z3.sat:
482
+ raise _unverified_error("the restricted primal LP was reported infeasible although "
483
+ "phase 1 had exhibited a feasible distribution over these columns")
484
+ model = opt.model()
485
+ weights = [_z3_to_fraction(model.eval(pi, model_completion=True)) for pi in p]
486
+ if not _distribution_is_exact(rows, columns, weights):
487
+ raise _unverified_error("the distribution returned by the optimiser is not a feasible "
488
+ "point of the restricted primal")
489
+ witness = [(w, pv) for w, pv in zip(columns, weights) if pv != 0]
490
+ value = sum((cost_of(w) * pv for w, pv in witness), Fraction(0))
491
+ return value, witness
492
+
493
+
494
+ # ---------------------------------------------------------------------------
495
+ # Phase 1 (feasibility) and phase 2 (optimize).
496
+ # ---------------------------------------------------------------------------
497
+
498
+ _ZERO_COST: Callable[[_World], Fraction] = lambda w: Fraction(0)
499
+ _ZERO_COST_Z3: Callable[[Dict[str, "z3.BoolRef"]], "z3.ArithRef"] = lambda av: z3.RealVal(0)
500
+
501
+
502
+ def _has_feasible_distribution(rows: Sequence[_Row], columns: Sequence[_World]) -> bool:
503
+ """``True`` iff a distribution over ``columns`` meets every row — and then one is exhibited.
504
+
505
+ A :class:`z3.Solver` query (no optimisation) whose model is accepted only after
506
+ :func:`_distribution_is_exact` confirms it in :class:`~fractions.Fraction` arithmetic.
507
+ ``False`` means the solver found none; the caller then asks for a certificate of it.
508
+ """
509
+ solver = z3.Solver()
510
+ p = [z3.Real(f"nilsson_cg!p_{i}") for i in range(len(columns))]
511
+ for pi in p:
512
+ solver.add(pi >= 0)
513
+ solver.add(z3.Sum(p) == z3.RealVal(1))
514
+ for row in rows:
515
+ terms = [z3.RealVal(row.coeff_of(w)) * pi for w, pi in zip(columns, p) if row.coeff_of(w) != 0]
516
+ lhs = z3.Sum(terms) if terms else z3.RealVal(0)
517
+ solver.add(lhs >= z3.RealVal(row.rhs))
518
+ if solver.check() != z3.sat:
519
+ return False
520
+ model = solver.model()
521
+ weights = [_z3_to_fraction(model.eval(pi, model_completion=True)) for pi in p]
522
+ if not _distribution_is_exact(rows, columns, weights):
523
+ raise _unverified_error("the model the solver returned for the restricted rows is not a "
524
+ "distribution that meets them")
525
+ return True
526
+
527
+
528
+ def _max_columns_error(max_columns: int, columns: Sequence[_World]) -> ValueError:
529
+ """The ``ValueError`` raised when column generation exhausts its ``max_columns`` brake."""
530
+ return ValueError(
531
+ f"entailment_bounds: column generation did not certify optimality within "
532
+ f"max_columns={max_columns} (currently {len(columns)} columns generated). "
533
+ "Never returns an unproven bound — pass a larger max_columns explicitly if "
534
+ "a harder problem is intended."
535
+ )
536
+
537
+
538
+ def _phase1(atom_list: Sequence[str], rows: Sequence[_Row], columns: List[_World],
539
+ max_columns: int, constraints: Sequence[ProbConstraint]) -> List[_World]:
540
+ """Grow ``columns`` (in place, and returned) until a distribution over them meets
541
+ every row, or PROVE the full constraint set is probabilistically inconsistent
542
+ (raising the same ``ValueError`` the direct strategy raises for the same reason — see
543
+ the module docstring's "Feasibility" section for the certificate).
544
+
545
+ The ``ValueError`` for an inconsistent set is raised only with a Farkas vector in hand:
546
+ ``y ≥ 0`` and ``λ`` with a positive exact dual value, satisfied by every column (checked
547
+ in :func:`_solve_dual`) and by every world (checked in :func:`_price`).
548
+ """
549
+ while True:
550
+ if _has_feasible_distribution(rows, columns):
551
+ return columns
552
+ y, lam = _solve_dual(rows, columns, _ZERO_COST, y_upper_bound=Fraction(1))
553
+ if _dual_value(rows, y, lam) <= 0:
554
+ raise _unverified_error("the solver found no distribution over the current columns, "
555
+ "but no certificate of that exists")
556
+ found, w_new, _value = _price(atom_list, rows, y, lam, _ZERO_COST, _ZERO_COST_Z3)
557
+ if not found or w_new is None: # no world violates the Farkas vector: it holds for all
558
+ raise _infeasible_error(constraints)
559
+ if len(columns) >= max_columns:
560
+ raise _max_columns_error(max_columns, columns)
561
+ if w_new in columns:
562
+ raise _unverified_error("a world with a positive reduced cost is already a column, "
563
+ "although the dual was verified feasible for every column")
564
+ columns.append(w_new)
565
+
566
+
567
+ def _phase2(atom_list: Sequence[str], rows: Sequence[_Row], columns: List[_World],
568
+ cost_of: Callable[[_World], Fraction],
569
+ cost_z3_of: Callable[[Dict[str, "z3.BoolRef"]], "z3.ArithRef"],
570
+ minimize: bool, max_columns: int
571
+ ) -> Tuple[Fraction, List[_World], List[Tuple[_World, Fraction]]]:
572
+ """Grow ``columns`` until pricing certifies optimality, then return the exact
573
+ optimum of ``minimize``/``maximize`` ``Σ_w cost_of(w)·p_w`` and its witness.
574
+
575
+ Maximizing is handled by minimizing the NEGATED cost throughout (so the
576
+ dual/pricing machinery above, written once for "minimize", covers both
577
+ directions) and negating the final value back.
578
+
579
+ The value is returned only when the exact value of the primal witness equals the exact
580
+ objective of a dual that no world violates (module docstring, point 5); otherwise the
581
+ restricted programs are solved again, and after :data:`_CERTIFICATION_ATTEMPTS` failures
582
+ ``ValueError`` is raised.
583
+ """
584
+ sign = 1 if minimize else -1
585
+ signed_cost_of = lambda w: sign * cost_of(w)
586
+ signed_cost_z3 = lambda av: z3.RealVal(sign) * cost_z3_of(av)
587
+ disagreements = 0
588
+ while True:
589
+ y, lam = _solve_dual(rows, columns, signed_cost_of)
590
+ found, w_new, _value = _price(atom_list, rows, y, lam, signed_cost_of, signed_cost_z3)
591
+ if found and w_new is not None:
592
+ if len(columns) >= max_columns:
593
+ raise _max_columns_error(max_columns, columns)
594
+ if w_new in columns:
595
+ raise _unverified_error("a world with a positive reduced cost is already a column, "
596
+ "although the dual was verified feasible for every column")
597
+ columns.append(w_new)
598
+ continue
599
+ value, witness = _solve_restricted_primal(rows, columns, signed_cost_of)
600
+ bound = _dual_value(rows, y, lam)
601
+ if value == bound:
602
+ return sign * value, columns, witness
603
+ if value < bound:
604
+ # Weak duality: a feasible distribution cannot cost less than the objective of a dual
605
+ # that no world violates. Both were checked exactly, so a solver that said "no world
606
+ # violates the dual" without it being true is the only way here.
607
+ raise _unverified_error(
608
+ f"the exact value of the primal witness ({value}) is below the objective of a dual "
609
+ f"that is feasible for every world ({bound}), which weak duality rules out")
610
+ disagreements += 1
611
+ if disagreements >= _CERTIFICATION_ATTEMPTS:
612
+ raise _unverified_error(
613
+ f"the exact value of the primal witness ({value}) differs from the exact "
614
+ f"objective of the dual that no world violates ({bound})")
615
+
616
+
617
+ # ---------------------------------------------------------------------------
618
+ # Public (within this internal module) entry point.
619
+ # ---------------------------------------------------------------------------
620
+
621
+ @dataclass(frozen=True)
622
+ class ColumnGenerationResult:
623
+ """The full result of :func:`solve`, including what the public
624
+ ``ProbBounds`` does not carry: the number of columns actually generated,
625
+ and each bound's explicit witnessing distribution — both worlds white-box
626
+ tests (``tests/test_nilsson_colgen.py``) check independently.
627
+ """
628
+
629
+ lower: Fraction
630
+ upper: Fraction
631
+ n_columns: int
632
+ lower_witness: List[Tuple[_World, Fraction]]
633
+ upper_witness: List[Tuple[_World, Fraction]]
634
+
635
+
636
+ def solve(atom_list: Sequence[str], constraints: Sequence[ProbConstraint], conclusion: Node,
637
+ max_columns: int) -> ColumnGenerationResult:
638
+ """Column-generation counterpart of ``nilsson.entailment_bounds``'s direct-strategy body.
639
+
640
+ Runs phase 1 once (feasibility; see the module docstring), then phase 2
641
+ twice (minimize, then maximize, ``P(conclusion)``, each continuing from
642
+ wherever the previous phase left ``columns`` — the feasible region does
643
+ not depend on the objective, so reusing columns across phases is free and
644
+ correct, never wrong to reuse a world already known to be in the
645
+ polytope). See this module's own docstring for the full termination and
646
+ optimality proof.
647
+
648
+ Raises:
649
+ ValueError: if the constraint set is probabilistically inconsistent
650
+ (identical message to the direct strategy's), if optimality is
651
+ not certified within ``max_columns`` generated columns, or if an
652
+ answer of Z3 fails the exact re-check made on it (so that no bound
653
+ it could not certify is ever returned).
654
+ """
655
+ rows = _build_rows(constraints)
656
+ cost_of = lambda w: _indicator(conclusion, w)
657
+ cost_z3_of = lambda av: _z3_indicator(conclusion, av)
658
+
659
+ seed = {a: False for a in atom_list}
660
+ columns: List[_World] = [seed]
661
+ columns = _phase1(atom_list, rows, columns, max_columns, constraints)
662
+
663
+ lower, columns, lower_witness = _phase2(
664
+ atom_list, rows, columns, cost_of, cost_z3_of, True, max_columns)
665
+ upper, columns, upper_witness = _phase2(
666
+ atom_list, rows, columns, cost_of, cost_z3_of, False, max_columns)
667
+
668
+ return ColumnGenerationResult(lower, upper, len(columns), lower_witness, upper_witness)