1use js_sys::Function;
4use std::collections::HashMap;
5use std::io::Write as _;
6use std::path::PathBuf;
7use std::process::{Command, Stdio};
8use wasm_bindgen::prelude::*;
9
10use super::Constraint;
11use super::Verdict;
12use super::poly::{
13 Poly,
14 coef::Coef,
15 mono::{Mono, Var},
16};
17
18pub struct Linearized {
21 pub facts: Vec<Constraint>,
22 pub goal: Constraint,
23 pub nonneg: Vec<Var>,
24 pub pure_linear: bool,
25 pub names: Vec<String>,
26}
27
28impl Linearized {
29 pub fn new(
30 facts: &[Constraint],
31 goal: &Constraint,
32 vars: &[String],
33 nonneg: &dyn Fn(Var) -> bool,
34 ) -> Self {
35 let base = vars.len() as u32;
36 let mut atoms: Vec<Mono> = Vec::new();
37 let mut linearize_poly = |p: &Poly| -> Poly {
38 let terms = p.terms().into_iter().map(|(c, m)| {
39 if m.total_degree() <= 1 {
40 (c, m.clone())
41 } else {
42 let idx = match atoms.iter().position(|a| *a == m) {
43 Some(i) => i,
44 None => {
45 atoms.push(m.clone());
46 atoms.len() - 1
47 }
48 };
49 (c, Mono::new([(base + idx as u32, 1)]))
50 }
51 });
52 Poly::from_terms(terms)
53 };
54
55 let mut linearize_constraint = |c: &Constraint| Constraint {
56 lhs: linearize_poly(&c.lhs),
57 cmp: c.cmp,
58 rhs: linearize_poly(&c.rhs),
59 };
60
61 let facts: Vec<Constraint> = facts.iter().map(&mut linearize_constraint).collect();
62 let goal = linearize_constraint(goal);
63
64 let mut nonneg_vars: Vec<Var> = (0..base).filter(|&v| nonneg(v)).collect();
65 let mut names: Vec<String> = vars.to_vec();
66 for (i, m) in atoms.iter().enumerate() {
67 if m.exps().iter().all(|&(v, _)| nonneg(v)) {
69 nonneg_vars.push(base + i as u32);
70 }
71 let rendered: Vec<String> = m
72 .exps()
73 .iter()
74 .map(|&(v, e)| {
75 let name = &vars[v as usize];
76 if e > 1 {
77 format!("{name}^{e}")
78 } else {
79 name.to_string()
80 }
81 })
82 .collect();
83 names.push(rendered.join("*"));
84 }
85
86 Self {
87 pure_linear: atoms.is_empty(),
88 facts,
89 goal,
90 nonneg: nonneg_vars,
91 names,
92 }
93 }
94}
95
96enum Z3Kind {
97 Callback(Box<dyn Fn(&str) -> String>),
98 Cli,
99}
100
101pub struct Z3 {
103 pub queries: usize,
105 pub z3_calls: usize,
107 cache: HashMap<u64, Verdict>,
108 kind: Z3Kind,
109 cache_dir: Option<PathBuf>,
111}
112
113impl Z3 {
114 pub fn new_cli(cache_dir: Option<PathBuf>) -> Option<Self> {
117 let z3_available = Command::new("z3")
118 .arg("-version")
119 .stdout(Stdio::null())
120 .stderr(Stdio::null())
121 .status()
122 .is_ok_and(|s| s.success());
123
124 z3_available.then_some(Self {
125 cache: HashMap::new(),
126 queries: 0,
127 z3_calls: 0,
128 kind: Z3Kind::Cli,
129 cache_dir,
130 })
131 }
132
133 pub fn new_wasm(callback: Function) -> Self {
135 let wrapped_callback = move |s: &str| {
136 let res = callback
137 .call1(&JsValue::NULL, &JsValue::from_str(s))
138 .and_then(|val| val.as_string().ok_or("callback returned non-string".into()));
139 match res {
140 Ok(model) => model,
141 Err(_) => "".into(),
142 }
143 };
144
145 Self {
146 cache: HashMap::new(),
147 queries: 0,
148 z3_calls: 0,
149 kind: Z3Kind::Callback(Box::new(wrapped_callback)),
150 cache_dir: None,
151 }
152 }
153
154 pub fn entails_lia(
156 &mut self,
157 facts: &[Constraint],
158 goal: &Constraint,
159 nonneg: &[Var],
160 names: &[&str],
161 ) -> Verdict {
162 self.queries += 1;
163 let mut vars: Vec<Var> = Vec::new();
164 for c in facts.iter().chain(std::iter::once(goal)) {
165 for p in [&c.lhs, &c.rhs] {
166 debug_assert!(p.total_degree() <= 1);
167 for v in p.vars() {
168 if !vars.contains(&v) {
169 vars.push(v);
170 }
171 }
172 }
173 }
174 vars.sort_unstable();
175
176 let script = build_script(facts, goal, nonneg, &vars);
177 let key = fnv1a_hash(script.as_bytes());
178 if let Some(v) = self.cache.get(&key) {
179 return v.clone();
180 }
181
182 self.z3_calls += 1;
183 let verdict = self.run_z3(&script, &vars);
184 if let Some(dir) = &self.cache_dir {
185 let _ = std::fs::create_dir_all(dir);
186 let mut file = String::new();
187 file.push_str(&format!("; obligation: {}\n", render(goal, names)));
188 for f in facts {
189 file.push_str(&format!("; given: {}\n", render(f, names)));
190 }
191 file.push_str(&format!("; verdict: {}\n", verdict_tag(&verdict)));
192 file.push_str(&script);
193 let _ = std::fs::write(dir.join(format!("{key:016x}.smt2")), file);
194 }
195 self.cache.insert(key, verdict.clone());
196 verdict
197 }
198
199 fn run_z3(&mut self, script: &str, vars: &[Var]) -> Verdict {
200 let stdout = match &mut self.kind {
201 Z3Kind::Cli => {
202 let Ok(mut child) = Command::new("z3")
203 .arg("-smt2")
204 .arg("-in")
205 .stdin(Stdio::piped())
206 .stdout(Stdio::piped())
207 .stderr(Stdio::null())
208 .spawn()
209 else {
210 return Verdict::Unknown;
211 };
212 if let Some(stdin) = child.stdin.take() {
213 let mut stdin = stdin;
214 if stdin.write_all(script.as_bytes()).is_err() {
215 let _ = child.kill();
216 return Verdict::Unknown;
217 }
218 }
219 let Ok(out) = child.wait_with_output() else {
220 return Verdict::Unknown;
221 };
222 let stdout = String::from_utf8_lossy(&out.stdout);
223 stdout.to_string()
224 }
225 Z3Kind::Callback(callback) => callback(script),
226 };
227
228 let mut stdout_lines = stdout.lines();
229
230 match stdout_lines.next().map(str::trim) {
231 Some("unsat") => Verdict::Proved,
232 Some("sat") => {
233 let rest: String = stdout_lines.collect::<Vec<_>>().join(" ");
234 Verdict::RefutedAt(parse_model(&rest, vars))
235 }
236 _ => Verdict::Unknown,
237 }
238 }
239}
240
241fn verdict_tag(v: &Verdict) -> &'static str {
242 match v {
243 Verdict::Proved => "proved (unsat)",
244 Verdict::RefutedBy(_) => "refuted (sat)",
245 Verdict::RefutedAt { .. } => "refuted (sat)",
246 Verdict::Unknown => "unknown",
247 }
248}
249
250fn render(c: &Constraint, names: &[&str]) -> String {
251 format!(
252 "{} {} {}",
253 c.lhs.display_with(Some(names)),
254 c.cmp,
255 c.rhs.display_with(Some(names))
256 )
257}
258
259fn build_script(facts: &[Constraint], goal: &Constraint, nonneg: &[Var], vars: &[Var]) -> String {
260 let mut s = String::new();
261 s.push_str("(set-option :timeout 2000)\n");
262 s.push_str("(set-logic QF_LIA)\n");
263 for v in vars {
264 s.push_str(&format!("(declare-const v{} Int)\n", v));
265 }
266 for v in vars {
267 if nonneg.contains(v) {
268 s.push_str(&format!("(assert (>= v{} 0))\n", v));
269 }
270 }
271 for f in facts {
272 s.push_str(&format!("(assert {})\n", smt_cmp(f)));
273 }
274 s.push_str(&format!("(assert (not {}))\n", smt_cmp(goal)));
275 s.push_str("(check-sat)\n");
276 if !vars.is_empty() {
277 let list: Vec<String> = vars.iter().map(|v| format!("v{}", v)).collect();
278 s.push_str(&format!("(get-value ({}))\n", list.join(" ")));
279 }
280 s
281}
282
283fn smt_cmp(c: &Constraint) -> String {
284 format!(
285 "({} {} {})",
286 c.cmp.smt(),
287 smt_poly(&c.lhs),
288 smt_poly(&c.rhs)
289 )
290}
291
292fn smt_poly(p: &Poly) -> String {
293 if p.is_zero() {
294 return "0".to_string();
295 }
296 let terms: Vec<String> = p
297 .terms()
298 .iter()
299 .map(|(c, m)| {
300 let coef = smt_int(*c);
301 match m.exps() {
302 [] => coef,
303 [(v, 1)] => {
304 if *c == 1.into() {
305 format!("v{}", v)
306 } else {
307 format!("(* {coef} v{})", v)
308 }
309 }
310 _ => unreachable!("not linearized"),
311 }
312 })
313 .collect();
314 if terms.len() == 1 {
315 terms.into_iter().next().unwrap()
316 } else {
317 format!("(+ {})", terms.join(" "))
318 }
319}
320
321fn smt_int(c: Coef) -> String {
322 if !c.is_zero() && !c.is_positive() {
323 format!("(- {:?})", c.abs())
324 } else {
325 format!("{:?}", c.abs())
326 }
327}
328
329fn parse_model(text: &str, vars: &[Var]) -> Vec<(Var, i128)> {
330 let mut model = Vec::new();
331 let cleaned = text.replace(['(', ')'], " ");
332 let tokens: Vec<&str> = cleaned.split_whitespace().collect();
333 let mut i = 0;
334 while i < tokens.len() {
335 if let Some(idx) = tokens[i]
336 .strip_prefix('v')
337 .and_then(|s| s.parse::<u32>().ok())
338 {
339 let (value, used) = match tokens.get(i + 1) {
340 Some(&"-") => (
341 tokens
342 .get(i + 2)
343 .and_then(|t| t.parse::<i128>().ok())
344 .map(|k| -k),
345 3,
346 ),
347 Some(t) => (t.parse::<i128>().ok(), 2),
348 None => (None, 1),
349 };
350 if let Some(value) = value
351 && vars.contains(&(idx as Var))
352 {
353 model.push(((idx as Var), value));
354 }
355 i += used;
356 } else {
357 i += 1;
358 }
359 }
360 model
361}
362
363fn fnv1a_hash(bytes: &[u8]) -> u64 {
364 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
365 for &b in bytes {
366 hash ^= u64::from(b);
367 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
368 }
369 hash
370}
371
372#[cfg(test)]
373mod tests {
374 use crate::solver::poly::{Poly, poly};
375 use crate::solver::{Cmp, Constraint};
376
377 use super::*;
378
379 #[test]
380 fn model_script() {
381 let facts = [Constraint::new(poly!(n), Cmp::Eq, poly!(m))];
382 let goal = Constraint::new(poly!(m), Cmp::Eq, poly!(n));
383 let script = "(set-option :timeout 2000)
384(set-logic QF_LIA)
385(declare-const v109 Int)
386(declare-const v110 Int)
387(assert (= v110 v109))
388(assert (not (= v109 v110)))
389(check-sat)
390(get-value (v109 v110))
391";
392 assert_eq!(script, build_script(&facts, &goal, &[], &[109, 110]))
393 }
394
395 #[test]
396 fn entailment_with_context() {
397 let mut s = Z3::new_cli(None).unwrap();
398 let facts = [Constraint::new(poly!(n), Cmp::Ge, poly!(1))];
400 let goal = Constraint::new(poly!(0), Cmp::Lt, poly!(n));
401 assert_eq!(s.entails_lia(&facts, &goal, &[], &["N"]), Verdict::Proved);
402 let goal = Constraint::new(poly!(1), Cmp::Lt, poly!(n));
404 match s.entails_lia(&facts, &goal, &['n' as Var], &["N"]) {
405 Verdict::RefutedAt(point) => assert_eq!(point, vec![('n' as Var, 1)]),
406 v => panic!("expected refutation, got {v:?}"),
407 }
408 }
409
410 #[test]
411 fn integer_reasoning_not_real() {
412 let mut s = Z3::new_cli(None).unwrap();
413 let facts = [Constraint::new(poly!(2 * n), Cmp::Ge, poly!(1))];
415 let goal = Constraint::new(poly!(n), Cmp::Ge, poly!(1));
416 assert_eq!(s.entails_lia(&facts, &goal, &[], &["N"]), Verdict::Proved);
417 }
418
419 #[test]
420 fn commute() {
421 let mut s = Z3::new_cli(None).unwrap();
422 let facts = [Constraint::new(poly!(n), Cmp::Eq, poly!(m))];
424 let goal = Constraint::new(poly!(m), Cmp::Eq, poly!(n));
425 assert_eq!(
426 s.entails_lia(&facts, &goal, &[], &["N", "M"]),
427 Verdict::Proved
428 );
429 assert_eq!(
431 s.entails_lia(&facts, &goal, &[], &["N", "M"]),
432 Verdict::Proved
433 );
434 }
435
436 #[test]
437 fn linearized() {
438 let vars = ["N".into(), "M".into()];
439 let nm = Poly::var(0u32, 1).mul(&Poly::var(1u32, 1));
440
441 let fact = Constraint {
442 lhs: nm.clone(),
443 cmp: Cmp::Ge,
444 rhs: poly!(4),
445 };
446 let goal = Constraint {
447 lhs: poly!(4),
448 cmp: Cmp::Le,
449 rhs: nm,
450 };
451
452 let lin = Linearized::new(&[fact], &goal, &vars, &|_| true);
453 assert!(!lin.pure_linear);
454 assert_eq!(lin.facts[0].lhs, lin.goal.rhs);
455 assert_eq!(lin.facts[0].lhs, Poly::var(2u32, 1));
456
457 assert!(lin.nonneg.contains(&2));
458 assert_eq!(lin.names[2], "N*M");
459 }
460}