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}