Skip to main content

problemreductions/rules/
ilp_i32_ilp_bool.rs

1//! Reduction from ILP<i32> to ILP<bool> via truncated binary encoding with FBBT.
2//!
3//! Uses Feasibility-Based Bound Tightening (Savelsbergh 1994, Achterberg et al. 2020)
4//! to infer per-variable upper bounds, then encodes each integer variable into
5//! ceil(log2(U+1)) binary variables using truncated binary encoding (Karimi & Rosenberg 2017).
6
7use crate::models::algebraic::{Comparison, LinearConstraint, ILP};
8use crate::reduction;
9use crate::rules::traits::{ReduceTo, ReductionResult};
10
11/// Error type for FBBT failures.
12#[derive(Debug, Clone, PartialEq)]
13pub enum FbbtError {
14    /// At least one variable has an unbounded upper bound after FBBT.
15    Unbounded,
16    /// The constraint system is provably infeasible.
17    Infeasible,
18}
19
20/// Per-variable encoding info: start index in binary variables, weights.
21#[derive(Debug, Clone)]
22struct VarEncoding {
23    /// Index of the first binary variable for this integer variable.
24    start: usize,
25    /// Weights for each binary variable: [1, 2, 4, ..., remainder].
26    weights: Vec<i64>,
27}
28
29/// Infer upper bounds for non-negative integer variables via FBBT.
30///
31/// Returns `Ok(bounds)` with finite upper bounds, or an error if the system
32/// is infeasible or unbounded.
33fn fbbt(num_vars: usize, constraints: &[LinearConstraint]) -> Result<Vec<i64>, FbbtError> {
34    const INF: i64 = i64::MAX / 2; // sentinel for +infinity (safe for addition)
35
36    let mut lower = vec![0i64; num_vars];
37    let mut upper = vec![INF; num_vars];
38
39    let max_iters = num_vars + 1;
40
41    for _ in 0..max_iters {
42        let mut changed = false;
43
44        for c in constraints {
45            // Compute activity bounds: act_min = sum of min contributions, act_max = sum of max contributions
46            let mut act_min: i64 = 0;
47            let mut act_max: i64 = 0;
48            let mut act_min_finite = true;
49            let mut act_max_finite = true;
50
51            for &(var, coef) in &c.terms {
52                let coef_i = coef as i64; // coefficients are integer-valued in practice
53                if coef_i > 0 {
54                    act_min = act_min.saturating_add(coef_i.saturating_mul(lower[var]));
55                    if upper[var] >= INF {
56                        act_max_finite = false;
57                    } else {
58                        act_max = act_max.saturating_add(coef_i.saturating_mul(upper[var]));
59                    }
60                } else if coef_i < 0 {
61                    if upper[var] >= INF {
62                        act_min_finite = false;
63                    } else {
64                        act_min = act_min.saturating_add(coef_i.saturating_mul(upper[var]));
65                    }
66                    act_max = act_max.saturating_add(coef_i.saturating_mul(lower[var]));
67                }
68            }
69
70            let rhs = c.rhs as i64;
71
72            // Infeasibility checks
73            if matches!(c.cmp, Comparison::Le | Comparison::Eq) && act_min_finite && act_min > rhs {
74                return Err(FbbtError::Infeasible);
75            }
76            if matches!(c.cmp, Comparison::Ge | Comparison::Eq) && act_max_finite && act_max < rhs {
77                return Err(FbbtError::Infeasible);
78            }
79
80            // Tighten each variable
81            for &(var, coef) in &c.terms {
82                let coef_i = coef as i64;
83                if coef_i == 0 {
84                    continue;
85                }
86
87                // From Le or Eq: upper bound tightening for positive coef, lower bound for negative
88                if matches!(c.cmp, Comparison::Le | Comparison::Eq) {
89                    // Compute residual min = act_min - this variable's min contribution
90                    let my_min = if coef_i > 0 {
91                        coef_i.saturating_mul(lower[var])
92                    } else {
93                        if upper[var] >= INF {
94                            continue; // can't compute residual
95                        }
96                        coef_i.saturating_mul(upper[var])
97                    };
98                    if !(act_min_finite || coef_i < 0 && upper[var] >= INF) {
99                        // act_min is -inf, residual is -inf, no useful bound
100                        continue;
101                    }
102                    let res_min = if act_min_finite {
103                        act_min - my_min
104                    } else {
105                        // act_min was -inf because of this var's contribution
106                        // but my_min was the infinite part, so residual is finite
107                        // This case shouldn't produce useful bounds
108                        continue;
109                    };
110
111                    if coef_i > 0 {
112                        // a_i * x_i <= rhs - res_min => x_i <= floor((rhs - res_min) / a_i)
113                        let new_u = floor_div(rhs - res_min, coef_i);
114                        if new_u < upper[var] {
115                            upper[var] = new_u;
116                            changed = true;
117                        }
118                    } else {
119                        // a_i * x_i <= rhs - res_min, a_i < 0 => x_i >= ceil((rhs - res_min) / a_i)
120                        let new_l = ceil_div(rhs - res_min, coef_i);
121                        if new_l > lower[var] {
122                            lower[var] = new_l;
123                            changed = true;
124                        }
125                    }
126                }
127
128                // From Ge or Eq: lower bound tightening for positive coef, upper for negative
129                if matches!(c.cmp, Comparison::Ge | Comparison::Eq) {
130                    let my_max = if coef_i > 0 {
131                        if upper[var] >= INF {
132                            continue;
133                        }
134                        coef_i.saturating_mul(upper[var])
135                    } else {
136                        coef_i.saturating_mul(lower[var])
137                    };
138                    if !(act_max_finite || coef_i > 0 && upper[var] >= INF) {
139                        continue;
140                    }
141                    let res_max = if act_max_finite {
142                        act_max - my_max
143                    } else {
144                        continue;
145                    };
146
147                    if coef_i > 0 {
148                        // a_i * x_i >= rhs - res_max => x_i >= ceil((rhs - res_max) / a_i)
149                        let new_l = ceil_div(rhs - res_max, coef_i);
150                        if new_l > lower[var] {
151                            lower[var] = new_l;
152                            changed = true;
153                        }
154                    } else {
155                        // a_i * x_i >= rhs - res_max, a_i < 0 => x_i <= floor((rhs - res_max) / a_i)
156                        let new_u = floor_div(rhs - res_max, coef_i);
157                        if new_u < upper[var] {
158                            upper[var] = new_u;
159                            changed = true;
160                        }
161                    }
162                }
163
164                if lower[var] > upper[var] {
165                    return Err(FbbtError::Infeasible);
166                }
167            }
168        }
169
170        if !changed {
171            break;
172        }
173    }
174
175    // Check for unbounded variables
176    for &u in &upper {
177        if u >= INF {
178            return Err(FbbtError::Unbounded);
179        }
180    }
181
182    Ok(upper)
183}
184
185/// Floor division that rounds toward negative infinity.
186fn floor_div(a: i64, b: i64) -> i64 {
187    let d = a / b;
188    let r = a % b;
189    if (r != 0) && ((r ^ b) < 0) {
190        d - 1
191    } else {
192        d
193    }
194}
195
196/// Ceiling division that rounds toward positive infinity.
197fn ceil_div(a: i64, b: i64) -> i64 {
198    let d = a / b;
199    let r = a % b;
200    if (r != 0) && ((r ^ b) >= 0) {
201        d + 1
202    } else {
203        d
204    }
205}
206
207/// Compute the truncated binary encoding weights for a variable with upper bound U.
208///
209/// Returns weights [1, 2, 4, ..., remainder] such that sum of weights = U.
210fn binary_weights(upper_bound: i64) -> Vec<i64> {
211    if upper_bound == 0 {
212        return vec![]; // fixed at 0, no binary variables needed
213    }
214    let k = num_bits(upper_bound);
215    let mut weights = Vec::with_capacity(k);
216    for j in 0..(k - 1) {
217        weights.push(1i64 << j);
218    }
219    // Last weight: U - (2^{K-1} - 1)
220    let last = upper_bound - ((1i64 << (k - 1)) - 1);
221    weights.push(last);
222    weights
223}
224
225/// Number of binary variables needed: ceil(log2(U + 1)).
226fn num_bits(upper_bound: i64) -> usize {
227    if upper_bound <= 0 {
228        return 0;
229    }
230    // ceil(log2(U + 1)) = floor(log2(U)) + 1 = 64 - leading_zeros(U)
231    64 - (upper_bound as u64).leading_zeros() as usize
232}
233
234/// Reduction result for ILP<i32> -> ILP<bool>.
235#[derive(Debug, Clone)]
236pub struct ReductionIntILPToBinaryILP {
237    target: ILP<bool>,
238    /// Per-source-variable encoding info.
239    encodings: Vec<VarEncoding>,
240}
241
242impl ReductionResult for ReductionIntILPToBinaryILP {
243    type Source = ILP<i32>;
244    type Target = ILP<bool>;
245
246    fn target_problem(&self) -> &ILP<bool> {
247        &self.target
248    }
249
250    fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
251        self.encodings
252            .iter()
253            .map(|enc| {
254                let val: i64 = enc
255                    .weights
256                    .iter()
257                    .enumerate()
258                    .map(|(j, &w)| w * target_solution[enc.start + j] as i64)
259                    .sum();
260                val as usize
261            })
262            .collect()
263    }
264}
265
266#[reduction(overhead = {
267    num_vars = "31 * num_variables",
268    num_constraints = "num_constraints",
269})]
270impl ReduceTo<ILP<bool>> for ILP<i32> {
271    type Result = ReductionIntILPToBinaryILP;
272
273    fn reduce_to(&self) -> Self::Result {
274        if self.num_vars == 0 {
275            return ReductionIntILPToBinaryILP {
276                target: ILP::<bool>::new(0, vec![], vec![], self.sense),
277                encodings: vec![],
278            };
279        }
280
281        // Step 1: FBBT to infer upper bounds
282        let upper_bounds = match fbbt(self.num_vars, &self.constraints) {
283            Ok(bounds) => bounds,
284            Err(FbbtError::Infeasible) => {
285                // Return an infeasible ILP<bool>: 1 variable, constraint y0 >= 1 AND y0 <= 0
286                return ReductionIntILPToBinaryILP {
287                    target: ILP::<bool>::new(
288                        1,
289                        vec![
290                            LinearConstraint::ge(vec![(0, 1.0)], 1.0),
291                            LinearConstraint::le(vec![(0, 1.0)], 0.0),
292                        ],
293                        vec![],
294                        self.sense,
295                    ),
296                    encodings: (0..self.num_vars)
297                        .map(|_| VarEncoding {
298                            start: 0,
299                            weights: vec![],
300                        })
301                        .collect(),
302                };
303            }
304            Err(FbbtError::Unbounded) => {
305                // Fallback: use 31 bits per variable (full i32 range)
306                vec![(1i64 << 31) - 1; self.num_vars]
307            }
308        };
309
310        // Step 2: Build encodings
311        let mut encodings = Vec::with_capacity(self.num_vars);
312        let mut total_bool_vars = 0;
313        for &u in &upper_bounds {
314            let weights = binary_weights(u);
315            encodings.push(VarEncoding {
316                start: total_bool_vars,
317                weights: weights.clone(),
318            });
319            total_bool_vars += weights.len();
320        }
321
322        // Step 3: Transform constraints
323        let constraints = self
324            .constraints
325            .iter()
326            .map(|c| {
327                let mut new_terms = Vec::new();
328                for &(var, coef) in &c.terms {
329                    let enc = &encodings[var];
330                    for (j, &w) in enc.weights.iter().enumerate() {
331                        new_terms.push((enc.start + j, coef * w as f64));
332                    }
333                }
334                LinearConstraint::new(new_terms, c.cmp, c.rhs)
335            })
336            .collect();
337
338        // Step 4: Transform objective
339        let mut new_objective = Vec::new();
340        for &(var, coef) in &self.objective {
341            let enc = &encodings[var];
342            for (j, &w) in enc.weights.iter().enumerate() {
343                new_objective.push((enc.start + j, coef * w as f64));
344            }
345        }
346
347        ReductionIntILPToBinaryILP {
348            target: ILP::<bool>::new(total_bool_vars, constraints, new_objective, self.sense),
349            encodings,
350        }
351    }
352}
353
354#[cfg(test)]
355#[path = "../unit_tests/rules/ilp_i32_ilp_bool.rs"]
356mod tests;