Skip to main content

logicaffeine_kernel/
lia.rs

1//! LIA Tactic: Linear Integer Arithmetic by Fourier-Motzkin Elimination
2//!
3//! This module implements a decision procedure for linear arithmetic over
4//! rational numbers (with integer semantics handled by [`crate::omega`]).
5//!
6//! # Algorithm
7//!
8//! The lia tactic works in four steps:
9//! 1. **Reification**: Convert Syntax terms to [`LinearExpr`] representation
10//! 2. **Negation**: Convert the goal to constraints (validity = negation is unsatisfiable)
11//! 3. **Elimination**: Use Fourier-Motzkin to eliminate variables one by one
12//! 4. **Check**: Verify the resulting constant constraints are contradictory
13//!
14//! # Fourier-Motzkin Elimination
15//!
16//! This classical algorithm eliminates variables from a system of linear inequalities.
17//! For each variable x:
18//! - Partition constraints into lower bounds (x >= L), upper bounds (x <= U), and independent
19//! - Combine each lower with each upper: L <= U
20//! - The resulting system has one fewer variable
21//!
22//! # Rational Arithmetic
23//!
24//! The module uses exact rational arithmetic during elimination to avoid
25//! precision issues. Rationals are automatically normalized to lowest terms.
26//!
27//! # Supported Relations
28//!
29//! - `Lt` (less than)
30//! - `Le` (less than or equal)
31//! - `Gt` (greater than)
32//! - `Ge` (greater than or equal)
33
34use std::collections::{BTreeMap, HashSet};
35
36use crate::term::{Literal, Term};
37
38/// Exact rational number for arithmetic during Fourier-Motzkin elimination.
39///
40/// Rationals are automatically normalized to lowest terms with positive denominator.
41/// This ensures consistent comparison and canonical representation.
42///
43/// # Invariants
44///
45/// - `denominator > 0` (sign carried by numerator)
46/// - `gcd(|numerator|, denominator) = 1` (lowest terms)
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
48pub struct Rational {
49    /// The numerator (may be negative).
50    pub numerator: i64,
51    /// The denominator (always positive, never zero).
52    pub denominator: i64,
53}
54
55impl Rational {
56    /// Create a new rational, automatically normalizing to lowest terms
57    pub fn new(n: i64, d: i64) -> Self {
58        if d == 0 {
59            panic!("Rational denominator cannot be zero");
60        }
61        let g = gcd(n.abs(), d.abs()).max(1);
62        let sign = if d < 0 { -1 } else { 1 };
63        Rational {
64            numerator: sign * n / g,
65            denominator: (d.abs()) / g,
66        }
67    }
68
69    /// The zero rational
70    pub fn zero() -> Self {
71        Rational {
72            numerator: 0,
73            denominator: 1,
74        }
75    }
76
77    /// Create a rational from an integer
78    pub fn from_int(n: i64) -> Self {
79        Rational {
80            numerator: n,
81            denominator: 1,
82        }
83    }
84
85    /// Add two rationals
86    pub fn add(&self, other: &Rational) -> Rational {
87        Rational::new(
88            self.numerator * other.denominator + other.numerator * self.denominator,
89            self.denominator * other.denominator,
90        )
91    }
92
93    /// Negate a rational
94    pub fn neg(&self) -> Rational {
95        Rational {
96            numerator: -self.numerator,
97            denominator: self.denominator,
98        }
99    }
100
101    /// Subtract two rationals
102    pub fn sub(&self, other: &Rational) -> Rational {
103        self.add(&other.neg())
104    }
105
106    /// Multiply two rationals
107    pub fn mul(&self, other: &Rational) -> Rational {
108        Rational::new(
109            self.numerator * other.numerator,
110            self.denominator * other.denominator,
111        )
112    }
113
114    /// Divide two rationals (returns None if dividing by zero)
115    pub fn div(&self, other: &Rational) -> Option<Rational> {
116        if other.numerator == 0 {
117            return None;
118        }
119        Some(Rational::new(
120            self.numerator * other.denominator,
121            self.denominator * other.numerator,
122        ))
123    }
124
125    /// Check if negative
126    pub fn is_negative(&self) -> bool {
127        self.numerator < 0
128    }
129
130    /// Check if positive
131    pub fn is_positive(&self) -> bool {
132        self.numerator > 0
133    }
134
135    /// Check if zero
136    pub fn is_zero(&self) -> bool {
137        self.numerator == 0
138    }
139}
140
141/// Greatest common divisor using Euclidean algorithm
142fn gcd(a: i64, b: i64) -> i64 {
143    if b == 0 {
144        a
145    } else {
146        gcd(b, a % b)
147    }
148}
149
150/// A linear expression of the form c₀ + c₁x₁ + c₂x₂ + ... + cₙxₙ.
151///
152/// Stored as a constant term plus a sparse map of variable coefficients.
153/// Variables with coefficient 0 are automatically removed.
154///
155/// # Representation
156///
157/// The expression `3 + 2x - y` is stored as:
158/// - `constant = 3`
159/// - `coefficients = {0: 2, 1: -1}` (assuming x is var 0, y is var 1)
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct LinearExpr {
162    /// The constant term c₀.
163    pub constant: Rational,
164    /// Maps variable index to its coefficient (sparse representation).
165    pub coefficients: BTreeMap<i64, Rational>,
166}
167
168impl LinearExpr {
169    /// Create a constant expression
170    pub fn constant(c: Rational) -> Self {
171        LinearExpr {
172            constant: c,
173            coefficients: BTreeMap::new(),
174        }
175    }
176
177    /// Create a single variable expression: 1*x_idx + 0
178    pub fn var(idx: i64) -> Self {
179        let mut coeffs = BTreeMap::new();
180        coeffs.insert(idx, Rational::from_int(1));
181        LinearExpr {
182            constant: Rational::zero(),
183            coefficients: coeffs,
184        }
185    }
186
187    /// Add two linear expressions
188    pub fn add(&self, other: &LinearExpr) -> LinearExpr {
189        let mut result = self.clone();
190        result.constant = result.constant.add(&other.constant);
191        for (var, coeff) in &other.coefficients {
192            let entry = result
193                .coefficients
194                .entry(*var)
195                .or_insert(Rational::zero());
196            *entry = entry.add(coeff);
197            if entry.is_zero() {
198                result.coefficients.remove(var);
199            }
200        }
201        result
202    }
203
204    /// Negate a linear expression
205    pub fn neg(&self) -> LinearExpr {
206        LinearExpr {
207            constant: self.constant.neg(),
208            coefficients: self
209                .coefficients
210                .iter()
211                .map(|(v, c)| (*v, c.neg()))
212                .collect(),
213        }
214    }
215
216    /// Subtract two linear expressions
217    pub fn sub(&self, other: &LinearExpr) -> LinearExpr {
218        self.add(&other.neg())
219    }
220
221    /// Scale a linear expression by a rational constant
222    pub fn scale(&self, c: &Rational) -> LinearExpr {
223        if c.is_zero() {
224            return LinearExpr::constant(Rational::zero());
225        }
226        LinearExpr {
227            constant: self.constant.mul(c),
228            coefficients: self
229                .coefficients
230                .iter()
231                .map(|(v, coeff)| (*v, coeff.mul(c)))
232                .filter(|(_, c)| !c.is_zero())
233                .collect(),
234        }
235    }
236
237    /// Check if this is a constant expression (no variables)
238    pub fn is_constant(&self) -> bool {
239        self.coefficients.is_empty()
240    }
241
242    /// Get coefficient of a variable (0 if not present)
243    pub fn get_coeff(&self, var: i64) -> Rational {
244        self.coefficients
245            .get(&var)
246            .cloned()
247            .unwrap_or(Rational::zero())
248    }
249}
250
251/// A linear constraint representing either `expr <= 0` or `expr < 0`.
252///
253/// All inequalities are normalized to this form during processing.
254/// For example, `x >= 5` becomes `-x + 5 <= 0`, i.e., `5 - x <= 0`.
255#[derive(Debug, Clone)]
256pub struct Constraint {
257    /// The linear expression (constraint is expr OP 0).
258    pub expr: LinearExpr,
259    /// If true, this is a strict inequality (`< 0`).
260    /// If false, this is a non-strict inequality (`<= 0`).
261    pub strict: bool,
262}
263
264impl Constraint {
265    /// Check if a constant constraint is satisfied
266    /// For non-constant constraints, returns true (we can't tell yet)
267    pub fn is_satisfied_constant(&self) -> bool {
268        if !self.expr.is_constant() {
269            return true; // Can't determine yet
270        }
271        let c = &self.expr.constant;
272        if self.strict {
273            c.is_negative() // c < 0
274        } else {
275            !c.is_positive() // c ≤ 0
276        }
277    }
278}
279
280/// Error during reification to linear expression
281#[derive(Debug)]
282pub enum LiaError {
283    /// Expression is not linear (e.g., x*y)
284    NonLinear(String),
285    /// Malformed term structure
286    MalformedTerm,
287    /// Goal is not an inequality
288    NotInequality,
289}
290
291/// Reify a Syntax term to a linear expression.
292///
293/// Converts the deep embedding of terms (Syntax) into a linear expression
294/// suitable for Fourier-Motzkin elimination.
295///
296/// # Supported Forms
297///
298/// - `SLit n` - Integer literal becomes a constant
299/// - `SVar i` - De Bruijn variable becomes a linear variable
300/// - `SName "x"` - Named global becomes a linear variable (hashed)
301/// - `SApp (SApp (SName "add") a) b` - Linear addition
302/// - `SApp (SApp (SName "sub") a) b` - Linear subtraction
303/// - `SApp (SApp (SName "mul") c) x` - Scaling (only if one operand is constant)
304///
305/// # Errors
306///
307/// Returns [`LiaError::NonLinear`] if the term contains non-linear operations
308/// (e.g., multiplication of two variables).
309pub fn reify_linear(term: &Term) -> Result<LinearExpr, LiaError> {
310    // SLit n -> constant
311    if let Some(n) = extract_slit(term) {
312        return Ok(LinearExpr::constant(Rational::from_int(n)));
313    }
314
315    // SVar i -> variable
316    if let Some(i) = extract_svar(term) {
317        return Ok(LinearExpr::var(i));
318    }
319
320    // SName "x" -> named variable (global constant treated as free variable)
321    if let Some(name) = extract_sname(term) {
322        let hash = name_to_var_index(&name);
323        return Ok(LinearExpr::var(hash));
324    }
325
326    // Binary operations
327    if let Some((op, a, b)) = extract_binary_app(term) {
328        match op.as_str() {
329            "add" => {
330                let la = reify_linear(&a)?;
331                let lb = reify_linear(&b)?;
332                return Ok(la.add(&lb));
333            }
334            "sub" => {
335                let la = reify_linear(&a)?;
336                let lb = reify_linear(&b)?;
337                return Ok(la.sub(&lb));
338            }
339            "mul" => {
340                let la = reify_linear(&a)?;
341                let lb = reify_linear(&b)?;
342                // Only linear if one side is constant
343                if la.is_constant() {
344                    return Ok(lb.scale(&la.constant));
345                }
346                if lb.is_constant() {
347                    return Ok(la.scale(&lb.constant));
348                }
349                return Err(LiaError::NonLinear(
350                    "multiplication of two variables is not linear".to_string(),
351                ));
352            }
353            "div" | "mod" => {
354                return Err(LiaError::NonLinear(format!(
355                    "operation '{}' is not supported in lia",
356                    op
357                )));
358            }
359            _ => {
360                return Err(LiaError::NonLinear(format!("unknown operation '{}'", op)));
361            }
362        }
363    }
364
365    Err(LiaError::MalformedTerm)
366}
367
368/// Extract comparison from goal: (SApp (SApp (SName "Lt"|"Le"|"Gt"|"Ge") lhs) rhs)
369pub fn extract_comparison(term: &Term) -> Option<(String, Term, Term)> {
370    if let Some((rel, lhs, rhs)) = extract_binary_app(term) {
371        match rel.as_str() {
372            "Lt" | "Le" | "Gt" | "Ge" | "lt" | "le" | "gt" | "ge" => {
373                return Some((rel, lhs, rhs));
374            }
375            _ => {}
376        }
377    }
378    None
379}
380
381/// Convert a goal to constraints for validity checking.
382///
383/// To prove a goal is valid, we check if its negation is unsatisfiable.
384/// - Lt(a, b) is valid iff a - b < 0 always, i.e., negation a - b >= 0 is unsat
385/// - Le(a, b) is valid iff a - b <= 0 always, i.e., negation a - b > 0 is unsat
386pub fn goal_to_negated_constraint(
387    rel: &str,
388    lhs: &LinearExpr,
389    rhs: &LinearExpr,
390) -> Option<Constraint> {
391    // diff = lhs - rhs
392    let diff = lhs.sub(rhs);
393
394    match rel {
395        // Lt: a < b valid iff NOT(a >= b), i.e., a - b >= 0 is unsat
396        // So negation constraint is: a - b >= 0, i.e., -(a - b) <= 0, i.e., (b - a) <= 0
397        // Actually: a >= b means a - b >= 0, which means -(a - b) <= 0
398        // But we want to find if a - b >= 0 can ever be true
399        // If we want to prove a < b (always), we check if a >= b (ever) is unsat
400        // Constraint form: expr <= 0 or expr < 0
401        // a >= b means a - b >= 0, means -(a - b) <= 0, means (b - a) <= 0
402        "Lt" | "lt" => {
403            // Want to prove: a < b always
404            // Negation: a >= b (can be true)
405            // a >= b means a - b >= 0
406            // In our constraint form (expr <= 0): -(a - b) <= 0, i.e., (rhs - lhs) <= 0
407            Some(Constraint {
408                expr: rhs.sub(lhs),
409                strict: false, // <= 0
410            })
411        }
412        // Le: a <= b valid iff NOT(a > b), i.e., a - b > 0 is unsat
413        "Le" | "le" => {
414            // Want to prove: a <= b always
415            // Negation: a > b
416            // a > b means a - b > 0
417            // In constraint form: -(a - b) < 0, i.e., (rhs - lhs) < 0
418            Some(Constraint {
419                expr: rhs.sub(lhs),
420                strict: true, // < 0
421            })
422        }
423        // Gt: a > b valid iff NOT(a <= b), i.e., a - b <= 0 is unsat
424        "Gt" | "gt" => {
425            // Want to prove: a > b always
426            // Negation: a <= b
427            // a <= b means a - b <= 0
428            // In constraint form: (a - b) <= 0, i.e., (lhs - rhs) <= 0
429            Some(Constraint {
430                expr: diff, // (lhs - rhs) <= 0
431                strict: false,
432            })
433        }
434        // Ge: a >= b valid iff NOT(a < b), i.e., a - b < 0 is unsat
435        "Ge" | "ge" => {
436            // Want to prove: a >= b always
437            // Negation: a < b
438            // a < b means a - b < 0
439            // In constraint form: (a - b) < 0, i.e., (lhs - rhs) < 0
440            Some(Constraint {
441                expr: diff, // (lhs - rhs) < 0
442                strict: true,
443            })
444        }
445        _ => None,
446    }
447}
448
449/// Check if a constraint set is unsatisfiable using Fourier-Motzkin elimination.
450///
451/// This is the core decision procedure. It eliminates variables one by one
452/// until only constant constraints remain, then checks for contradictions.
453///
454/// # Algorithm
455///
456/// For each variable x in the system:
457/// 1. Partition constraints into lower bounds on x, upper bounds on x, and independent
458/// 2. For each pair (lower, upper), derive a new constraint without x
459/// 3. Check for immediate contradictions (e.g., `5 <= 0`)
460///
461/// # Returns
462///
463/// - `true` if the constraints are unsatisfiable (contradiction found)
464/// - `false` if the constraints may be satisfiable
465///
466/// # Usage for Validity
467///
468/// To prove a goal G is valid, we check if NOT(G) is unsatisfiable.
469/// If `fourier_motzkin_unsat(negation_constraints)` returns true, the goal is valid.
470pub fn fourier_motzkin_unsat(constraints: &[Constraint]) -> bool {
471    if constraints.is_empty() {
472        return false; // Empty set is satisfiable
473    }
474
475    // Collect all variables
476    let vars: Vec<i64> = constraints
477        .iter()
478        .flat_map(|c| c.expr.coefficients.keys().copied())
479        .collect::<HashSet<_>>()
480        .into_iter()
481        .collect();
482
483    let mut current = constraints.to_vec();
484
485    // Eliminate each variable
486    for var in vars {
487        current = eliminate_variable(&current, var);
488
489        // Early termination: check for constant contradictions
490        for c in &current {
491            if c.expr.is_constant() && !c.is_satisfied_constant() {
492                return true; // Contradiction found!
493            }
494        }
495    }
496
497    // Check all remaining constant constraints
498    current.iter().any(|c| !c.is_satisfied_constant())
499}
500
501/// Eliminate a variable from a set of constraints using Fourier-Motzkin.
502///
503/// Partitions constraints into:
504/// - Lower bounds: x >= expr (coeff < 0 means -|c|*x + rest <= 0 => x >= rest/|c|)
505/// - Upper bounds: x <= expr (coeff > 0 means c*x + rest <= 0 => x <= -rest/c)
506/// - Independent: doesn't contain variable
507///
508/// Combines each lower with each upper to get new constraints without the variable.
509fn eliminate_variable(constraints: &[Constraint], var: i64) -> Vec<Constraint> {
510    let mut lower: Vec<(LinearExpr, bool)> = vec![]; // lower bound on var
511    let mut upper: Vec<(LinearExpr, bool)> = vec![]; // upper bound on var
512    let mut independent: Vec<Constraint> = vec![];
513
514    for c in constraints {
515        let coeff = c.expr.get_coeff(var);
516        if coeff.is_zero() {
517            independent.push(c.clone());
518        } else {
519            // c.expr = coeff*var + rest <= 0 (or < 0)
520            // Extract: rest = c.expr - coeff*var
521            let mut rest = c.expr.clone();
522            rest.coefficients.remove(&var);
523
524            if coeff.is_positive() {
525                // coeff*var + rest <= 0
526                // var <= -rest/coeff
527                // Upper bound: -rest/coeff
528                let bound = rest.neg().scale(&coeff.div(&Rational::from_int(1)).unwrap());
529                let bound = bound.scale(
530                    &Rational::from_int(1)
531                        .div(&coeff)
532                        .unwrap_or(Rational::from_int(1)),
533                );
534                upper.push((rest.neg().scale(&coeff.div(&coeff).unwrap()), c.strict));
535            } else {
536                // coeff*var + rest <= 0, coeff < 0
537                // |coeff|*(-var) + rest <= 0
538                // -var <= -rest/|coeff|
539                // var >= rest/|coeff|
540                // Lower bound: rest/|coeff|
541                let abs_coeff = coeff.neg();
542                lower.push((rest.scale(&abs_coeff.div(&abs_coeff).unwrap()), c.strict));
543            }
544        }
545    }
546
547    // Combine lower and upper bounds
548    // If lo <= var <= hi, then lo <= hi must hold
549    for (lo_expr, lo_strict) in &lower {
550        for (hi_expr, hi_strict) in &upper {
551            // We need: lo <= hi
552            // In constraint form: lo - hi <= 0
553            let diff = lo_expr.sub(hi_expr);
554            independent.push(Constraint {
555                expr: diff,
556                strict: *lo_strict || *hi_strict,
557            });
558        }
559    }
560
561    independent
562}
563
564// =============================================================================
565// Helper functions for extracting Syntax patterns (same as ring.rs)
566// =============================================================================
567
568/// Extract integer from SLit n
569fn extract_slit(term: &Term) -> Option<i64> {
570    if let Term::App(ctor, arg) = term {
571        if let Term::Global(name) = ctor.as_ref() {
572            if name == "SLit" {
573                if let Term::Lit(Literal::Int(n)) = arg.as_ref() {
574                    return Some(*n);
575                }
576            }
577        }
578    }
579    None
580}
581
582/// Extract variable index from SVar i
583fn extract_svar(term: &Term) -> Option<i64> {
584    if let Term::App(ctor, arg) = term {
585        if let Term::Global(name) = ctor.as_ref() {
586            if name == "SVar" {
587                if let Term::Lit(Literal::Int(i)) = arg.as_ref() {
588                    return Some(*i);
589                }
590            }
591        }
592    }
593    None
594}
595
596/// Extract name from SName "x"
597fn extract_sname(term: &Term) -> Option<String> {
598    if let Term::App(ctor, arg) = term {
599        if let Term::Global(name) = ctor.as_ref() {
600            if name == "SName" {
601                if let Term::Lit(Literal::Text(s)) = arg.as_ref() {
602                    return Some(s.clone());
603                }
604            }
605        }
606    }
607    None
608}
609
610/// Extract binary application: SApp (SApp (SName "op") a) b
611fn extract_binary_app(term: &Term) -> Option<(String, Term, Term)> {
612    if let Term::App(outer, b) = term {
613        if let Term::App(sapp_outer, inner) = outer.as_ref() {
614            if let Term::Global(ctor) = sapp_outer.as_ref() {
615                if ctor == "SApp" {
616                    if let Term::App(partial, a) = inner.as_ref() {
617                        if let Term::App(sapp_inner, op_term) = partial.as_ref() {
618                            if let Term::Global(ctor2) = sapp_inner.as_ref() {
619                                if ctor2 == "SApp" {
620                                    if let Some(op) = extract_sname(op_term) {
621                                        return Some((
622                                            op,
623                                            a.as_ref().clone(),
624                                            b.as_ref().clone(),
625                                        ));
626                                    }
627                                }
628                            }
629                        }
630                    }
631                }
632            }
633        }
634    }
635    None
636}
637
638/// Convert a name to a unique negative variable index
639fn name_to_var_index(name: &str) -> i64 {
640    let hash: i64 = name
641        .bytes()
642        .fold(0i64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as i64));
643    -(hash.abs() + 1_000_000)
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_rational_arithmetic() {
652        let half = Rational::new(1, 2);
653        let third = Rational::new(1, 3);
654        let sum = half.add(&third);
655        assert_eq!(sum, Rational::new(5, 6));
656    }
657
658    #[test]
659    fn test_linear_expr_add() {
660        let x = LinearExpr::var(0);
661        let y = LinearExpr::var(1);
662        let sum = x.add(&y);
663        assert!(!sum.is_constant());
664        assert_eq!(sum.get_coeff(0), Rational::from_int(1));
665        assert_eq!(sum.get_coeff(1), Rational::from_int(1));
666    }
667
668    #[test]
669    fn test_linear_expr_cancel() {
670        let x = LinearExpr::var(0);
671        let neg_x = x.neg();
672        let zero = x.add(&neg_x);
673        assert!(zero.is_constant());
674        assert!(zero.constant.is_zero());
675    }
676
677    #[test]
678    fn test_constraint_satisfied() {
679        // -1 <= 0 is satisfied
680        let c1 = Constraint {
681            expr: LinearExpr::constant(Rational::from_int(-1)),
682            strict: false,
683        };
684        assert!(c1.is_satisfied_constant());
685
686        // 1 <= 0 is NOT satisfied
687        let c2 = Constraint {
688            expr: LinearExpr::constant(Rational::from_int(1)),
689            strict: false,
690        };
691        assert!(!c2.is_satisfied_constant());
692
693        // 0 <= 0 is satisfied
694        let c3 = Constraint {
695            expr: LinearExpr::constant(Rational::zero()),
696            strict: false,
697        };
698        assert!(c3.is_satisfied_constant());
699
700        // 0 < 0 is NOT satisfied (strict)
701        let c4 = Constraint {
702            expr: LinearExpr::constant(Rational::zero()),
703            strict: true,
704        };
705        assert!(!c4.is_satisfied_constant());
706    }
707
708    #[test]
709    fn test_fourier_motzkin_constant() {
710        // Single constraint: 1 <= 0 (false)
711        let constraints = vec![Constraint {
712            expr: LinearExpr::constant(Rational::from_int(1)),
713            strict: false,
714        }];
715        assert!(fourier_motzkin_unsat(&constraints));
716
717        // Single constraint: -1 <= 0 (true)
718        let constraints2 = vec![Constraint {
719            expr: LinearExpr::constant(Rational::from_int(-1)),
720            strict: false,
721        }];
722        assert!(!fourier_motzkin_unsat(&constraints2));
723    }
724
725    #[test]
726    fn test_x_lt_x_plus_1() {
727        // x < x + 1 is always true
728        // Negation: x >= x + 1, i.e., x - x - 1 >= 0, i.e., -1 >= 0
729        // Constraint: -(-1) <= 0 => 1 <= 0 which is unsat => goal is valid
730        let x = LinearExpr::var(0);
731        let one = LinearExpr::constant(Rational::from_int(1));
732        let xp1 = x.add(&one);
733
734        // Goal: Lt x (x+1)
735        // Negation constraint: (x+1) - x <= 0 (non-strict for Lt's negation Ge)
736        // Wait, let me reconsider...
737        // Lt(a, b) valid means a < b always
738        // Negation: a >= b can be true
739        // For FM: we want to show a >= b is unsat
740        // a >= b means a - b >= 0
741        // In our form (expr <= 0): -(a - b) <= 0, i.e., (b - a) <= 0
742
743        // So for Lt(x, x+1): negation constraint is (x+1 - x) <= 0 = 1 <= 0
744        let constraint = Constraint {
745            expr: LinearExpr::constant(Rational::from_int(1)),
746            strict: false,
747        };
748        // 1 <= 0 is unsat, so goal is valid
749        assert!(fourier_motzkin_unsat(&[constraint]));
750    }
751}