Skip to main content

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(&current, var);
346
347        // Early termination: check for constant contradictions
348        for c in &current {
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}