3
0
Fork 0
mirror of https://github.com/Z3Prover/z3 synced 2026-06-11 19:35:43 +00:00

Improvements to NLA lemmas (#9391)

* Add dual-row shared-factor sandwich for NLA bound propagation

When enabled via arith.nl.monomial_sandwich (default off), monomial_bounds
finds LP term columns whose term has shape  a_m * m + a_v * v  with exactly
two variables — both factors of a binary monomial m = u*v. The term column's
bound bounds (a_m * m + a_v * v); substituting m = u*v gives v * (a_m*u + a_v),
and sign-aware interval division by v plus an affine shift yields a numeric
bound on u. The derived interval is fed to the existing propagate_value path
so the lemma channel and integer rounding logic are shared with the rest of
NLA's forward/backward propagation; no new emit code.

Catches conflicts of the form
  α_v1 * v + α_m * m ≥ k1
  α_v2 * v + α_m * m ≤ k2
that today require nlsat (when no single row alone yields infeasibility but
their conjunction tightly bounds u after factoring v).

Scope: binary monomials only (m.size()==2, no squares); cap of 16 term-columns
scanned per call; one lemma per (u,v) attempt to keep the lemma channel quiet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add arith.nl.order.binomial_sign flag (default true)

Granular gate for order_lemma_on_binomial_sign — the only order family that
embeds a model-snapshot literal (x ≷ val(x)) in the lemma body. Disabling it
keeps the always-good structural mon-ol family running while removing the
SAT-splitter shape that cascades under model perturbations (e.g., from
arith.nl.monomial_sandwich tightening factor bounds).

Default true preserves master behaviour; the flag is intended as an
experimental knob to measure how much of an observed cascade is specifically
attributable to the binomial-sign splitter vs. the structural cancellation
lemmas in the same module.

See ord-binom-opportunities.md for the full gap analysis and the
deterministic-replacement directions (sandwich, McCormick) that would let
this flag eventually default to false without regressing leaves where
ord-binom currently carries the proof.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add sign-pinned binomial bound for NLA (Opportunity 1 from ord-binom doc)

When enabled via arith.nl.monomial_binomial_sign (default off), monomial_bounds
adds a third pass alongside propagate_down (existing) and propagate_shared_factor
(sandwich). For a binary monomial m = u*v in m_to_refine whose model value mv
disagrees with val(u)*val(v), and where v has a determined sign:

  1. synthesize a one-sided interval for m.var() at mv (no deps; the snapshot
     enters as a literal in the lemma body, not as an antecedent)
  2. divide by v's interval (sign-aware via dep.div<with_deps>) to get a
     deterministic interval for u
  3. emit a propagate_value-style lemma whose body is
        m.var() < mv (or > mv) ∨ u-bound
     conditioned on v's bound witness

Targets the case ord-binom currently handles: factors have determined signs,
m.var() may have no LP bound. The clause is sound modulo the monomial
definition (same condition propagate_down, propagate_shared_factor, and
ord-binom already rely on).

A new throttle kind MONOMIAL_BINOMIAL_SIGN keyed on (m.var, u, v, direction)
prevents cascading: without it, each new val(m.var()) snapshot would re-emit
across model changes the same way ord-binom does.

Validated via smt.arith.validate=true: 0 soundness errors across the
32-leaf test corpus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add McCormick box-corner tangent points (Opportunity 2 from ord-binom doc)

When enabled via arith.nl.tangents.box_corners (default off, sub-flag of
arith.nl.tangents), tangent_imp::get_points selects m_a, m_b at the corners
of the bound box [x_lo, x_hi] × [y_lo, y_hi] instead of the model-centered
points val(x) ± delta. The selection follows the classical McCormick
under/over envelope:

  - m_below=true (under-approximation):
      m_a = (x_lo, y_lo),  m_b = (x_hi, y_hi)
  - m_below=false (over-approximation):
      m_a = (x_lo, y_hi),  m_b = (x_hi, y_lo)

The existing generate_plane already produces the McCormick linear form
xy ≷ pl.y·x + pl.x·y − pl.x·pl.y at any chosen point pl. push_point is
skipped in box-corner mode: corners are extremes, so doubling the offset
moves out of the box and would invalidate the McCormick property.

Falls back to the existing model-driven point selection when either factor
has an unbounded side or the box is degenerate (single-point in a
dimension).

Soundness — non-strict inequality at corners. The classical model-driven
flow uses pl strictly in the interior of the box, so generate_plane emits
xy > T (strict). At the box corners the tangent meets the surface along
the box's edges (xy = T when x = pl.x or y = pl.y), so the strict
inequality is violated by any model with x at the box boundary. A new
m_pl_strict_interior member, set false on a successful set_box_corners(),
switches generate_plane's emission to ≥/≤ (non-strict). The model-driven
path keeps strict — its push_point + plane_is_correct_cut chain already
guarantees pl is interior.

Validated via smt.arith.validate=true: 0 validate_conflict() failures
across the 32-leaf test corpus with box_corners=true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arie 2026-04-26 15:07:28 -04:00 committed by GitHub
parent b0956429fe
commit d99d5a736f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 264 additions and 7 deletions

View file

@ -312,6 +312,12 @@ namespace nla {
} }
dep.mul<dep_intervals::with_deps>(product, vi, product); dep.mul<dep_intervals::with_deps>(product, vi, product);
} }
if (do_propagate_down && c().params().arith_nl_monomial_sandwich() &&
propagate_shared_factor(m))
return true;
if (c().params().arith_nl_monomial_binomial_sign() &&
propagate_binomial_sign(m))
return true;
return do_propagate_up && propagate_value(product, m.var()); return do_propagate_up && propagate_value(product, m.var());
} }
@ -501,11 +507,196 @@ namespace nla {
} }
lpvar monomial_bounds::non_fixed_var(monic const& m) { lpvar monomial_bounds::non_fixed_var(monic const& m) {
for (lpvar v : m) for (lpvar v : m)
if (!c().var_is_fixed(v)) if (!c().var_is_fixed(v))
return v; return v;
return null_lpvar; return null_lpvar;
} }
/**
* Dual-row shared-factor sandwich. For a binary monomial m = u*v, find LP
* term columns whose term has shape a_m * m + a_v * v (exactly two
* variables, both factors of m). The term column's bound is a sound
* interval for (a_m * m + a_v * v). Substituting m = u*v yields
* v * (a_m * u + a_v); dividing by the interval on v (sign-determined)
* gives an interval on (a_m * u + a_v), and an affine shift gives an
* interval on u. The derived interval is fed to the existing
* propagate_value path so the lemma channel and integer rounding are
* shared with the rest of the propagation pipeline.
*/
bool monomial_bounds::propagate_shared_factor(monic const& m) {
if (m.size() != 2)
return false;
lpvar f0 = m.vars()[0], f1 = m.vars()[1];
if (f0 == f1)
return false;
unsigned const fanout_limit = c().params().arith_nl_monomial_sandwich_max_fanout();
auto try_pair = [&](lpvar u, lpvar v) -> bool {
// Skip if u participates in too many monomials: tightening such a
// factor cascades through ord-binom / monotonicity on every monic
// that contains it.
if (fanout_limit > 0) {
unsigned fanout = 0;
for (auto const& m1 : c().emons().get_use_list(u)) {
(void)m1;
if (++fanout > fanout_limit)
return false;
}
}
scoped_dep_interval vi(dep);
var2interval(v, vi);
if (!dep.separated_from_zero(vi))
return false;
auto& lra = c().lra;
unsigned const ROW_CAP = 16;
unsigned scanned = 0;
for (auto const& cell : lra.A_r().m_columns[m.var()]) {
if (++scanned > ROW_CAP)
break;
unsigned basic = lra.get_base_column_in_row(cell.var());
if (basic == m.var() || basic == v || basic == u)
continue;
if (!lra.column_has_term(basic))
continue;
auto const& term = lra.get_term(basic);
if (term.size() != 2 ||
!term.contains(m.var()) || !term.contains(v))
continue;
rational const& a_m = term.get_coeff(m.var());
rational const& a_v = term.get_coeff(v);
if (a_m.is_zero())
continue;
// Term value = a_m*m + a_v*v; bound on basic bounds the term.
// Substituting m = u*v: term = v * (a_m*u + a_v).
scoped_dep_interval bi(dep);
var2interval(basic, bi);
scoped_dep_interval inner(dep);
dep.div<dep_intervals::with_deps>(bi, vi, inner);
scoped_dep_interval shift(dep);
dep.set_value(shift, -a_v);
scoped_dep_interval scaled(dep);
dep.add<dep_intervals::with_deps>(inner, shift, scaled);
scoped_dep_interval u_int(dep);
dep.mul<dep_intervals::with_deps>(rational::one() / a_m, scaled, u_int);
TRACE(nla_solver, tout << "sandwich shared-factor basic=" << basic
<< " m=" << m.var() << " v=" << v << " u=" << u
<< " a_m=" << a_m << " a_v=" << a_v << "\n";);
if (propagate_value(u_int, u))
return true; // one lemma per call to keep the channel quiet
}
return false;
};
return try_pair(f1, f0) || try_pair(f0, f1);
}
/**
* Sign-pinned binomial bound. For a binary monomial m = u*v in m_to_refine,
* use the current LP value mv = val(m.var()) as a one-sided anchor on the
* monomial value variable, and derive a deterministic interval for u via
* sign-aware division by v.
*
* Direction is chosen by the disagreement: if val(m.var()) > val(u)*val(v)
* the LP placed the monomial above the factor product, so we condition on
* "m.var() >= mv"; otherwise on "m.var() <= mv". The resulting clause is
* structurally analogous to a propagate_value lemma plus one extra
* snapshot literal on m.var(): under the asserted bounds on v, the clause
* reduces to a 2-disjunct (snapshot literal | factor bound).
*
* Targets the case ord-binom currently handles: factors have determined
* signs, m.var() may have no LP bound at all. The clause is sound modulo
* the monomial definition (the same condition propagate_down,
* propagate_shared_factor and ord-binom rely on).
*/
bool monomial_bounds::propagate_binomial_sign(monic const& m) {
if (m.size() != 2)
return false;
lpvar f0 = m.vars()[0], f1 = m.vars()[1];
if (f0 == f1)
return false;
rational const mv = c().val(m.var());
rational const fp = c().val(f0) * c().val(f1);
if (mv == fp)
return false;
bool const below = mv > fp; // LP placed m.var() too high
llc const anchor_cmp = below ? llc::LT : llc::GT;
auto try_anchor = [&](lpvar u, lpvar v) -> bool {
// Throttle once per (m.var(), u, v, direction) tuple. Without it
// each new val(m.var()) snapshot would re-emit and the search
// would cascade across model changes the same way ord-binom does.
if (c().throttle().insert_new(
nla_throttle::MONOMIAL_BINOMIAL_SIGN,
m.var(), u, v, below))
return false;
scoped_dep_interval vi(dep);
var2interval(v, vi);
if (!dep.separated_from_zero(vi))
return false;
// Synthesize a one-sided interval for m.var() at mv. No deps;
// the snapshot literal goes into the lemma body directly.
scoped_dep_interval mi_anchor(dep);
if (below) {
dep.set_lower(mi_anchor, mv);
dep.set_lower_is_inf(mi_anchor, false);
dep.set_lower_is_open(mi_anchor, false);
dep.set_upper_is_inf(mi_anchor, true);
} else {
dep.set_upper(mi_anchor, mv);
dep.set_upper_is_inf(mi_anchor, false);
dep.set_upper_is_open(mi_anchor, false);
dep.set_lower_is_inf(mi_anchor, true);
}
scoped_dep_interval u_int(dep);
dep.div<dep_intervals::with_deps>(mi_anchor, vi, u_int);
bool emitted = false;
if (should_propagate_lower(u_int, u, 1)) {
auto const& lower = dep.lower(u_int);
if (!is_too_big(lower)) {
auto cmp = dep.lower_is_open(u_int) ? llc::GT : llc::GE;
lp::explanation ex;
dep.get_lower_dep(u_int, ex);
lemma_builder lemma(c(), "binomial sign anchor");
lemma &= ex;
lemma |= ineq(m.var(), anchor_cmp, mv);
lemma |= ineq(u, cmp, lower);
emitted = true;
}
}
if (should_propagate_upper(u_int, u, 1)) {
auto const& upper = dep.upper(u_int);
if (!is_too_big(upper)) {
auto cmp = dep.upper_is_open(u_int) ? llc::LT : llc::LE;
lp::explanation ex;
dep.get_upper_dep(u_int, ex);
lemma_builder lemma(c(), "binomial sign anchor");
lemma &= ex;
lemma |= ineq(m.var(), anchor_cmp, mv);
lemma |= ineq(u, cmp, upper);
emitted = true;
}
}
return emitted;
};
return try_anchor(f1, f0) || try_anchor(f0, f1);
}
} }

View file

@ -33,6 +33,8 @@ namespace nla {
u_dependency* explain_fixed(monic const& m, rational const& k); u_dependency* explain_fixed(monic const& m, rational const& k);
lp::explanation get_explanation(u_dependency* dep); lp::explanation get_explanation(u_dependency* dep);
bool propagate_down(monic const& m, dep_interval& mi, lpvar v, unsigned power, dep_interval& product); bool propagate_down(monic const& m, dep_interval& mi, lpvar v, unsigned power, dep_interval& product);
bool propagate_shared_factor(monic const& m);
bool propagate_binomial_sign(monic const& m);
void analyze_monomial(monic const& m, unsigned& num_free, lpvar& free_v, unsigned& power) const; void analyze_monomial(monic const& m, unsigned& num_free, lpvar& free_v, unsigned& power) const;
bool is_free(lpvar v) const; bool is_free(lpvar v) const;
bool is_zero(lpvar v) const; bool is_zero(lpvar v) const;

View file

@ -81,9 +81,11 @@ void order::order_lemma_on_binomial(const monic& ac) {
*/ */
void order::order_lemma_on_binomial_sign(const monic& xy, lpvar x, lpvar y, int sign) { void order::order_lemma_on_binomial_sign(const monic& xy, lpvar x, lpvar y, int sign) {
if (!c().params().arith_nl_order_binomial_sign())
return;
if (!c().var_is_int(x) && val(x).is_big()) if (!c().var_is_int(x) && val(x).is_big())
return; return;
SASSERT(!_().mon_has_zero(xy.vars())); SASSERT(!_().mon_has_zero(xy.vars()));
int sy = rat_sign(val(y)); int sy = rat_sign(val(y));

View file

@ -18,6 +18,12 @@ class tangent_imp {
rational m_correct_v; rational m_correct_v;
// "below" means that the incorrect value is less than the correct one, that is m_v < m_correct_v // "below" means that the incorrect value is less than the correct one, that is m_v < m_correct_v
bool m_below; bool m_below;
// pl is in the strict interior of the bound box (model-driven points
// get_initial_points + push_point); McCormick at the box corners
// requires non-strict inequality because the tangent meets the surface
// along the box's edges (xy = pl.y*x + pl.x*y - pl.x*pl.y at x = pl.x
// or y = pl.y).
bool m_pl_strict_interior = true;
rational m_v; // the monomial value rational m_v; // the monomial value
lpvar m_j; // the monic variable lpvar m_j; // the monic variable
const monic& m_m; const monic& m_m;
@ -89,7 +95,10 @@ private:
t.add_monomial(- m_y.rat_sign()*pl.x, m_jy); t.add_monomial(- m_y.rat_sign()*pl.x, m_jy);
t.add_monomial(- m_x.rat_sign()*pl.y, m_jx); t.add_monomial(- m_x.rat_sign()*pl.y, m_jx);
t.add_var(m_j); t.add_var(m_j);
lemma |= ineq(t, m_below? llc::GT : llc::LT, - pl.x*pl.y); llc cmp = m_below
? (m_pl_strict_interior ? llc::GT : llc::GE)
: (m_pl_strict_interior ? llc::LT : llc::LE);
lemma |= ineq(t, cmp, - pl.x*pl.y);
explain(lemma); explain(lemma);
} }
@ -164,14 +173,61 @@ private:
return a.x * m_xy.y + a.y * m_xy.x - a.x * a.y; return a.x * m_xy.y + a.y * m_xy.x - a.x * a.y;
} }
// McCormick at box corners: choose m_a, m_b at the corners of
// [x_lo, x_hi] x [y_lo, y_hi] that bound xy from the side dictated by
// m_below. Returns false if either factor has an unbounded side, the
// box is degenerate, or the current LP value of a factor coincides with
// a chosen corner — generate_plane's negate_relation requires
// val(j) != corner_coord (SASSERT in debug; trivially-true literal in
// release). The caller falls back to the model-driven point selection in
// these cases.
bool set_box_corners() {
if (!c().has_lower_bound(m_jx) || !c().has_upper_bound(m_jx))
return false;
if (!c().has_lower_bound(m_jy) || !c().has_upper_bound(m_jy))
return false;
rational const& x_lo = c().get_lower_bound(m_jx);
rational const& x_hi = c().get_upper_bound(m_jx);
rational const& y_lo = c().get_lower_bound(m_jy);
rational const& y_hi = c().get_upper_bound(m_jy);
if (x_lo == x_hi || y_lo == y_hi)
return false;
// negate_relation requires the model value to be strictly separated
// from the corner coordinate it's compared to. If LP currently sits
// exactly at a box edge, fall back.
rational const& vx = c().val(m_jx);
rational const& vy = c().val(m_jy);
if (vx == x_lo || vx == x_hi || vy == y_lo || vy == y_hi)
return false;
if (m_below) {
// Under-approximation: tangents at (x_lo, y_lo) and (x_hi, y_hi)
// bound xy from below across the box.
m_a = point(x_lo, y_lo);
m_b = point(x_hi, y_hi);
} else {
// Over-approximation: anti-diagonal corners.
m_a = point(x_lo, y_hi);
m_b = point(x_hi, y_lo);
}
m_pl_strict_interior = false;
return true;
}
void get_points() { void get_points() {
if (c().params().arith_nl_tangents_box_corners() && set_box_corners()) {
// Box corners are extremes; pushing further moves out of the box
// and would invalidate the McCormick property.
TRACE(nla_solver, tout << "xy = " << m_xy << ", box-corner points: ";
print_tangent_domain(tout) << std::endl;);
return;
}
get_initial_points(); get_initial_points();
TRACE(nla_solver, tout << "xy = " << m_xy << ", correct val = " << m_correct_v; TRACE(nla_solver, tout << "xy = " << m_xy << ", correct val = " << m_correct_v;
print_tangent_domain(tout << "\ntang points:") << std::endl;); print_tangent_domain(tout << "\ntang points:") << std::endl;);
push_point(m_a); push_point(m_a);
push_point(m_b); push_point(m_b);
TRACE(nla_solver, TRACE(nla_solver,
tout << "pushed a = " << m_a << std::endl tout << "pushed a = " << m_a << std::endl
<< "pushed b = " << m_b << std::endl << "pushed b = " << m_b << std::endl
<< "tang_plane(a) = " << tang_plane(m_a) << " , val = " << m_a << ", " << "tang_plane(a) = " << tang_plane(m_a) << " , val = " << m_a << ", "
<< "tang_plane(b) = " << tang_plane(m_b) << " , val = " << m_b << std::endl;); << "tang_plane(b) = " << tang_plane(m_b) << " , val = " << m_b << std::endl;);

View file

@ -18,9 +18,10 @@ class nla_throttle {
public: public:
enum throttle_kind { enum throttle_kind {
ORDER_LEMMA, // order lemma (9 params) ORDER_LEMMA, // order lemma (9 params)
BINOMIAL_SIGN_LEMMA, // binomial sign (6 params) BINOMIAL_SIGN_LEMMA, // binomial sign (6 params)
MONOTONE_LEMMA, // monotonicity (2 params) MONOTONE_LEMMA, // monotonicity (2 params)
TANGENT_LEMMA // tangent lemma (5 params: monic_var, x_var, y_var, below, plane_type) TANGENT_LEMMA, // tangent lemma (5 params: monic_var, x_var, y_var, below, plane_type)
MONOMIAL_BINOMIAL_SIGN // monomial binomial sign anchor (4 params: monic_var, u, v, below)
}; };
private: private:

View file

@ -67,8 +67,10 @@ def_module_params(module_name='smt',
('arith.nl.expensive_patching', BOOL, False, 'use the expensive of monomials'), ('arith.nl.expensive_patching', BOOL, False, 'use the expensive of monomials'),
('arith.nl.rounds', UINT, 1024, 'threshold for number of (nested) final checks for non linear arithmetic, relevant only if smt.arith.solver=2'), ('arith.nl.rounds', UINT, 1024, 'threshold for number of (nested) final checks for non linear arithmetic, relevant only if smt.arith.solver=2'),
('arith.nl.order', BOOL, True, 'run order lemmas'), ('arith.nl.order', BOOL, True, 'run order lemmas'),
('arith.nl.order.binomial_sign', BOOL, True, 'run order_lemma_on_binomial_sign; disabling it keeps the structural order-lemma splitting'),
('arith.nl.expp', BOOL, False, 'expensive patching'), ('arith.nl.expp', BOOL, False, 'expensive patching'),
('arith.nl.tangents', BOOL, True, 'run tangent lemmas'), ('arith.nl.tangents', BOOL, True, 'run tangent lemmas'),
('arith.nl.tangents.box_corners', BOOL, False, 'choose tangent-plane points at the bound-box corners instead of the model-centered val(x) +/- delta; produces the McCormick under/over envelope and is deterministic and snapshot-independent'),
('arith.nl.horner', BOOL, True, 'run horner\'s heuristic'), ('arith.nl.horner', BOOL, True, 'run horner\'s heuristic'),
('arith.nl.horner_subs_fixed', UINT, 2, '0 - no subs, 1 - substitute, 2 - substitute fixed zeros only'), ('arith.nl.horner_subs_fixed', UINT, 2, '0 - no subs, 1 - substitute, 2 - substitute fixed zeros only'),
('arith.nl.horner_frequency', UINT, 4, 'horner\'s call frequency'), ('arith.nl.horner_frequency', UINT, 4, 'horner\'s call frequency'),
@ -87,6 +89,9 @@ def_module_params(module_name='smt',
('arith.nl.gr_q', UINT, 10, 'grobner\'s quota'), ('arith.nl.gr_q', UINT, 10, 'grobner\'s quota'),
('arith.nl.grobner_subs_fixed', UINT, 1, '0 - no subs, 1 - substitute, 2 - substitute fixed zeros only'), ('arith.nl.grobner_subs_fixed', UINT, 1, '0 - no subs, 1 - substitute, 2 - substitute fixed zeros only'),
('arith.nl.grobner_expand_terms', BOOL, True, 'expand terms before computing grobner basis'), ('arith.nl.grobner_expand_terms', BOOL, True, 'expand terms before computing grobner basis'),
('arith.nl.monomial_sandwich', BOOL, False, 'derive bound on a monomial factor by pairing two LP rows that share the other factor'),
('arith.nl.monomial_sandwich.max_fanout', UINT, 0, 'skip monomial sandwich when the conclusion factor appears in more than this many monomials (0 = no limit)'),
('arith.nl.monomial_binomial_sign', BOOL, False, 'derive bound on a binomial-monomial factor anchored on the current LP value of the monomial; replaces order_lemma_on_binomial_sign with a deterministic factor bound conditioned on a one-sided snapshot of the monomial value'),
('arith.nl.reduce_pseudo_linear', BOOL, True, 'create incremental linearization axioms for pseudo-linear monomials'), ('arith.nl.reduce_pseudo_linear', BOOL, True, 'create incremental linearization axioms for pseudo-linear monomials'),
('arith.nl.delay', UINT, 10, 'number of calls to final check before invoking bounded nlsat check'), ('arith.nl.delay', UINT, 10, 'number of calls to final check before invoking bounded nlsat check'),
('arith.nl.propagate_linear_monomials', BOOL, True, 'propagate linear monomials'), ('arith.nl.propagate_linear_monomials', BOOL, True, 'propagate linear monomials'),