logicaffeine_kernel/lia.rs
1//! Linear Integer Arithmetic via Fourier-Motzkin Elimination
2//!
3//! This module implements a decision procedure for linear arithmetic over the
4//! rationals: reify a Syntax goal into [`LinearExpr`] constraints, then decide
5//! unsatisfiability with Fourier-Motzkin elimination.
6//!
7//! # Exactness
8//!
9//! Coefficients are exact arbitrary-precision rationals
10//! ([`logicaffeine_base::numeric::Rational`]). The verdict feeds trusted
11//! reflection reductions, so the arithmetic must be exact at every magnitude:
12//! elimination multiplies coefficients pairwise, and a wrapped or declined
13//! product either flips a verdict (unsound) or loses a refutation
14//! (incomplete). There is no overflow path — the procedure is total.
15//!
16//! # Rational vs Integer Semantics
17//!
18//! This procedure decides satisfiability over the RATIONALS. It is sound for
19//! integer goals (a rationally-unsatisfiable system has no integer solution
20//! either) but incomplete for integer-specific facts — use [`crate::omega`]
21//! when discreteness matters (`x > 1 ⟹ x ≥ 2`).
22
23use std::collections::{BTreeMap, HashSet};
24
25pub use logicaffeine_base::numeric::Rational;
26
27use crate::reify::{extract_binary_app, extract_slit, extract_sname, extract_svar, VarInterner};
28use crate::term::Term;
29
30/// A linear expression of the form c₀ + c₁x₁ + c₂x₂ + ... + cₙxₙ.
31///
32/// Stored as a constant term plus a sparse map of variable coefficients.
33/// Variables with coefficient 0 are automatically removed.
34///
35/// # Representation
36///
37/// The expression `3 + 2x - y` is stored as:
38/// - `constant = 3`
39/// - `coefficients = {0: 2, 1: -1}` (assuming x is var 0, y is var 1)
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LinearExpr {
42 /// The constant term c₀.
43 pub constant: Rational,
44 /// Maps variable index to its coefficient (sparse representation).
45 pub coefficients: BTreeMap<i64, Rational>,
46}
47
48impl LinearExpr {
49 /// Create a constant expression
50 pub fn constant(c: Rational) -> Self {
51 LinearExpr {
52 constant: c,
53 coefficients: BTreeMap::new(),
54 }
55 }
56
57 /// Create a single variable expression: 1*x_idx + 0
58 pub fn var(idx: i64) -> Self {
59 let mut coeffs = BTreeMap::new();
60 coeffs.insert(idx, Rational::from_i64(1));
61 LinearExpr {
62 constant: Rational::zero(),
63 coefficients: coeffs,
64 }
65 }
66
67 /// Add two linear expressions
68 pub fn add(&self, other: &LinearExpr) -> LinearExpr {
69 let mut result = self.clone();
70 result.constant = result.constant.add(&other.constant);
71 for (var, coeff) in &other.coefficients {
72 let entry = result
73 .coefficients
74 .entry(*var)
75 .or_insert_with(Rational::zero);
76 *entry = entry.add(coeff);
77 if entry.is_zero() {
78 result.coefficients.remove(var);
79 }
80 }
81 result
82 }
83
84 /// Negate a linear expression
85 pub fn neg(&self) -> LinearExpr {
86 LinearExpr {
87 constant: self.constant.negated(),
88 coefficients: self
89 .coefficients
90 .iter()
91 .map(|(v, c)| (*v, c.negated()))
92 .collect(),
93 }
94 }
95
96 /// Subtract two linear expressions
97 pub fn sub(&self, other: &LinearExpr) -> LinearExpr {
98 self.add(&other.neg())
99 }
100
101 /// Scale a linear expression by a rational constant
102 pub fn scale(&self, c: &Rational) -> LinearExpr {
103 if c.is_zero() {
104 return LinearExpr::constant(Rational::zero());
105 }
106 LinearExpr {
107 constant: self.constant.mul(c),
108 coefficients: self
109 .coefficients
110 .iter()
111 .map(|(v, coeff)| (*v, coeff.mul(c)))
112 .filter(|(_, c)| !c.is_zero())
113 .collect(),
114 }
115 }
116
117 /// Check if this is a constant expression (no variables)
118 pub fn is_constant(&self) -> bool {
119 self.coefficients.is_empty()
120 }
121
122 /// Get coefficient of a variable (0 if not present)
123 pub fn get_coeff(&self, var: i64) -> Rational {
124 self.coefficients
125 .get(&var)
126 .cloned()
127 .unwrap_or_else(Rational::zero)
128 }
129}
130
131/// A linear constraint representing either `expr <= 0` or `expr < 0`.
132///
133/// All inequalities are normalized to this form during processing.
134/// For example, `x >= 5` becomes `-x + 5 <= 0`, i.e., `5 - x <= 0`.
135#[derive(Debug, Clone)]
136pub struct Constraint {
137 /// The linear expression (constraint is expr OP 0).
138 pub expr: LinearExpr,
139 /// If true, this is a strict inequality (`< 0`).
140 /// If false, this is a non-strict inequality (`<= 0`).
141 pub strict: bool,
142}
143
144impl Constraint {
145 /// Check if a constant constraint is satisfied
146 /// For non-constant constraints, returns true (we can't tell yet)
147 pub fn is_satisfied_constant(&self) -> bool {
148 if !self.expr.is_constant() {
149 return true; // Can't determine yet
150 }
151 let c = &self.expr.constant;
152 if self.strict {
153 c.is_negative() // c < 0
154 } else {
155 !c.is_positive() // c ≤ 0
156 }
157 }
158}
159
160/// Error during reification to linear expression
161#[derive(Debug)]
162pub enum LiaError {
163 /// Expression is not linear (e.g., x*y)
164 NonLinear(String),
165 /// Malformed term structure
166 MalformedTerm,
167 /// Goal is not an inequality
168 NotInequality,
169}
170
171/// Reify a Syntax term to a linear expression.
172///
173/// Converts the deep embedding of terms (Syntax) into a linear expression
174/// suitable for Fourier-Motzkin elimination.
175///
176/// # Supported Forms
177///
178/// - `SLit n` - Integer literal becomes a constant
179/// - `SVar i` - De Bruijn variable becomes a linear variable
180/// - `SName "x"` - Named global becomes a linear variable (interned)
181/// - `SApp (SApp (SName "add") a) b` - Linear addition
182/// - `SApp (SApp (SName "sub") a) b` - Linear subtraction
183/// - `SApp (SApp (SName "mul") c) x` - Scaling (only if one operand is constant)
184///
185/// Every term reified for one goal (both sides of a comparison, hypotheses
186/// and conclusion) must share one `vars` interner, or their variable indices
187/// will not line up.
188///
189/// # Errors
190///
191/// Returns [`LiaError::NonLinear`] if the term contains non-linear operations
192/// (e.g., multiplication of two variables).
193pub fn reify_linear(term: &Term, vars: &mut VarInterner) -> Result<LinearExpr, LiaError> {
194 // SLit n -> constant
195 if let Some(n) = extract_slit(term) {
196 return Ok(LinearExpr::constant(Rational::from_i64(n)));
197 }
198
199 // SVar i -> variable
200 if let Some(i) = extract_svar(term) {
201 return Ok(LinearExpr::var(i));
202 }
203
204 // SName "x" -> named variable (global constant treated as free variable)
205 if let Some(name) = extract_sname(term) {
206 return Ok(LinearExpr::var(vars.intern(&name)));
207 }
208
209 // Binary operations
210 if let Some((op, a, b)) = extract_binary_app(term) {
211 match op.as_str() {
212 "add" => {
213 let la = reify_linear(&a, vars)?;
214 let lb = reify_linear(&b, vars)?;
215 return Ok(la.add(&lb));
216 }
217 "sub" => {
218 let la = reify_linear(&a, vars)?;
219 let lb = reify_linear(&b, vars)?;
220 return Ok(la.sub(&lb));
221 }
222 "mul" => {
223 let la = reify_linear(&a, vars)?;
224 let lb = reify_linear(&b, vars)?;
225 // Only linear if one side is constant
226 if la.is_constant() {
227 return Ok(lb.scale(&la.constant));
228 }
229 if lb.is_constant() {
230 return Ok(la.scale(&lb.constant));
231 }
232 return Err(LiaError::NonLinear(
233 "multiplication of two variables is not linear".to_string(),
234 ));
235 }
236 "div" | "mod" => {
237 return Err(LiaError::NonLinear(format!(
238 "operation '{}' is not supported in lia",
239 op
240 )));
241 }
242 _ => {
243 return Err(LiaError::NonLinear(format!("unknown operation '{}'", op)));
244 }
245 }
246 }
247
248 Err(LiaError::MalformedTerm)
249}
250
251/// Extract comparison from goal: (SApp (SApp (SName "Lt"|"Le"|"Gt"|"Ge") lhs) rhs)
252pub fn extract_comparison(term: &Term) -> Option<(String, Term, Term)> {
253 if let Some((rel, lhs, rhs)) = extract_binary_app(term) {
254 match rel.as_str() {
255 "Lt" | "Le" | "Gt" | "Ge" | "lt" | "le" | "gt" | "ge" => {
256 return Some((rel, lhs, rhs));
257 }
258 _ => {}
259 }
260 }
261 None
262}
263
264/// Convert a goal to constraints for validity checking.
265///
266/// To prove a goal is valid, we check if its negation is unsatisfiable.
267/// - Lt(a, b) is valid iff a - b < 0 always, i.e., negation a - b >= 0 is unsat
268/// - Le(a, b) is valid iff a - b <= 0 always, i.e., negation a - b > 0 is unsat
269pub fn goal_to_negated_constraint(
270 rel: &str,
271 lhs: &LinearExpr,
272 rhs: &LinearExpr,
273) -> Option<Constraint> {
274 // diff = lhs - rhs
275 let diff = lhs.sub(rhs);
276
277 match rel {
278 // Lt: a < b valid iff NOT(a >= b), i.e., a - b >= 0 is unsat.
279 // a >= b means a - b >= 0; in constraint form (expr <= 0) that is
280 // (rhs - lhs) <= 0.
281 "Lt" | "lt" => Some(Constraint {
282 expr: rhs.sub(lhs),
283 strict: false, // <= 0
284 }),
285 // Le: a <= b valid iff NOT(a > b), i.e., a - b > 0 is unsat.
286 // a > b means a - b > 0; in constraint form: (rhs - lhs) < 0.
287 "Le" | "le" => Some(Constraint {
288 expr: rhs.sub(lhs),
289 strict: true, // < 0
290 }),
291 // Gt: a > b valid iff NOT(a <= b), i.e., a - b <= 0 is unsat.
292 "Gt" | "gt" => Some(Constraint {
293 expr: diff, // (lhs - rhs) <= 0
294 strict: false,
295 }),
296 // Ge: a >= b valid iff NOT(a < b), i.e., a - b < 0 is unsat.
297 "Ge" | "ge" => Some(Constraint {
298 expr: diff, // (lhs - rhs) < 0
299 strict: true,
300 }),
301 _ => None,
302 }
303}
304
305/// Check if a constraint set is unsatisfiable using Fourier-Motzkin elimination.
306///
307/// This is the core decision procedure. It eliminates variables one by one
308/// until only constant constraints remain, then checks for contradictions.
309///
310/// # Algorithm
311///
312/// For each variable x in the system:
313/// 1. Partition constraints into lower bounds on x, upper bounds on x, and independent
314/// 2. For each pair (lower, upper), derive a new constraint without x
315/// 3. Check for immediate contradictions (e.g., `5 <= 0`)
316///
317/// # Returns
318///
319/// - `true` if the constraints are unsatisfiable (contradiction found)
320/// - `false` if the constraints may be satisfiable
321///
322/// # Usage for Validity
323///
324/// To prove a goal G is valid, we check if NOT(G) is unsatisfiable.
325/// If `fourier_motzkin_unsat(negation_constraints)` returns true, the goal is valid.
326pub fn fourier_motzkin_unsat(constraints: &[Constraint]) -> bool {
327 if constraints.is_empty() {
328 return false; // Empty set is satisfiable
329 }
330
331 // Collect all variables (sorted for a deterministic elimination order —
332 // the verdict is order-invariant, but reproducibility is not).
333 let mut vars: Vec<i64> = constraints
334 .iter()
335 .flat_map(|c| c.expr.coefficients.keys().copied())
336 .collect::<HashSet<_>>()
337 .into_iter()
338 .collect();
339 vars.sort_unstable();
340
341 let mut current = constraints.to_vec();
342
343 // Eliminate each variable
344 for var in vars {
345 current = eliminate_variable(¤t, var);
346
347 // Early termination: check for constant contradictions
348 for c in ¤t {
349 if c.expr.is_constant() && !c.is_satisfied_constant() {
350 return true; // Contradiction found!
351 }
352 }
353 }
354
355 // Check all remaining constant constraints
356 current.iter().any(|c| !c.is_satisfied_constant())
357}
358
359/// Eliminate a variable from a set of constraints using Fourier-Motzkin.
360///
361/// Partitions constraints into:
362/// - Lower bounds: x >= expr (coeff < 0 means -|c|*x + rest <= 0 => x >= rest/|c|)
363/// - Upper bounds: x <= expr (coeff > 0 means c*x + rest <= 0 => x <= -rest/c)
364/// - Independent: doesn't contain variable
365///
366/// Combines each lower with each upper to get new constraints without the variable.
367fn eliminate_variable(constraints: &[Constraint], var: i64) -> Vec<Constraint> {
368 let mut lower: Vec<(LinearExpr, bool)> = vec![]; // lower bound on var
369 let mut upper: Vec<(LinearExpr, bool)> = vec![]; // upper bound on var
370 let mut independent: Vec<Constraint> = vec![];
371
372 for c in constraints {
373 let coeff = c.expr.get_coeff(var);
374 if coeff.is_zero() {
375 independent.push(c.clone());
376 continue;
377 }
378 // c.expr = coeff*var + rest (OP) 0, with OP ∈ {<=, <}. Isolate var by
379 // dividing through by `coeff`: the bound expression is `-rest/coeff`.
380 // For coeff > 0 this is an UPPER bound (var <= -rest/coeff); for
381 // coeff < 0 dividing flips the relation into a LOWER bound
382 // (var >= -rest/coeff). The division is what makes the combined
383 // `lo <= hi` constraint correct for |coeff| ≠ 1.
384 let mut rest = c.expr.clone();
385 rest.coefficients.remove(&var);
386 let inv = coeff.recip().expect("coefficient is nonzero");
387 let bound = rest.neg().scale(&inv);
388 if coeff.is_positive() {
389 upper.push((bound, c.strict));
390 } else {
391 lower.push((bound, c.strict));
392 }
393 }
394
395 // lo <= var <= hi ⟹ lo <= hi must hold (strict if either bound is strict).
396 for (lo_expr, lo_strict) in &lower {
397 for (hi_expr, hi_strict) in &upper {
398 // In constraint form: lo - hi <= 0 (or < 0).
399 let diff = lo_expr.sub(hi_expr);
400 independent.push(Constraint {
401 expr: diff,
402 strict: *lo_strict || *hi_strict,
403 });
404 }
405 }
406
407 independent
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn test_rational_arithmetic() {
416 let half = Rational::from_ratio_i64(1, 2).unwrap();
417 let third = Rational::from_ratio_i64(1, 3).unwrap();
418 let sum = half.add(&third);
419 assert_eq!(sum, Rational::from_ratio_i64(5, 6).unwrap());
420 }
421
422 #[test]
423 fn test_linear_expr_add() {
424 let x = LinearExpr::var(0);
425 let y = LinearExpr::var(1);
426 let sum = x.add(&y);
427 assert!(!sum.is_constant());
428 assert_eq!(sum.get_coeff(0), Rational::from_i64(1));
429 assert_eq!(sum.get_coeff(1), Rational::from_i64(1));
430 }
431
432 #[test]
433 fn test_linear_expr_cancel() {
434 let x = LinearExpr::var(0);
435 let neg_x = x.neg();
436 let zero = x.add(&neg_x);
437 assert!(zero.is_constant());
438 assert!(zero.constant.is_zero());
439 }
440
441 #[test]
442 fn test_constraint_satisfied() {
443 // -1 <= 0 is satisfied
444 let c1 = Constraint {
445 expr: LinearExpr::constant(Rational::from_i64(-1)),
446 strict: false,
447 };
448 assert!(c1.is_satisfied_constant());
449
450 // 1 <= 0 is NOT satisfied
451 let c2 = Constraint {
452 expr: LinearExpr::constant(Rational::from_i64(1)),
453 strict: false,
454 };
455 assert!(!c2.is_satisfied_constant());
456
457 // 0 <= 0 is satisfied
458 let c3 = Constraint {
459 expr: LinearExpr::constant(Rational::zero()),
460 strict: false,
461 };
462 assert!(c3.is_satisfied_constant());
463
464 // 0 < 0 is NOT satisfied (strict)
465 let c4 = Constraint {
466 expr: LinearExpr::constant(Rational::zero()),
467 strict: true,
468 };
469 assert!(!c4.is_satisfied_constant());
470 }
471
472 #[test]
473 fn test_fourier_motzkin_constant() {
474 // Single constraint: 1 <= 0 (false)
475 let constraints = vec![Constraint {
476 expr: LinearExpr::constant(Rational::from_i64(1)),
477 strict: false,
478 }];
479 assert!(fourier_motzkin_unsat(&constraints));
480
481 // Single constraint: -1 <= 0 (true)
482 let constraints2 = vec![Constraint {
483 expr: LinearExpr::constant(Rational::from_i64(-1)),
484 strict: false,
485 }];
486 assert!(!fourier_motzkin_unsat(&constraints2));
487 }
488
489 // A constraint `c·x + d <= 0` (or `< 0`) from an integer triple.
490 fn c(cx: i64, d: i64, strict: bool) -> Constraint {
491 let mut e = LinearExpr::constant(Rational::from_i64(d));
492 e = e.add(&LinearExpr::var(0).scale(&Rational::from_i64(cx)));
493 Constraint { expr: e, strict }
494 }
495
496 #[test]
497 fn nonunit_coeff_satisfiable_is_not_unsat() {
498 // 2x + 4 <= 0 (x <= -2) ∧ -x - 3 <= 0 (x >= -3). x = -2 satisfies
499 // both, so the system is SATISFIABLE — `unsat` MUST be false. The
500 // dropped-division bug derives a spurious contradiction here (UNSOUND).
501 let sys = vec![c(2, 4, false), c(-1, -3, false)];
502 assert!(
503 !fourier_motzkin_unsat(&sys),
504 "satisfiable non-unit system wrongly reported unsatisfiable (unsound FM)"
505 );
506 }
507
508 #[test]
509 fn nonunit_coeff_unsat_is_detected() {
510 // 3x - 6 <= 0 (x <= 2) ∧ -x + 3 <= 0 (x >= 3). No integer (or
511 // rational) x satisfies both → UNSAT must be true. The bug misses it.
512 let sys = vec![c(3, -6, false), c(-1, 3, false)];
513 assert!(
514 fourier_motzkin_unsat(&sys),
515 "unsatisfiable non-unit system not detected (incomplete FM)"
516 );
517 }
518
519 #[test]
520 fn nonunit_coeff_strict_and_larger() {
521 // 5x <= 12 (x <= 2.4) ∧ 3x >= 9 (x >= 3): unsat (2.4 < 3).
522 let sys = vec![c(5, -12, false), c(-3, 9, false)];
523 assert!(fourier_motzkin_unsat(&sys));
524 // 5x <= 15 (x <= 3) ∧ 3x >= 9 (x >= 3): x = 3 satisfies — SAT.
525 let sys2 = vec![c(5, -15, false), c(-3, 9, false)];
526 assert!(!fourier_motzkin_unsat(&sys2));
527 }
528
529 #[test]
530 fn overflow_fails_closed_to_satisfiable() {
531 // `4e9·x - 1 <= 0` (x <= 1/4e9) ∧ `-3e9·x - 1 <= 0` (x >= -1/3e9): the
532 // interval contains 0, so the system is SATISFIABLE. Isolating x makes
533 // bounds with denominators 4e9 and 3e9; combining them needs the
534 // product 4e9·3e9 = 1.2e19, which exceeds i64. Exact arbitrary-
535 // precision arithmetic must compute straight through it and report the
536 // correct verdict.
537 let sys = vec![c(4_000_000_000, -1, false), c(-3_000_000_000, -1, false)];
538 assert!(
539 !fourier_motzkin_unsat(&sys),
540 "large denominators must be computed exactly, and this system is satisfiable"
541 );
542 }
543
544 #[test]
545 fn test_x_lt_x_plus_1() {
546 // x < x + 1 is always true
547 // Negation: x >= x + 1, i.e., x - x - 1 >= 0, i.e., -1 >= 0
548 // Constraint: -(-1) <= 0 => 1 <= 0 which is unsat => goal is valid
549 let x = LinearExpr::var(0);
550 let one = LinearExpr::constant(Rational::from_i64(1));
551 let _xp1 = x.add(&one);
552
553 // For Lt(x, x+1): negation constraint is (x+1 - x) <= 0 = 1 <= 0
554 let constraint = Constraint {
555 expr: LinearExpr::constant(Rational::from_i64(1)),
556 strict: false,
557 };
558 // 1 <= 0 is unsat, so goal is valid
559 assert!(fourier_motzkin_unsat(&[constraint]));
560 }
561}