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.
- unicode_logic_kit/__init__.py +385 -0
- unicode_logic_kit/__main__.py +520 -0
- unicode_logic_kit/_deadline.py +219 -0
- unicode_logic_kit/ace/__init__.py +126 -0
- unicode_logic_kit/ace/_align.py +135 -0
- unicode_logic_kit/ace/chem_lexicon.py +128 -0
- unicode_logic_kit/ace/drs_reader.py +570 -0
- unicode_logic_kit/ace/mapping.py +666 -0
- unicode_logic_kit/ace/reverse_modal.py +138 -0
- unicode_logic_kit/ace/runner.py +551 -0
- unicode_logic_kit/ace/translate.py +452 -0
- unicode_logic_kit/ace/verbalize.py +1070 -0
- unicode_logic_kit/api.py +1284 -0
- unicode_logic_kit/atp/__init__.py +177 -0
- unicode_logic_kit/atp/_ascii_names.py +113 -0
- unicode_logic_kit/atp/_html.py +72 -0
- unicode_logic_kit/atp/_substructural_input.py +228 -0
- unicode_logic_kit/atp/_tff_problem.py +715 -0
- unicode_logic_kit/atp/_tptp_problem.py +1111 -0
- unicode_logic_kit/atp/_writer_support.py +289 -0
- unicode_logic_kit/atp/clingo_backend.py +1180 -0
- unicode_logic_kit/atp/cvc5_backend.py +1385 -0
- unicode_logic_kit/atp/eprover_backend.py +732 -0
- unicode_logic_kit/atp/finite_domain.py +1055 -0
- unicode_logic_kit/atp/fitch.py +1547 -0
- unicode_logic_kit/atp/fitch_search.py +551 -0
- unicode_logic_kit/atp/hets_backend.py +339 -0
- unicode_logic_kit/atp/hybrid_down.py +120 -0
- unicode_logic_kit/atp/incremental.py +250 -0
- unicode_logic_kit/atp/kripke_enum.py +741 -0
- unicode_logic_kit/atp/lambek.py +436 -0
- unicode_logic_kit/atp/leo3_backend.py +332 -0
- unicode_logic_kit/atp/linear.py +738 -0
- unicode_logic_kit/atp/lj.py +705 -0
- unicode_logic_kit/atp/logic_backends.py +566 -0
- unicode_logic_kit/atp/ltl_tableau.py +1084 -0
- unicode_logic_kit/atp/minizinc_backend.py +1402 -0
- unicode_logic_kit/atp/modal_tableau.py +1382 -0
- unicode_logic_kit/atp/nanocop_backend.py +410 -0
- unicode_logic_kit/atp/portfolio.py +489 -0
- unicode_logic_kit/atp/protocol.py +1803 -0
- unicode_logic_kit/atp/prover9_entailment.py +1153 -0
- unicode_logic_kit/atp/resolution.py +1376 -0
- unicode_logic_kit/atp/resolution_check.py +1114 -0
- unicode_logic_kit/atp/sequent.py +1050 -0
- unicode_logic_kit/atp/tableau.py +921 -0
- unicode_logic_kit/atp/tableau_check.py +543 -0
- unicode_logic_kit/atp/tptp_ncl.py +811 -0
- unicode_logic_kit/atp/tptp_tff.py +1546 -0
- unicode_logic_kit/atp/tstp.py +1333 -0
- unicode_logic_kit/atp/tstp_check.py +1096 -0
- unicode_logic_kit/atp/twee_backend.py +236 -0
- unicode_logic_kit/atp/twee_check.py +711 -0
- unicode_logic_kit/atp/twee_entailment.py +953 -0
- unicode_logic_kit/atp/vampire_entailment.py +540 -0
- unicode_logic_kit/atp/z3_arith.py +470 -0
- unicode_logic_kit/atp/z3_equivalence.py +36 -0
- unicode_logic_kit/atp/z3_fuzzy.py +362 -0
- unicode_logic_kit/atp/z3_input.py +500 -0
- unicode_logic_kit/atp/z3_models.py +208 -0
- unicode_logic_kit/chem/__init__.py +88 -0
- unicode_logic_kit/chem/_naming.py +284 -0
- unicode_logic_kit/chem/cache.py +185 -0
- unicode_logic_kit/chem/interop.py +244 -0
- unicode_logic_kit/chem/mol.py +525 -0
- unicode_logic_kit/chem/signature.py +112 -0
- unicode_logic_kit/comorphism.py +497 -0
- unicode_logic_kit/dl/__init__.py +384 -0
- unicode_logic_kit/dl/classification.py +227 -0
- unicode_logic_kit/dl/concepts.py +632 -0
- unicode_logic_kit/dl/datatypes.py +818 -0
- unicode_logic_kit/dl/owl_functional.py +2433 -0
- unicode_logic_kit/dl/owl_manchester.py +1637 -0
- unicode_logic_kit/dl/owl_reasoner.py +790 -0
- unicode_logic_kit/dl/parser.py +391 -0
- unicode_logic_kit/dl/tableau.py +4048 -0
- unicode_logic_kit/dl/translate.py +2704 -0
- unicode_logic_kit/drt/__init__.py +94 -0
- unicode_logic_kit/drt/export.py +179 -0
- unicode_logic_kit/drt/nodes.py +506 -0
- unicode_logic_kit/drt/parser.py +965 -0
- unicode_logic_kit/drt/resolve.py +195 -0
- unicode_logic_kit/drt/reverse.py +175 -0
- unicode_logic_kit/eval/__init__.py +106 -0
- unicode_logic_kit/eval/batch.py +382 -0
- unicode_logic_kit/eval/canonical.py +663 -0
- unicode_logic_kit/eval/chem_batch.py +606 -0
- unicode_logic_kit/eval/converses.py +200 -0
- unicode_logic_kit/eval/datasets/__init__.py +136 -0
- unicode_logic_kit/eval/datasets/_base.py +263 -0
- unicode_logic_kit/eval/datasets/_proofwriter_proof.py +422 -0
- unicode_logic_kit/eval/datasets/c3po.py +678 -0
- unicode_logic_kit/eval/datasets/folio.py +158 -0
- unicode_logic_kit/eval/datasets/fracas.py +418 -0
- unicode_logic_kit/eval/datasets/groves.py +191 -0
- unicode_logic_kit/eval/datasets/logicbench.py +467 -0
- unicode_logic_kit/eval/datasets/logicnli.py +303 -0
- unicode_logic_kit/eval/datasets/malls.py +133 -0
- unicode_logic_kit/eval/datasets/pfolio.py +594 -0
- unicode_logic_kit/eval/datasets/pmb.py +242 -0
- unicode_logic_kit/eval/datasets/prontoqa.py +611 -0
- unicode_logic_kit/eval/datasets/proofwriter.py +1431 -0
- unicode_logic_kit/eval/datasets/proverqa.py +674 -0
- unicode_logic_kit/eval/datasets/willow.py +478 -0
- unicode_logic_kit/eval/equivalence.py +466 -0
- unicode_logic_kit/eval/exercise_gen.py +533 -0
- unicode_logic_kit/eval/explain.py +791 -0
- unicode_logic_kit/eval/generality.py +750 -0
- unicode_logic_kit/eval/metric_hf.py +458 -0
- unicode_logic_kit/eval/predicate_match.py +343 -0
- unicode_logic_kit/eval/theory_check.py +1170 -0
- unicode_logic_kit/eval/validate.py +306 -0
- unicode_logic_kit/fol/__init__.py +177 -0
- unicode_logic_kit/fol/_atom_keys.py +510 -0
- unicode_logic_kit/fol/_fol_nodes.py +3586 -0
- unicode_logic_kit/fol/_free_parameters.py +105 -0
- unicode_logic_kit/fol/_ho_nodes.py +448 -0
- unicode_logic_kit/fol/_hybrid_nodes.py +308 -0
- unicode_logic_kit/fol/_identifiers.py +1091 -0
- unicode_logic_kit/fol/_lambek_nodes.py +112 -0
- unicode_logic_kit/fol/_linear_nodes.py +352 -0
- unicode_logic_kit/fol/_modal_nodes.py +1467 -0
- unicode_logic_kit/fol/_msfl_nodes.py +2196 -0
- unicode_logic_kit/fol/_numeral_symbols.py +231 -0
- unicode_logic_kit/fol/_so_nodes.py +200 -0
- unicode_logic_kit/fol/_symbol_names.py +81 -0
- unicode_logic_kit/fol/_team_nodes.py +181 -0
- unicode_logic_kit/fol/_tptp_symbols.py +551 -0
- unicode_logic_kit/fol/_truth_constants.py +117 -0
- unicode_logic_kit/fol/casl_export.py +1135 -0
- unicode_logic_kit/fol/casl_import.py +929 -0
- unicode_logic_kit/fol/derivation.py +367 -0
- unicode_logic_kit/fol/dialect_detect.py +70 -0
- unicode_logic_kit/fol/dialect_repair.py +537 -0
- unicode_logic_kit/fol/frames.py +637 -0
- unicode_logic_kit/fol/grammars/terminals.lark +31 -0
- unicode_logic_kit/fol/lambda_tools.py +297 -0
- unicode_logic_kit/fol/latex_input.py +429 -0
- unicode_logic_kit/fol/modal_translation.py +944 -0
- unicode_logic_kit/fol/msflparser.py +1033 -0
- unicode_logic_kit/fol/naming.py +422 -0
- unicode_logic_kit/fol/nodes.py +241 -0
- unicode_logic_kit/fol/normalforms.py +492 -0
- unicode_logic_kit/fol/pal.py +287 -0
- unicode_logic_kit/fol/prolog_export.py +566 -0
- unicode_logic_kit/fol/prolog_input.py +505 -0
- unicode_logic_kit/fol/prover9_input.py +1325 -0
- unicode_logic_kit/fol/qml.py +1760 -0
- unicode_logic_kit/fol/qmltp_input.py +525 -0
- unicode_logic_kit/fol/sanitize.py +221 -0
- unicode_logic_kit/fol/serialize.py +79 -0
- unicode_logic_kit/fol/signature.py +1290 -0
- unicode_logic_kit/fol/simplify_check.py +544 -0
- unicode_logic_kit/fol/spans.py +594 -0
- unicode_logic_kit/fol/tptp_input.py +1503 -0
- unicode_logic_kit/fol/tptp_repair.py +941 -0
- unicode_logic_kit/fol/unification.py +157 -0
- unicode_logic_kit/fol/verbalize.py +263 -0
- unicode_logic_kit/hets/__init__.py +163 -0
- unicode_logic_kit/hets/bridge.py +142 -0
- unicode_logic_kit/hets/client.py +748 -0
- unicode_logic_kit/hets/docker.py +420 -0
- unicode_logic_kit/hets/dol.py +712 -0
- unicode_logic_kit/hets/haskell_json.py +355 -0
- unicode_logic_kit/hets/owl_backend.py +794 -0
- unicode_logic_kit/hets/owl_cli.py +598 -0
- unicode_logic_kit/hets/symbols.py +512 -0
- unicode_logic_kit/hol/__init__.py +140 -0
- unicode_logic_kit/hol/_ho_common.py +323 -0
- unicode_logic_kit/hol/_isabelle_binders.py +125 -0
- unicode_logic_kit/hol/classical.py +812 -0
- unicode_logic_kit/hol/deepshallow/__init__.py +45 -0
- unicode_logic_kit/hol/deepshallow/_common.py +177 -0
- unicode_logic_kit/hol/deepshallow/conditional.py +225 -0
- unicode_logic_kit/hol/deepshallow/intuitionistic.py +181 -0
- unicode_logic_kit/hol/deepshallow/modal.py +217 -0
- unicode_logic_kit/hol/deepshallow/qml.py +406 -0
- unicode_logic_kit/hol/deepshallow/relevant.py +206 -0
- unicode_logic_kit/hol/free.py +753 -0
- unicode_logic_kit/hol/goedel.py +336 -0
- unicode_logic_kit/hol/ho_modal.py +1743 -0
- unicode_logic_kit/hol/intuitionistic.py +403 -0
- unicode_logic_kit/hol/isabelle_conditional.py +593 -0
- unicode_logic_kit/hol/isabelle_modal.py +1908 -0
- unicode_logic_kit/hol/isabelle_relevant.py +412 -0
- unicode_logic_kit/hol/isabelle_runner.py +1147 -0
- unicode_logic_kit/hol/isabelle_substructural.py +884 -0
- unicode_logic_kit/hol/lean.py +1018 -0
- unicode_logic_kit/hol/manyvalued.py +921 -0
- unicode_logic_kit/hol/secondorder.py +687 -0
- unicode_logic_kit/hol/thf_modal.py +941 -0
- unicode_logic_kit/hol/thirdorder.py +397 -0
- unicode_logic_kit/ilp/__init__.py +89 -0
- unicode_logic_kit/ilp/readback.py +389 -0
- unicode_logic_kit/ilp/separation.py +153 -0
- unicode_logic_kit/ilp/task.py +730 -0
- unicode_logic_kit/logic.py +163 -0
- unicode_logic_kit/mcp/__init__.py +28 -0
- unicode_logic_kit/mcp/__main__.py +5 -0
- unicode_logic_kit/mcp/chem_tools.py +1031 -0
- unicode_logic_kit/mcp/server.py +2453 -0
- unicode_logic_kit/mcp/syntax_spec.py +681 -0
- unicode_logic_kit/prob/__init__.py +53 -0
- unicode_logic_kit/prob/_bdd.py +225 -0
- unicode_logic_kit/prob/_column_gen.py +668 -0
- unicode_logic_kit/prob/distribution.py +686 -0
- unicode_logic_kit/prob/nilsson.py +470 -0
- unicode_logic_kit/py.typed +0 -0
- unicode_logic_kit/semantics/__init__.py +137 -0
- unicode_logic_kit/semantics/_modal_reject.py +156 -0
- unicode_logic_kit/semantics/action_models.py +466 -0
- unicode_logic_kit/semantics/asp_models.py +1200 -0
- unicode_logic_kit/semantics/conditional.py +580 -0
- unicode_logic_kit/semantics/dynamic_epistemic.py +95 -0
- unicode_logic_kit/semantics/free_logic.py +913 -0
- unicode_logic_kit/semantics/fuzzy.py +384 -0
- unicode_logic_kit/semantics/fuzzy_kripke.py +442 -0
- unicode_logic_kit/semantics/intuitionistic.py +581 -0
- unicode_logic_kit/semantics/kripke.py +1139 -0
- unicode_logic_kit/semantics/manyvalued.py +580 -0
- unicode_logic_kit/semantics/matrix.py +342 -0
- unicode_logic_kit/semantics/model_eval.py +1135 -0
- unicode_logic_kit/semantics/modelfinder.py +1036 -0
- unicode_logic_kit/semantics/nonmonotonic.py +372 -0
- unicode_logic_kit/semantics/relevant.py +331 -0
- unicode_logic_kit/semantics/secondorder.py +657 -0
- unicode_logic_kit/semantics/structures.py +352 -0
- unicode_logic_kit/semantics/tarski.py +975 -0
- unicode_logic_kit/semantics/team.py +315 -0
- unicode_logic_kit/semantics/team_translation.py +416 -0
- unicode_logic_kit/semantics/thirdorder.py +358 -0
- unicode_logic_kit/semantics/tnorm.py +85 -0
- unicode_logic_kit/semantics/truthtable.py +201 -0
- unicode_logic_kit-0.31.0.dist-info/METADATA +333 -0
- unicode_logic_kit-0.31.0.dist-info/RECORD +237 -0
- unicode_logic_kit-0.31.0.dist-info/WHEEL +4 -0
- 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)
|