Skip to main content

sirius/solver/
t2.rs

1//! Bounded-degree positivity certificates for non-linear goals.
2//!
3//! Given the code:
4//! ```text
5//! fn blocks{A, B}(arr: f32[A*B]) -> f32[A, B]:
6//!     for i from 0 to A:
7//!         for j from 0 to B:
8//!             yield arr[i*B + j]
9//! ```
10//!
11//! The solver is given the goal `i*B + j < A*B` to verify the `arr` access. Linearization
12//! replaces `i*B` and `A*B` with fresh atoms, so [`t1`](super::t1) cannot solve the system using the constraint `i < A`.
13//!
14//! The goal and all other [`Constraint`]s (except [`Ne`](super::Cmp::Ne), a conjunction for [`Eq`](super::Cmp::Eq)) can be written as non-negative facts:
15//!
16//! $$iB + j < AB \iff AB - iB - j - 1 \geq 0$$
17//!
18//! Then the goal is a non-negative combination of products of facts already given:
19//!
20//! $$AB - iB - j - 1 = B(A - i - 1) + 1(B - j - 1)$$
21//!
22//! Or generally,
23//!
24//! $$s \cdot \text{goal} = \sum_{k} c_kp_k$$
25//! for some constant integers $c\geq0$, products $p$ of non-negative facts, and a scale factor $s$  to permit rational certificates. This is a [Handelman](https://projecteuclid.org/journals/pacific-journal-of-mathematics/volume-132/issue-1/Representing-polynomials-by-positive-linear-functions-on-compact-convex-polyhedra/pjm/1102689794.full)
26//! representation (in this case, degree-2).
27//!
28//! This module searches for such solutions to non-linear systems. We do so by generating $p$ candidates using non-negative monomial divisors of the goal and non-negative facts from context $h$. For example, supposing these six candidate products are generated:
29//!
30//! |        |                                                  |
31//! | ------ | ------------------------------------------------ |
32//! | $p_1$  | $1 = 1$                                          |
33//! | $p_2$  | $h_1 = A - i - 1$                                |
34//! | $p_3$  | $h_2 = B - j - 1$                                |
35//! | $p_4$  | $Bh_1 = AB - iB - B$                             |
36//! | $p_5$  | $Bh_2 = B^2 - jB - B$                            |
37//! | $p_6$  | $h_1h_2 = AB − jA − A − iB + ij + i − B + j + 1$ |
38//!
39//! We can construct a linear system of equations in $c$ and $s$ which builds the goal, monomial by monomial. Satisfying each equation means selecting $c$ such that the sum-of-products has the correct $M$ term; $\sum_k c_k \cdot \text{coef}_{p_k}(M) = s \cdot \text{coef}\_{\text{goal}}(M)$.
40//!
41//! |  $M$  | $c_1$ | $c_2$ | $c_3$ | $c_4$ | $c_5$ | $c_6$ | $=s \cdot$ |
42//! | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ---------- |
43//! | $AB$  |       |       |       |  $1$  |       |  $1$  |        $1$ |
44//! | $iB$  |       |       |       | $-1$  |       | $-1$  |       $-1$ |
45//! | $j$   |       |       | $-1$  |       |       |  $1$  |       $-1$ |
46//! | $1$   |  $1$  | $-1$  | $-1$  |       |       |  $1$  |       $-1$ |
47//! | $A$   |       |  $1$  |       |       |       | $-1$  |            |
48//! | $i$   |       | $-1$  |       |       |       |  $1$  |            |
49//! | $B$   |       |       |  $1$  | $-1$  | $-1$  | $-1$  |            |
50//! | $B^2$ |       |       |       |       |  $1$  |       |            |
51//! | $jB$  |       |       |       |       | $-1$  |       |            |
52//! | $jA$  |       |       |       |       |       | $-1$  |            |
53//! | $ij$  |       |       |       |       |       |  $1$  |            |
54//!
55//! This system can be solved in [`t1`](super::t1), giving $c_3=1, c_4=1$, $s=1$, $\text{rest}=0$. The final sum is $p_3 + p_4 = \text{goal}$.
56//!
57//! ```
58//! use sirius::solver::{Constraint, Cmp, poly::{poly, Var}, t1::Z3, t2::prove};
59//!
60//! let mut z3 = Z3::new_cli(None).unwrap();
61//! let goal = Constraint::new(poly!(i*B + j), Cmp::Lt, poly!(A*B));
62//! let facts = [
63//!     Constraint::new(poly!(i), Cmp::Lt, poly!(A)),
64//!     Constraint::new(poly!(j), Cmp::Lt, poly!(B)),
65//! ];
66//! let nonneg = [
67//!     'i' as Var,
68//!     'j' as Var,
69//!     'A' as Var,
70//!     'B' as Var,
71//! ];
72//!
73//! assert!(prove(&mut z3, &facts, &goal, &nonneg));
74//! ```
75
76use crate::solver::poly::coef::Coef;
77use crate::solver::poly::{Mono, Poly, Var};
78use crate::solver::{Cmp, Constraint, Verdict, t1::Z3};
79
80/// Facts considered, starting with non-negative [`Var`]s, after filtering to those sharing a variable with the goal. [`Cmp::Eq`] constraints generate two facts.
81pub const MAX_FACTS: usize = 12;
82/// Monomial multipliers considered.
83pub const MAX_MULTIPLIERS: usize = 16;
84
85/// Try to prove nonlinear `goal` from `facts`.
86pub fn prove(z3: &mut Z3, facts: &[Constraint], goal: &Constraint, nonneg: &[Var]) -> bool {
87    let Some(goals) = goal_diffs(goal) else {
88        return false;
89    };
90
91    // a loop's `i >= 0` lowers to the same hypothesis as `i` being non-negative, so dedup before
92    // the MAX_FACTS cut or half the budget goes on repeats
93    let mut hypotheses: Vec<Poly> = vec![];
94    for &v in nonneg {
95        hypotheses.push(Poly::var(v, 1));
96    }
97    for f in facts {
98        hypotheses.extend(fact_diffs(f));
99    }
100    let mut seen: Vec<Poly> = vec![];
101    hypotheses.retain(|h| {
102        let fresh = !seen.contains(h);
103        if fresh {
104            seen.push(h.clone());
105        }
106        fresh
107    });
108
109    goals
110        .iter()
111        .all(|d| certify(z3, &hypotheses, nonneg, d).is_some())
112}
113
114/// `d >= 0` goals whose conjunction is equivalent to `c`. `!=` has no such form.
115fn goal_diffs(c: &Constraint) -> Option<Vec<Poly>> {
116    let fwd = c.rhs.sub(&c.lhs);
117    let back = c.lhs.sub(&c.rhs);
118    Some(match c.cmp {
119        Cmp::Le => vec![fwd],
120        Cmp::Lt => vec![fwd.sub(&Poly::constant(1))],
121        Cmp::Ge => vec![back],
122        Cmp::Gt => vec![back.sub(&Poly::constant(1))],
123        Cmp::Eq => vec![fwd, back],
124        Cmp::Ne => return None,
125    })
126}
127
128/// `f >= 0` facts implied by `c`.
129fn fact_diffs(c: &Constraint) -> Vec<Poly> {
130    let fwd = c.rhs.sub(&c.lhs);
131    let back = c.lhs.sub(&c.rhs);
132    match c.cmp {
133        Cmp::Le => vec![fwd],
134        Cmp::Lt => vec![fwd.sub(&Poly::constant(1))],
135        Cmp::Ge => vec![back],
136        Cmp::Gt => vec![back.sub(&Poly::constant(1))],
137        Cmp::Eq => vec![fwd, back],
138        Cmp::Ne => vec![],
139    }
140}
141
142/// Look for non-negative integers `c_k` and a scale `s >= 1` with `s*d == Σ c_k · p_k`, where each
143/// `p_k` is a non-negative product built from the hypotheses. Since every `p_k >= 0`, that forces
144/// `d >= 0`. The scale keeps rational certificates in reach without leaving integer arithmetic.
145fn certify(
146    solver: &mut Z3,
147    hypotheses: &[Poly],
148    nonneg: &[Var],
149    d: &Poly,
150) -> Option<Vec<(usize, i128)>> {
151    // a hypothesis sharing no variable with the goal cannot contribute a needed monomial, and
152    // only inflates the search
153    let goal_vars = d.vars();
154    let relevant: Vec<&Poly> = hypotheses
155        .iter()
156        .filter(|h| h.vars().iter().any(|v| goal_vars.contains(v)) || h.vars().is_empty())
157        .take(MAX_FACTS)
158        .collect();
159
160    // A row of a tiled access needs a monomial multiplier, not just another fact: the certificate
161    // for a three-deep nest is `B*C·(A-i-1) + C·(B-j-1) + (C-k-1)`. The multipliers that can
162    // appear are divisors of the goal's own monomials, so take those rather than enumerating
163    // every monomial up to some degree.
164    let multipliers = goal_multipliers(d, nonneg);
165
166    let mut products: Vec<Poly> = vec![];
167    for m in &multipliers {
168        let m = Poly::term(1, m.clone());
169        products.push(m.clone());
170        for h in &relevant {
171            products.push(m.mul(h));
172        }
173    }
174    for i in 0..relevant.len() {
175        for j in i..relevant.len() {
176            products.push(relevant[i].mul(relevant[j]));
177        }
178    }
179
180    // unknowns: products.len() coefficients, then the scale
181    let scale = products.len() as Var;
182    let mut system = vec![Constraint::new(
183        Poly::var(scale, 1),
184        Cmp::Ge,
185        Poly::constant(1),
186    )];
187
188    let mut monos: Vec<Mono> = vec![];
189    for p in products.iter().chain(std::iter::once(d)) {
190        for (_, m) in p.terms() {
191            if !monos.contains(&m) {
192                monos.push(m);
193            }
194        }
195    }
196
197    for m in &monos {
198        let mut lhs = Poly::zero();
199        for (k, p) in products.iter().enumerate() {
200            let c = coef_of(p, m);
201            if !c.is_zero() {
202                lhs = lhs.add(&Poly::var(k as Var, 1).mul_scalar(c));
203            }
204        }
205        let rhs = Poly::var(scale, 1).mul_scalar(coef_of(d, m));
206        system.push(Constraint::new(lhs, Cmp::Eq, rhs));
207    }
208
209    // the coefficients are the non-negative ones; the scale is bounded below by its own fact
210    let nonneg: Vec<Var> = (0..products.len() as Var).collect();
211    let names: Vec<String> = (0..=products.len()).map(|k| format!("c{k}")).collect();
212    let names: Vec<&str> = names.iter().map(String::as_str).collect();
213
214    // `entails_lia` refutes by exhibiting a model, so asking it to derive a contradiction from the
215    // system is how we ask whether the system is satisfiable
216    let contradiction = Constraint::new(Poly::zero(), Cmp::Eq, Poly::constant(1));
217    match solver.entails_lia(&system, &contradiction, &nonneg, &names) {
218        Verdict::RefutedAt(point) => Some(
219            point
220                .iter()
221                .filter(|(v, c)| *v < scale && *c != 0)
222                .map(|(v, c)| (*v as usize, *c))
223                .collect(),
224        ),
225        _ => None,
226    }
227}
228
229/// Divisors of the goal's monomials, restricted to variables known non-negative -- multiplying a
230/// hypothesis by one of these keeps it non-negative. Always includes `1`.
231fn goal_multipliers(d: &Poly, nonneg: &[Var]) -> Vec<Mono> {
232    let mut out = vec![Mono::unit()];
233    for (_, m) in d.terms() {
234        let exps: Vec<(Var, u32)> = m
235            .exps()
236            .iter()
237            .copied()
238            .filter(|(v, _)| nonneg.contains(v))
239            .collect();
240
241        // every way of lowering each exponent, `1` included
242        let mut divisors: Vec<Vec<(Var, u32)>> = vec![vec![]];
243        for &(v, e) in &exps {
244            divisors = divisors
245                .iter()
246                .flat_map(|base| {
247                    (0..=e).map(move |k| {
248                        let mut next = base.clone();
249                        if k > 0 {
250                            next.push((v, k));
251                        }
252                        next
253                    })
254                })
255                .collect();
256            if divisors.len() > MAX_MULTIPLIERS {
257                break;
258            }
259        }
260
261        for exps in divisors {
262            let mono = Mono::new(exps);
263            if !out.contains(&mono) {
264                out.push(mono);
265            }
266        }
267        if out.len() >= MAX_MULTIPLIERS {
268            break;
269        }
270    }
271    out
272}
273
274fn coef_of(p: &Poly, m: &Mono) -> Coef {
275    p.terms()
276        .iter()
277        .find(|(_, pm)| pm == m)
278        .map(|(c, _)| *c)
279        .unwrap_or(Coef::new(0))
280}
281
282#[cfg(test)]
283mod tests {
284    use super::prove;
285    use crate::solver::poly::{Var, poly};
286    use crate::solver::{Cmp, Constraint, Z3};
287
288    fn nonneg() -> Vec<Var> {
289        vec!['a' as Var, 'b' as Var, 'i' as Var, 'j' as Var]
290    }
291
292    #[test]
293    fn strided_access() {
294        let mut s = Z3::new_cli(None).unwrap();
295        // i < A, B > 0  |-  i*B < A*B
296        let facts = [
297            Constraint::new(poly!(i), Cmp::Lt, poly!(a)),
298            Constraint::new(poly!(b), Cmp::Gt, poly!(0)),
299        ];
300        let goal = Constraint::new(poly!(b * i), Cmp::Lt, poly!(a * b));
301        assert!(prove(&mut s, &facts, &goal, &nonneg()));
302    }
303
304    #[test]
305    fn tiled_access() {
306        let mut s = Z3::new_cli(None).unwrap();
307        // i < A, j < B, B > 0  |-  i*B + j < A*B
308        let facts = [
309            Constraint::new(poly!(i), Cmp::Lt, poly!(a)),
310            Constraint::new(poly!(j), Cmp::Lt, poly!(b)),
311            Constraint::new(poly!(b), Cmp::Gt, poly!(0)),
312        ];
313        let goal = Constraint::new(poly!(b * i + j), Cmp::Lt, poly!(a * b));
314        assert!(prove(&mut s, &facts, &goal, &nonneg()));
315    }
316
317    #[test]
318    fn unsound_variants_are_refused() {
319        let mut s = Z3::new_cli(None).unwrap();
320
321        // without B > 0 the goal is false at B = 0
322        let facts = [Constraint::new(poly!(i), Cmp::Lt, poly!(a))];
323        let goal = Constraint::new(poly!(b * i), Cmp::Lt, poly!(a * b));
324        assert!(!prove(&mut s, &facts, &goal, &nonneg()));
325
326        // A*B >= A + B is false at A = B = 1
327        let goal = Constraint::new(poly!(a * b), Cmp::Ge, poly!(a + b));
328        assert!(!prove(&mut s, &[], &goal, &nonneg()));
329    }
330
331    #[test]
332    fn handelman_boundary() {
333        let mut s = Z3::new_cli(None).unwrap();
334
335        // perfect square, but would require SDP dark magic
336        let goal = Constraint::new(poly!(a ^ 2 - 2 * a * b + b ^ 2), Cmp::Ge, poly!(0));
337        assert!(!prove(&mut s, &[], &goal, &nonneg()));
338    }
339}