Skip to main content

oxiz_theories/arithmetic/
solver.rs

1//! Arithmetic Theory Solver
2
3use super::simplex::{LinExpr, Simplex, VarId};
4use crate::theory::{EqualityNotification, Theory, TheoryCombination, TheoryId, TheoryResult};
5use num_rational::Rational64;
6use num_traits::{One, Signed};
7use oxiz_core::ast::TermId;
8use oxiz_core::error::Result;
9use rustc_hash::FxHashMap;
10
11/// Compute GCD of two i64 values
12fn gcd_i64(mut a: i64, mut b: i64) -> i64 {
13    a = a.abs();
14    b = b.abs();
15    while b != 0 {
16        let temp = b;
17        b = a % b;
18        a = temp;
19    }
20    a
21}
22
23/// Arithmetic Theory Solver (LRA/LIA)
24#[derive(Debug)]
25pub struct ArithSolver {
26    /// Simplex instance
27    simplex: Simplex,
28    /// Term to variable mapping
29    term_to_var: FxHashMap<TermId, VarId>,
30    /// Variable to term mapping
31    var_to_term: Vec<TermId>,
32    /// Reason counter
33    reason_counter: u32,
34    /// Reason to term mapping
35    reasons: Vec<TermId>,
36    /// Is this LIA (integers) or LRA (reals)?
37    is_integer: bool,
38    /// Context stack
39    context_stack: Vec<ContextState>,
40}
41
42/// State for push/pop
43#[derive(Debug, Clone)]
44struct ContextState {
45    num_vars: usize,
46    num_reasons: usize,
47}
48
49impl Default for ArithSolver {
50    fn default() -> Self {
51        Self::new(false)
52    }
53}
54
55impl ArithSolver {
56    /// Create a new arithmetic solver
57    #[must_use]
58    pub fn new(is_integer: bool) -> Self {
59        Self {
60            simplex: Simplex::new(),
61            term_to_var: FxHashMap::default(),
62            var_to_term: Vec::new(),
63            reason_counter: 0,
64            reasons: Vec::new(),
65            is_integer,
66            context_stack: Vec::new(),
67        }
68    }
69
70    /// Create a new LRA solver
71    #[must_use]
72    pub fn lra() -> Self {
73        Self::new(false)
74    }
75
76    /// Create a new LIA solver
77    #[must_use]
78    pub fn lia() -> Self {
79        Self::new(true)
80    }
81
82    /// Intern a term as a variable
83    pub fn intern(&mut self, term: TermId) -> VarId {
84        if let Some(&var) = self.term_to_var.get(&term) {
85            return var;
86        }
87
88        let var = self.simplex.new_var();
89        self.term_to_var.insert(term, var);
90        self.var_to_term.push(term);
91        var
92    }
93
94    /// Add a reason and return its ID
95    fn add_reason(&mut self, term: TermId) -> u32 {
96        let id = self.reason_counter;
97        self.reason_counter += 1;
98        self.reasons.push(term);
99        id
100    }
101
102    /// Normalize a linear expression
103    ///
104    /// Normalization performs:
105    /// 1. Coefficient reduction: divide by GCD of all coefficients
106    /// 2. Sign normalization: ensure first coefficient is positive
107    /// 3. Sorting: order terms by variable ID for canonical form
108    fn normalize_expr(&self, expr: &mut LinExpr) {
109        if expr.terms.is_empty() {
110            return;
111        }
112
113        // For integer arithmetic, reduce by GCD
114        if self.is_integer {
115            // Find GCD of all coefficients
116            let gcd = expr
117                .terms
118                .iter()
119                .map(|(_, c)| c.numer().abs())
120                .fold(0i64, |acc, n| if acc == 0 { n } else { gcd_i64(acc, n) });
121
122            if gcd > 1 {
123                let divisor = Rational64::from_integer(gcd);
124                expr.scale(Rational64::one() / divisor);
125            }
126        }
127
128        // Ensure first coefficient is positive
129        if let Some((_, c)) = expr.terms.first()
130            && c.is_negative()
131        {
132            expr.negate();
133        }
134
135        // Sort terms by variable ID for canonical form
136        expr.terms.sort_by_key(|(v, _)| *v);
137    }
138
139    /// Assert: lhs <= rhs
140    pub fn assert_le(&mut self, lhs: &[(TermId, Rational64)], rhs: Rational64, reason: TermId) {
141        let mut expr = LinExpr::new();
142
143        for (term, coef) in lhs {
144            let var = self.intern(*term);
145            expr.add_term(var, *coef);
146        }
147        expr.add_constant(-rhs);
148
149        // Normalize the expression
150        self.normalize_expr(&mut expr);
151
152        let reason_id = self.add_reason(reason);
153        self.simplex.add_le(expr, reason_id);
154    }
155
156    /// Assert: lhs >= rhs
157    pub fn assert_ge(&mut self, lhs: &[(TermId, Rational64)], rhs: Rational64, reason: TermId) {
158        let mut expr = LinExpr::new();
159
160        for (term, coef) in lhs {
161            let var = self.intern(*term);
162            expr.add_term(var, *coef);
163        }
164        expr.add_constant(-rhs);
165
166        // Normalize the expression
167        self.normalize_expr(&mut expr);
168
169        let reason_id = self.add_reason(reason);
170        self.simplex.add_ge(expr, reason_id);
171    }
172
173    /// Assert: lhs = rhs
174    ///
175    /// For integer arithmetic (LIA), checks GCD-based infeasibility:
176    /// If all coefficients share a common GCD that doesn't divide the RHS,
177    /// the constraint is infeasible over integers.
178    ///
179    /// Example: 2x + 2y = 7 is infeasible because gcd(2,2) = 2 doesn't divide 7.
180    pub fn assert_eq(&mut self, lhs: &[(TermId, Rational64)], rhs: Rational64, reason: TermId) {
181        let mut expr = LinExpr::new();
182
183        for (term, coef) in lhs {
184            let var = self.intern(*term);
185            expr.add_term(var, *coef);
186        }
187        expr.add_constant(-rhs);
188
189        // For LIA, check GCD-based infeasibility BEFORE normalization
190        // (normalization divides by GCD, which would lose the infeasibility signal)
191        if self.is_integer {
192            // Extract integer coefficients
193            let coeffs: Vec<i64> = expr
194                .terms
195                .iter()
196                .filter_map(|(_, c)| {
197                    if c.denom() == &1 {
198                        Some(*c.numer())
199                    } else {
200                        None
201                    }
202                })
203                .collect();
204
205            // Extract the constant (which is -rhs in expr = 0 form)
206            let const_term = if expr.constant.denom() == &1 {
207                -*expr.constant.numer()
208            } else {
209                // Non-integer constant in equality - infeasible for integers
210                if let Some(&(var, _)) = expr.terms.first() {
211                    self.simplex.set_lower(var, Rational64::from_integer(1), 0);
212                    self.simplex.set_upper(var, Rational64::from_integer(0), 0);
213                }
214                return;
215            };
216
217            // Check GCD infeasibility if all coefficients are integers
218            if !coeffs.is_empty() && coeffs.len() == expr.terms.len() {
219                // Compute GCD of all coefficients
220                let g = coeffs.iter().fold(0i64, |acc, &c| gcd_i64(acc, c.abs()));
221
222                if g > 0 && const_term % g != 0 {
223                    // GCD infeasibility detected!
224                    // Add contradictory constraints: x >= 1 and x <= 0
225                    if let Some(&(var, _)) = expr.terms.first() {
226                        self.simplex.set_lower(var, Rational64::from_integer(1), 0);
227                        self.simplex.set_upper(var, Rational64::from_integer(0), 0);
228                    }
229                    return;
230                }
231            }
232        }
233
234        // Normalize the expression
235        self.normalize_expr(&mut expr);
236
237        let reason_id = self.add_reason(reason);
238        self.simplex.add_eq(expr, reason_id);
239    }
240
241    /// Assert: lhs < rhs (strict inequality)
242    /// For LRA, uses infinitesimals: lhs <= rhs - δ
243    /// For LIA, transforms to: lhs <= rhs - 1 (since no integer exists between k and k+1)
244    pub fn assert_lt(&mut self, lhs: &[(TermId, Rational64)], rhs: Rational64, reason: TermId) {
245        // For integer arithmetic, x < k is equivalent to x <= k - 1
246        // because there's no integer strictly between k-1 and k
247        if self.is_integer {
248            // Transform: lhs < rhs becomes lhs <= rhs - 1
249            self.assert_le(lhs, rhs - Rational64::one(), reason);
250            return;
251        }
252
253        // For reals, use delta-rationals
254        // lhs < rhs is equivalent to lhs - rhs < 0
255        let mut expr = LinExpr::new();
256
257        for (term, coef) in lhs {
258            let var = self.intern(*term);
259            expr.add_term(var, *coef);
260        }
261        expr.add_constant(-rhs);
262
263        // Note: We do NOT normalize here because normalize_expr may negate
264        // the expression to make the first coefficient positive, which would
265        // flip the inequality direction for strict inequalities.
266
267        let reason_id = self.add_reason(reason);
268        self.simplex.add_strict_lt(expr, reason_id);
269    }
270
271    /// Assert: lhs > rhs (strict inequality)
272    /// For LRA, uses infinitesimals: lhs >= rhs + δ
273    /// For LIA, transforms to: lhs >= rhs + 1 (since no integer exists between k and k+1)
274    pub fn assert_gt(&mut self, lhs: &[(TermId, Rational64)], rhs: Rational64, reason: TermId) {
275        // For integer arithmetic, x > k is equivalent to x >= k + 1
276        // because there's no integer strictly between k and k+1
277        if self.is_integer {
278            // Transform: lhs > rhs becomes lhs >= rhs + 1
279            self.assert_ge(lhs, rhs + Rational64::one(), reason);
280            return;
281        }
282
283        // For reals, use delta-rationals
284        // lhs > rhs is equivalent to rhs - lhs < 0
285        // We build rhs - lhs directly instead of negating lhs - rhs
286        // This avoids issues with normalize_expr which ensures positive first coefficient
287        let mut expr = LinExpr::new();
288
289        for (term, coef) in lhs {
290            let var = self.intern(*term);
291            // Add negative coefficient since we want rhs - lhs
292            expr.add_term(var, -(*coef));
293        }
294        // Add +rhs (since we want rhs - lhs, not lhs - rhs)
295        expr.add_constant(rhs);
296
297        // Note: We do NOT normalize here because:
298        // 1. normalize_expr may negate to make first coefficient positive
299        // 2. This would flip the inequality direction
300        // 3. For strict inequalities, the sign matters
301
302        let reason_id = self.add_reason(reason);
303        self.simplex.add_strict_lt(expr, reason_id);
304    }
305
306    /// Get the current value of a variable
307    ///
308    /// For integer arithmetic (LIA), this properly rounds values that have
309    /// infinitesimal components from strict inequalities:
310    /// - If value is `r + δ` (positive delta), return `ceil(r)` for integers
311    /// - If value is `r - δ` (negative delta), return `floor(r)` for integers
312    #[must_use]
313    pub fn value(&self, term: TermId) -> Option<Rational64> {
314        self.term_to_var.get(&term).map(|&var| {
315            if self.is_integer {
316                // Get the full delta-rational value
317                let dval = self.simplex.delta_value(var);
318
319                // For integer arithmetic, round based on delta:
320                // - Positive delta means we have a strict lower bound (x > r)
321                //   so round up to the next integer
322                // - Negative delta means we have a strict upper bound (x < r)
323                //   so round down to the previous integer
324                // - Zero delta means exact value, round to nearest integer
325                if dval.delta.is_positive() {
326                    // x > r implies x >= ceil(r) for integers
327                    // If r is already an integer, we need r + 1
328                    let real_val = dval.real;
329                    if real_val.is_integer() {
330                        Rational64::from_integer(real_val.to_integer() + 1)
331                    } else {
332                        Rational64::from_integer(real_val.ceil().to_integer())
333                    }
334                } else if dval.delta.is_negative() {
335                    // x < r implies x <= floor(r) for integers
336                    // If r is already an integer, we need r - 1
337                    let real_val = dval.real;
338                    if real_val.is_integer() {
339                        Rational64::from_integer(real_val.to_integer() - 1)
340                    } else {
341                        Rational64::from_integer(real_val.floor().to_integer())
342                    }
343                } else {
344                    // No strict bound, just return the value
345                    // Round to nearest integer for consistency
346                    dval.real
347                }
348            } else {
349                // For reals, just return the real part
350                self.simplex.value(var)
351            }
352        })
353    }
354
355    /// Tighten a rational bound for integer variables
356    ///
357    /// For integer variables:
358    /// - x <= 5.7 becomes x <= 5
359    /// - x >= 2.3 becomes x >= 3
360    /// - x < 5.0 becomes x <= 4
361    /// - x > 2.0 becomes x >= 3
362    #[allow(dead_code)]
363    fn tighten_bound(&self, bound: Rational64, is_upper: bool) -> Rational64 {
364        if !self.is_integer {
365            return bound;
366        }
367
368        // For upper bounds (<=), floor the value
369        // For lower bounds (>=), ceiling the value
370        if bound.is_integer() {
371            bound
372        } else if is_upper {
373            // x <= 5.7 becomes x <= 5
374            Rational64::from_integer(bound.floor().to_integer())
375        } else {
376            // x >= 2.3 becomes x >= 3
377            Rational64::from_integer(bound.ceil().to_integer())
378        }
379    }
380
381    /// Tighten constraints for integer arithmetic
382    ///
383    /// Returns true if any tightening was performed
384    pub fn tighten_constraints(&mut self) -> bool {
385        if !self.is_integer {
386            return false;
387        }
388
389        // In a full implementation, we would:
390        // 1. Iterate through all bounds
391        // 2. Apply tightening rules
392        // 3. Propagate tightened bounds
393        //
394        // For now, tightening is applied during assertion
395        false
396    }
397}
398
399impl Theory for ArithSolver {
400    fn id(&self) -> TheoryId {
401        if self.is_integer {
402            TheoryId::LIA
403        } else {
404            TheoryId::LRA
405        }
406    }
407
408    fn name(&self) -> &str {
409        if self.is_integer { "LIA" } else { "LRA" }
410    }
411
412    fn can_handle(&self, _term: TermId) -> bool {
413        // In a full implementation, check if term is arithmetic
414        true
415    }
416
417    fn assert_true(&mut self, term: TermId) -> Result<TheoryResult> {
418        // In a full implementation, parse the term and add constraints
419        let _ = self.intern(term);
420        Ok(TheoryResult::Sat)
421    }
422
423    fn assert_false(&mut self, term: TermId) -> Result<TheoryResult> {
424        let _ = self.intern(term);
425        Ok(TheoryResult::Sat)
426    }
427
428    fn check(&mut self) -> Result<TheoryResult> {
429        match self.simplex.check() {
430            Ok(()) => Ok(TheoryResult::Sat),
431            Err(reasons) => {
432                let terms: Vec<_> = reasons
433                    .iter()
434                    .filter_map(|&r| self.reasons.get(r as usize).copied())
435                    .collect();
436                Ok(TheoryResult::Unsat(terms))
437            }
438        }
439    }
440
441    fn push(&mut self) {
442        self.context_stack.push(ContextState {
443            num_vars: self.var_to_term.len(),
444            num_reasons: self.reasons.len(),
445        });
446        self.simplex.push();
447    }
448
449    fn pop(&mut self) {
450        if let Some(state) = self.context_stack.pop() {
451            self.var_to_term.truncate(state.num_vars);
452            self.reasons.truncate(state.num_reasons);
453            self.reason_counter = state.num_reasons as u32;
454            self.simplex.pop();
455        }
456    }
457
458    fn reset(&mut self) {
459        self.simplex.reset();
460        self.term_to_var.clear();
461        self.var_to_term.clear();
462        self.reason_counter = 0;
463        self.reasons.clear();
464        self.context_stack.clear();
465    }
466
467    fn get_model(&self) -> Vec<(TermId, TermId)> {
468        // Return variable -> value pairs
469        // In a full implementation, we'd create value terms
470        Vec::new()
471    }
472}
473
474impl TheoryCombination for ArithSolver {
475    fn notify_equality(&mut self, eq: EqualityNotification) -> bool {
476        // Check if both terms are relevant to arithmetic
477        let lhs_var = self.term_to_var.get(&eq.lhs).copied();
478        let rhs_var = self.term_to_var.get(&eq.rhs).copied();
479
480        if let (Some(_lhs), Some(_rhs)) = (lhs_var, rhs_var) {
481            // For an equality constraint lhs = rhs, we need to ensure both
482            // lhs - rhs = 0 and rhs - lhs = 0 (which is the same constraint)
483            // In the simplex implementation, we can model this by creating
484            // a slack variable s and asserting:
485            // lhs = rhs (by setting bounds on the difference)
486
487            // For now, this is a simplified implementation that doesn't fully
488            // enforce the equality in the simplex tableau. A complete implementation
489            // would need to extend the simplex solver to support equality constraints
490            // or introduce a slack variable to model the equality.
491
492            // As a placeholder, we just record that the notification was received
493            let _reason = if let Some(r) = eq.reason {
494                self.add_reason(r)
495            } else {
496                self.add_reason(eq.lhs)
497            };
498
499            true
500        } else {
501            // Terms not relevant to this arithmetic solver
502            false
503        }
504    }
505
506    fn get_shared_equalities(&self) -> Vec<EqualityNotification> {
507        // In a full implementation, we would track which equalities were derived
508        // and return those that should be shared with other theories.
509        // For now, return an empty vector as a placeholder.
510        Vec::new()
511    }
512
513    fn is_relevant(&self, term: TermId) -> bool {
514        // Check if this term has been interned in the arithmetic solver
515        self.term_to_var.contains_key(&term)
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use num_traits::{One, Zero};
523
524    #[test]
525    fn test_arith_basic() {
526        let mut solver = ArithSolver::lra();
527
528        let x = TermId::new(1);
529        let y = TermId::new(2);
530        let reason = TermId::new(100);
531
532        // x >= 0
533        solver.assert_ge(
534            &[(x, Rational64::one())],
535            Rational64::from_integer(0),
536            reason,
537        );
538
539        // y >= 0
540        solver.assert_ge(
541            &[(y, Rational64::one())],
542            Rational64::from_integer(0),
543            reason,
544        );
545
546        // x + y <= 10
547        solver.assert_le(
548            &[(x, Rational64::one()), (y, Rational64::one())],
549            Rational64::from_integer(10),
550            reason,
551        );
552
553        let result = solver.check().unwrap();
554        assert!(matches!(result, TheoryResult::Sat));
555    }
556
557    #[test]
558    fn test_arith_unsat() {
559        let mut solver = ArithSolver::lra();
560
561        let x = TermId::new(1);
562        let reason = TermId::new(100);
563
564        // x >= 10
565        solver.assert_ge(
566            &[(x, Rational64::one())],
567            Rational64::from_integer(10),
568            reason,
569        );
570
571        // x <= 5
572        solver.assert_le(
573            &[(x, Rational64::one())],
574            Rational64::from_integer(5),
575            reason,
576        );
577
578        let result = solver.check().unwrap();
579        assert!(matches!(result, TheoryResult::Unsat(_)));
580    }
581
582    #[test]
583    fn test_arith_strict_inequality() {
584        let mut solver = ArithSolver::lra();
585
586        let x = TermId::new(1);
587        let reason = TermId::new(100);
588
589        // x > 0 (strict)
590        solver.assert_gt(
591            &[(x, Rational64::one())],
592            Rational64::from_integer(0),
593            reason,
594        );
595
596        // x < 10 (strict)
597        solver.assert_lt(
598            &[(x, Rational64::one())],
599            Rational64::from_integer(10),
600            reason,
601        );
602
603        let result = solver.check().unwrap();
604        assert!(matches!(result, TheoryResult::Sat));
605    }
606
607    #[test]
608    fn test_arith_strict_unsat() {
609        let mut solver = ArithSolver::lra();
610
611        let x = TermId::new(1);
612        let reason = TermId::new(100);
613
614        // x >= 5
615        solver.assert_ge(
616            &[(x, Rational64::one())],
617            Rational64::from_integer(5),
618            reason,
619        );
620
621        // x < 5 (strict) - should be unsatisfiable with x >= 5
622        solver.assert_lt(
623            &[(x, Rational64::one())],
624            Rational64::from_integer(5),
625            reason,
626        );
627
628        let result = solver.check().unwrap();
629        assert!(matches!(result, TheoryResult::Unsat(_)));
630    }
631
632    #[test]
633    fn test_coefficient_normalization_lia() {
634        let mut solver = ArithSolver::lia();
635
636        let x = TermId::new(1);
637        let y = TermId::new(2);
638        let reason = TermId::new(100);
639
640        // 2x + 4y <= 10 should be normalized to x + 2y <= 5 (GCD = 2)
641        solver.assert_le(
642            &[
643                (x, Rational64::from_integer(2)),
644                (y, Rational64::from_integer(4)),
645            ],
646            Rational64::from_integer(10),
647            reason,
648        );
649
650        // The solver should handle this correctly
651        let result = solver.check().unwrap();
652        assert!(matches!(result, TheoryResult::Sat));
653    }
654
655    #[test]
656    fn test_coefficient_normalization_sign() {
657        let solver = ArithSolver::lra();
658
659        let _x = TermId::new(1);
660        let _y = TermId::new(2);
661
662        // Test normalization ensures first coefficient is positive
663        let mut expr = LinExpr::new();
664        expr.add_term(0, Rational64::from_integer(-3));
665        expr.add_term(1, Rational64::from_integer(2));
666
667        solver.normalize_expr(&mut expr);
668
669        // After normalization, first coefficient should be positive
670        if let Some((_, c)) = expr.terms.first() {
671            assert!(c > &Rational64::zero());
672        }
673    }
674
675    #[test]
676    fn test_gcd_computation() {
677        assert_eq!(gcd_i64(12, 8), 4);
678        assert_eq!(gcd_i64(15, 25), 5);
679        assert_eq!(gcd_i64(7, 13), 1);
680        assert_eq!(gcd_i64(0, 5), 5);
681        assert_eq!(gcd_i64(5, 0), 5);
682        assert_eq!(gcd_i64(-12, 8), 4);
683        assert_eq!(gcd_i64(12, -8), 4);
684    }
685
686    #[test]
687    fn test_bound_tightening_lia() {
688        let solver = ArithSolver::lia();
689
690        // Upper bound tightening: x <= 5.7 -> x <= 5
691        let tightened = solver.tighten_bound(Rational64::new(57, 10), true);
692        assert_eq!(tightened, Rational64::from_integer(5));
693
694        // Lower bound tightening: x >= 2.3 -> x >= 3
695        let tightened = solver.tighten_bound(Rational64::new(23, 10), false);
696        assert_eq!(tightened, Rational64::from_integer(3));
697
698        // Integer bounds don't change
699        let tightened = solver.tighten_bound(Rational64::from_integer(5), true);
700        assert_eq!(tightened, Rational64::from_integer(5));
701    }
702
703    #[test]
704    fn test_bound_tightening_lra() {
705        let solver = ArithSolver::lra();
706
707        // No tightening for real arithmetic
708        let bound = Rational64::new(57, 10);
709        let tightened = solver.tighten_bound(bound, true);
710        assert_eq!(tightened, bound);
711    }
712
713    #[test]
714    fn test_tighten_constraints() {
715        let mut solver_lia = ArithSolver::lia();
716        let mut solver_lra = ArithSolver::lra();
717
718        // For now, this always returns false (tightening happens during assertion)
719        assert!(!solver_lia.tighten_constraints());
720        assert!(!solver_lra.tighten_constraints());
721    }
722
723    /// Test that x > 5 AND x < 6 is UNSAT for integers (no integer in open interval (5,6))
724    /// This is the bug report test case: strict inequalities must be transformed for LIA
725    #[test]
726    fn test_lia_strict_inequality_empty_interval() {
727        let mut solver = ArithSolver::lia();
728
729        let x = TermId::new(1);
730        let reason = TermId::new(100);
731
732        // x > 5 (for integers, this becomes x >= 6)
733        solver.assert_gt(
734            &[(x, Rational64::one())],
735            Rational64::from_integer(5),
736            reason,
737        );
738
739        // x < 6 (for integers, this becomes x <= 5)
740        solver.assert_lt(
741            &[(x, Rational64::one())],
742            Rational64::from_integer(6),
743            reason,
744        );
745
746        // Should be UNSAT: x >= 6 AND x <= 5 is impossible
747        let result = solver.check().unwrap();
748        assert!(
749            matches!(result, TheoryResult::Unsat(_)),
750            "Expected UNSAT for x > 5 AND x < 6 in LIA, got {:?}",
751            result
752        );
753    }
754
755    /// Test that x > 5 AND x < 6 is SAT for reals (5.5 is a valid solution)
756    #[test]
757    fn test_lra_strict_inequality_has_solution() {
758        let mut solver = ArithSolver::lra();
759
760        let x = TermId::new(1);
761        let reason = TermId::new(100);
762
763        // x > 5
764        solver.assert_gt(
765            &[(x, Rational64::one())],
766            Rational64::from_integer(5),
767            reason,
768        );
769
770        // x < 6
771        solver.assert_lt(
772            &[(x, Rational64::one())],
773            Rational64::from_integer(6),
774            reason,
775        );
776
777        // Should be SAT for reals: x = 5.5 is a valid solution
778        let result = solver.check().unwrap();
779        assert!(
780            matches!(result, TheoryResult::Sat),
781            "Expected SAT for x > 5 AND x < 6 in LRA, got {:?}",
782            result
783        );
784    }
785
786    /// Test x >= 5 AND x <= 5 with strict bounds in LIA
787    #[test]
788    fn test_lia_strict_at_boundary() {
789        let mut solver = ArithSolver::lia();
790
791        let x = TermId::new(1);
792        let reason = TermId::new(100);
793
794        // x >= 5
795        solver.assert_ge(
796            &[(x, Rational64::one())],
797            Rational64::from_integer(5),
798            reason,
799        );
800
801        // x < 6 (becomes x <= 5)
802        solver.assert_lt(
803            &[(x, Rational64::one())],
804            Rational64::from_integer(6),
805            reason,
806        );
807
808        // Should be SAT: x = 5 is the only solution
809        let result = solver.check().unwrap();
810        assert!(
811            matches!(result, TheoryResult::Sat),
812            "Expected SAT for x >= 5 AND x < 6 in LIA, got {:?}",
813            result
814        );
815    }
816}