algos/math/integer_linear/
branch_and_price.rs

1use crate::math::integer_linear::{ILPSolution, ILPSolver, ILPStatus, IntegerLinearProgram};
2use crate::math::optimization::simplex::{minimize, LinearProgram};
3use crate::math::optimization::OptimizationConfig;
4use std::error::Error;
5
6pub struct BranchAndPriceSolver {
7    max_iterations: usize,
8    tolerance: f64,
9    max_columns_per_node: usize,
10}
11
12impl BranchAndPriceSolver {
13    pub fn new(max_iterations: usize, tolerance: f64, max_columns_per_node: usize) -> Self {
14        Self {
15            max_iterations,
16            tolerance,
17            max_columns_per_node,
18        }
19    }
20
21    fn is_integer(&self, value: f64) -> bool {
22        (value - value.round()).abs() < self.tolerance
23    }
24
25    fn solve_relaxation(
26        &self,
27        problem: &IntegerLinearProgram,
28    ) -> Result<ILPSolution, Box<dyn Error>> {
29        let original_objective = problem.objective.clone();
30        let n = problem.objective.len();
31        let m = problem.constraints.len();
32
33        // Convert the maximization problem into a minimization one by negating the objective.
34        let mut lp_objective: Vec<f64> = original_objective.iter().map(|x| -x).collect();
35        lp_objective.extend(vec![0.0; m]);
36
37        let lp_constraints: Vec<Vec<f64>> = problem
38            .constraints
39            .iter()
40            .enumerate()
41            .map(|(i, row)| {
42                let mut new_row = row.clone();
43                new_row.resize(n + m, 0.0);
44                new_row[n + i] = 1.0;
45                new_row
46            })
47            .collect();
48
49        let lp = LinearProgram {
50            objective: lp_objective,
51            constraints: lp_constraints,
52            rhs: problem.bounds.clone(),
53        };
54
55        let config = OptimizationConfig {
56            max_iterations: self.max_iterations,
57            tolerance: self.tolerance,
58            learning_rate: 1.0,
59        };
60        let result = minimize(&lp, &config);
61
62        if !result.converged
63            || result.optimal_point.is_empty()
64            || result.optimal_value.is_infinite()
65            || result.optimal_value.is_nan()
66        {
67            return Ok(ILPSolution {
68                values: vec![],
69                objective_value: f64::NEG_INFINITY,
70                status: ILPStatus::Infeasible,
71            });
72        }
73
74        // Retrieve the values corresponding to the original variables.
75        let x = result.optimal_point[..n].to_vec();
76        // Check feasibility against original constraints.
77        for (i, constraint) in problem.constraints.iter().enumerate() {
78            let lhs: f64 = constraint.iter().zip(&x).map(|(&a, &xi)| a * xi).sum();
79            if lhs > problem.bounds[i] + self.tolerance {
80                return Ok(ILPSolution {
81                    values: vec![],
82                    objective_value: f64::NEG_INFINITY,
83                    status: ILPStatus::Infeasible,
84                });
85            }
86        }
87
88        // Re-negate the optimal value to convert back into a maximization result.
89        Ok(ILPSolution {
90            values: x,
91            objective_value: -result.optimal_value,
92            status: ILPStatus::Optimal,
93        })
94    }
95
96    fn solve_pricing_problem(
97        &self,
98        dual_values: &[f64],
99        problem: &IntegerLinearProgram,
100    ) -> Option<Vec<f64>> {
101        // In a real implementation, this would solve a problem-specific pricing problem
102        // to find columns with negative reduced cost. Here we use a simple heuristic.
103        let mut best_column = None;
104        let mut best_reduced_cost = -self.tolerance;
105        // Try unit vectors as potential columns.
106        for i in 0..problem.constraints.len() {
107            let mut new_column = vec![0.0; problem.constraints.len()];
108            new_column[i] = 1.0;
109            // Calculate reduced cost for this column.
110            let mut reduced_cost = -1.0; // Assume unit contribution to objective.
111            for (j, &dual) in dual_values.iter().enumerate() {
112                reduced_cost += dual * new_column[j];
113            }
114            if reduced_cost < best_reduced_cost {
115                // Verify the column satisfies all constraints.
116                let mut feasible = true;
117                for (constraint, &bound) in problem.constraints.iter().zip(problem.bounds.iter()) {
118                    let lhs: f64 = constraint
119                        .iter()
120                        .zip(&new_column)
121                        .map(|(&a, &x)| a * x)
122                        .sum();
123                    if lhs > bound + self.tolerance {
124                        feasible = false;
125                        break;
126                    }
127                }
128                if feasible {
129                    best_reduced_cost = reduced_cost;
130                    best_column = Some(new_column);
131                }
132            }
133        }
134        best_column
135    }
136
137    fn generate_columns(&self, problem: &mut IntegerLinearProgram, solution: &ILPSolution) -> bool {
138        if problem.objective.len() >= self.max_columns_per_node {
139            return false;
140        }
141        let mut columns_added = 0;
142        let mut dual_values = vec![0.0; problem.constraints.len()];
143        // Calculate dual values from solution.
144        for (i, constraint) in problem.constraints.iter().enumerate() {
145            let lhs: f64 = constraint
146                .iter()
147                .zip(&solution.values)
148                .map(|(&a, &x)| a * x)
149                .sum();
150            let slack = problem.bounds[i] - lhs;
151            if slack.abs() < self.tolerance {
152                dual_values[i] = if problem.bounds[i] > 0.0 { 1.0 } else { -1.0 };
153            }
154        }
155        // Try to generate a single improving column.
156        if let Some(new_column) = self.solve_pricing_problem(&dual_values, problem) {
157            // Add new column to problem.
158            for (i, constraint) in problem.constraints.iter_mut().enumerate() {
159                constraint.push(new_column[i]);
160            }
161            // Calculate objective coefficient.
162            let obj_coeff = new_column
163                .iter()
164                .zip(problem.objective.iter())
165                .map(|(&a, &c)| a * c)
166                .sum::<f64>();
167            problem.objective.push(obj_coeff);
168            problem.integer_vars.push(problem.objective.len() - 1);
169            columns_added += 1;
170        }
171        columns_added > 0
172    }
173
174    fn branch(
175        &self,
176        problem: &IntegerLinearProgram,
177        var_idx: usize,
178        value: f64,
179    ) -> (IntegerLinearProgram, IntegerLinearProgram) {
180        let mut lower_branch = problem.clone();
181        let mut upper_branch = problem.clone();
182        // Add constraint x_i <= floor(value) to lower branch.
183        let mut lower_constraint = vec![0.0; problem.objective.len()];
184        lower_constraint[var_idx] = 1.0;
185        lower_branch.constraints.push(lower_constraint);
186        lower_branch.bounds.push(value.floor());
187        // Add constraint x_i >= ceil(value) to upper branch.
188        let mut upper_constraint = vec![0.0; problem.objective.len()];
189        upper_constraint[var_idx] = -1.0; // Use -1.0 to convert >= to <=.
190        upper_branch.constraints.push(upper_constraint);
191        upper_branch.bounds.push(-value.ceil()); // Negate bound for <=.
192        (lower_branch, upper_branch)
193    }
194}
195
196impl ILPSolver for BranchAndPriceSolver {
197    fn solve(&self, problem: &IntegerLinearProgram) -> Result<ILPSolution, Box<dyn Error>> {
198        let mut stack = vec![problem.clone()];
199        let mut iterations = 0;
200        let mut best_objective = f64::NEG_INFINITY;
201        let mut best_solution = None;
202
203        while let Some(node) = stack.pop() {
204            iterations += 1;
205            if iterations > self.max_iterations {
206                break;
207            }
208
209            // Solve LP relaxation on this node.
210            let relaxation = match self.solve_relaxation(&node) {
211                Ok(sol) if sol.status == ILPStatus::Optimal => sol,
212                _ => continue,
213            };
214
215            // If the LP relaxation yields an integer solution for the original variables, return it.
216            let mut solution_is_integer = true;
217            for &i in &node.integer_vars {
218                if (relaxation.values[i] - relaxation.values[i].round()).abs() > self.tolerance {
219                    solution_is_integer = false;
220                    break;
221                }
222            }
223            if solution_is_integer {
224                return Ok(relaxation);
225            }
226
227            // Generate additional columns if possible.
228            let mut current_node = node.clone();
229            if self.generate_columns(&mut current_node, &relaxation) {
230                stack.push(current_node);
231                continue;
232            }
233
234            // Prune if the relaxation's objective is not better than the best known.
235            if relaxation.objective_value <= best_objective {
236                continue;
237            }
238
239            // Branch on the most fractional variable.
240            let mut all_integer = true;
241            let mut most_fractional = None;
242            let mut max_frac_diff = 0.0;
243            for (i, &v) in relaxation.values.iter().enumerate() {
244                if node.integer_vars.contains(&i) && !self.is_integer(v) {
245                    all_integer = false;
246                    let frac = (v - v.floor()).abs();
247                    let diff = (frac - 0.5).abs();
248                    if diff > max_frac_diff {
249                        max_frac_diff = diff;
250                        most_fractional = Some((i, v));
251                    }
252                }
253            }
254            if !all_integer {
255                if let Some((idx, val)) = most_fractional {
256                    let (lower, upper) = self.branch(&node, idx, val);
257                    stack.push(lower);
258                    stack.push(upper);
259                }
260            }
261
262            // Update best solution if applicable.
263            if all_integer && relaxation.objective_value > best_objective {
264                best_objective = relaxation.objective_value;
265                best_solution = Some(relaxation);
266            }
267        }
268
269        Ok(best_solution.unwrap_or(ILPSolution {
270            values: vec![],
271            objective_value: f64::NEG_INFINITY,
272            status: ILPStatus::Infeasible,
273        }))
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_simple_ilp() -> Result<(), Box<dyn Error>> {
283        // maximize 2x + y
284        // subject to:
285        //   x + y ≤ 4
286        //   x ≤ 2
287        //   x, y ≥ 0 and integer
288        let problem = IntegerLinearProgram {
289            objective: vec![2.0, 1.0],
290            constraints: vec![
291                vec![1.0, 1.0],
292                vec![1.0, 0.0],
293                vec![-1.0, 0.0],
294                vec![0.0, -1.0],
295            ],
296            bounds: vec![4.0, 2.0, 0.0, 0.0],
297            integer_vars: vec![0, 1],
298        };
299        let solver = BranchAndPriceSolver::new(100, 1e-6, 5);
300        let solution = solver.solve(&problem)?;
301        assert_eq!(solution.status, ILPStatus::Optimal);
302        // Optimal solution should be x=2, y=2, value of 6.
303        assert!((solution.objective_value - 6.0).abs() < 1e-1);
304        for &v in &solution.values {
305            assert!((v - v.round()).abs() < 1e-6 && v >= 0.0);
306        }
307        Ok(())
308    }
309
310    #[test]
311    fn test_infeasible_ilp() -> Result<(), Box<dyn Error>> {
312        // maximize x + y
313        // subject to:
314        //   x + y ≤ 5
315        //   x + y ≥ 6  (represented as -(x + y) ≤ -6)
316        //   x, y ≥ 0 and integer
317        let problem = IntegerLinearProgram {
318            objective: vec![1.0, 1.0],
319            constraints: vec![
320                vec![1.0, 1.0],
321                vec![-1.0, -1.0],
322                vec![-1.0, 0.0],
323                vec![0.0, -1.0],
324            ],
325            bounds: vec![5.0, -6.0, 0.0, 0.0],
326            integer_vars: vec![0, 1],
327        };
328        let solver = BranchAndPriceSolver::new(100, 1e-6, 5);
329        let solution = solver.solve(&problem)?;
330        assert_eq!(solution.status, ILPStatus::Infeasible);
331        Ok(())
332    }
333}