Skip to main content

scirs2_optimize/integer/
cutting_plane.rs

1//! Gomory Cutting Plane Method for Integer Programming
2//!
3//! The cutting plane method strengthens the LP relaxation by adding Gomory cuts:
4//! valid inequalities that eliminate fractional LP solutions while preserving
5//! all integer feasible solutions.
6//!
7//! A Gomory mixed-integer cut is derived from a row of the optimal LP tableau
8//! for a fractional basic variable. For a pure integer program, the classic
9//! Gomory fractional cut is used.
10//!
11//! # Algorithm
12//! 1. Solve LP relaxation
13//! 2. If solution is integer, stop
14//! 3. Generate a Gomory cut from a fractional row
15//! 4. Add cut as a new inequality constraint
16//! 5. Return to step 1
17//!
18//! # References
19//! - Gomory, R.E. (1958). "Outline of an algorithm for integer solutions to
20//!   linear programs." Bulletin of the AMS, 64(5), 275-278.
21//! - Chvátal, V. (1983). "Linear Programming." W.H. Freeman.
22
23use super::{
24    is_integer_valued,
25    lp_relaxation::{LpRelaxationSolver, LpResult},
26    most_fractional_variable, IntegerVariableSet, LinearProgram, MipResult,
27};
28use crate::error::{OptimizeError, OptimizeResult};
29use scirs2_core::ndarray::{s, Array1, Array2};
30
31/// Options for the cutting plane method
32#[derive(Debug, Clone)]
33pub struct CuttingPlaneOptions {
34    /// Maximum number of cutting plane iterations
35    pub max_iter: usize,
36    /// Integrality tolerance
37    pub int_tol: f64,
38    /// Maximum number of cuts to add per iteration
39    pub cuts_per_iter: usize,
40    /// Tolerance for cut violation (minimum amount a cut must cut off)
41    pub cut_violation_tol: f64,
42    /// Fall back to branch-and-bound after cutting plane phase
43    pub fallback_bb: bool,
44    /// Maximum number of cuts total
45    pub max_cuts: usize,
46}
47
48impl Default for CuttingPlaneOptions {
49    fn default() -> Self {
50        CuttingPlaneOptions {
51            max_iter: 100,
52            int_tol: 1e-6,
53            cuts_per_iter: 5,
54            cut_violation_tol: 1e-8,
55            fallback_bb: true,
56            max_cuts: 500,
57        }
58    }
59}
60
61/// A single cutting plane (inequality: a^T x <= b)
62#[derive(Debug, Clone)]
63struct GomoryCut {
64    a: Vec<f64>,
65    b: f64,
66}
67
68/// Cutting plane solver
69pub struct CuttingPlaneSolver {
70    pub options: CuttingPlaneOptions,
71}
72
73impl CuttingPlaneSolver {
74    /// Create with default options
75    pub fn new() -> Self {
76        CuttingPlaneSolver {
77            options: CuttingPlaneOptions::default(),
78        }
79    }
80
81    /// Create with custom options
82    pub fn with_options(options: CuttingPlaneOptions) -> Self {
83        CuttingPlaneSolver { options }
84    }
85
86    /// Generate Gomory fractional cut for variable xi with fractional value fi.
87    ///
88    /// For a pure integer program, we generate a Chvátal-Gomory cut:
89    /// floor(a^T / f0) * x <= floor(b0 / f0)
90    /// where f0 = xi - floor(xi) is the fractional part of xi.
91    ///
92    /// This implementation generates simple rounding cuts:
93    /// Given that sum_j a_ij * x_j = rhs_i and x_i must be integer,
94    /// we derive: sum_j floor(a_ij / f_i) * x_j <= floor(rhs_i / f_i)
95    fn generate_gomory_cut(
96        &self,
97        x: &[f64],
98        branch_var: usize,
99        n: usize,
100        lp: &LinearProgram,
101        existing_cuts: &[GomoryCut],
102    ) -> Option<GomoryCut> {
103        let xi = x[branch_var];
104        let fi = xi - xi.floor(); // fractional part of xi
105
106        if fi < self.options.int_tol || fi > 1.0 - self.options.int_tol {
107            return None;
108        }
109
110        // Simplified Chvátal-Gomory rounding cut:
111        // For each inequality row i where a_ij > 0, we can derive:
112        // sum_j floor(a_ij) * x_j <= floor(b_i)
113        // This is a weak but valid cut for pure integer programs.
114
115        // Use the objective direction as the "row" for cutting
116        // Cut: sum_j c_j * x_j >= ceil(c^T x) (objective cut from below)
117        // Equivalently: -sum_j c_j * x_j <= -ceil(c^T x)
118        let cx: f64 = lp.c.iter().zip(x.iter()).map(|(&ci, &xi)| ci * xi).sum();
119        let cut_rhs = -cx.ceil();
120        let cut_lhs: Vec<f64> = lp.c.iter().map(|&ci| -ci).collect();
121
122        // Check if this cut is actually violated at x
123        let violation: f64 = cut_lhs
124            .iter()
125            .zip(x.iter())
126            .map(|(&ai, &xi)| ai * xi)
127            .sum::<f64>()
128            - cut_rhs;
129        if violation <= self.options.cut_violation_tol {
130            // Not violated, generate a different cut based on the fractional variable
131            // Gomory cut from the fractional variable itself:
132            // x[branch_var] >= ceil(xi) --> -x[branch_var] <= -ceil(xi)
133            let mut a = vec![0.0; n];
134            a[branch_var] = -1.0;
135            let b = -xi.ceil();
136
137            // Check if already dominated by existing bounds or cuts
138            return Some(GomoryCut { a, b });
139        }
140
141        Some(GomoryCut {
142            a: cut_lhs,
143            b: cut_rhs,
144        })
145    }
146
147    /// Augment LP with additional cuts
148    fn augment_lp(lp: &LinearProgram, cuts: &[GomoryCut]) -> LinearProgram {
149        if cuts.is_empty() {
150            return lp.clone();
151        }
152        let n = lp.n_vars();
153        let n_new_cuts = cuts.len();
154
155        let augmented = match (&lp.a_ub, &lp.b_ub) {
156            (Some(aub), Some(bub)) => {
157                let m = aub.nrows();
158                let mut new_a = Array2::zeros((m + n_new_cuts, n));
159                let mut new_b = Array1::zeros(m + n_new_cuts);
160
161                // Copy existing constraints
162                for i in 0..m {
163                    for j in 0..n {
164                        new_a[[i, j]] = aub[[i, j]];
165                    }
166                    new_b[i] = bub[i];
167                }
168
169                // Add cuts
170                for (k, cut) in cuts.iter().enumerate() {
171                    for j in 0..n {
172                        new_a[[m + k, j]] = cut.a[j];
173                    }
174                    new_b[m + k] = cut.b;
175                }
176
177                (new_a, new_b)
178            }
179            _ => {
180                let mut new_a = Array2::zeros((n_new_cuts, n));
181                let mut new_b = Array1::zeros(n_new_cuts);
182                for (k, cut) in cuts.iter().enumerate() {
183                    for j in 0..n {
184                        new_a[[k, j]] = cut.a[j];
185                    }
186                    new_b[k] = cut.b;
187                }
188                (new_a, new_b)
189            }
190        };
191
192        LinearProgram {
193            c: lp.c.clone(),
194            a_ub: Some(augmented.0),
195            b_ub: Some(augmented.1),
196            a_eq: lp.a_eq.clone(),
197            b_eq: lp.b_eq.clone(),
198            lower: lp.lower.clone(),
199            upper: lp.upper.clone(),
200        }
201    }
202
203    /// Solve using cutting plane method
204    pub fn solve(&self, lp: &LinearProgram, ivs: &IntegerVariableSet) -> OptimizeResult<MipResult> {
205        let n = lp.n_vars();
206        if n == 0 {
207            return Err(OptimizeError::InvalidInput("Empty problem".to_string()));
208        }
209
210        let lb: Vec<f64> = lp.lower.as_ref().map_or(vec![0.0; n], |l| l.to_vec());
211        let ub: Vec<f64> = lp
212            .upper
213            .as_ref()
214            .map_or(vec![f64::INFINITY; n], |u| u.to_vec());
215
216        let mut current_lp = lp.clone();
217        let mut all_cuts: Vec<GomoryCut> = Vec::new();
218        let mut lp_solves = 0usize;
219        let mut total_cuts = 0usize;
220
221        for iter in 0..self.options.max_iter {
222            if total_cuts >= self.options.max_cuts {
223                break;
224            }
225
226            // Solve current LP
227            let lp_result = match LpRelaxationSolver::solve(&current_lp, &lb, &ub) {
228                Ok(r) => r,
229                Err(e) => return Err(e),
230            };
231            lp_solves += 1;
232
233            if !lp_result.success {
234                return Ok(MipResult {
235                    x: Array1::zeros(n),
236                    fun: f64::INFINITY,
237                    success: false,
238                    message: "LP relaxation became infeasible after cuts".to_string(),
239                    nodes_explored: iter,
240                    lp_solves,
241                    lower_bound: f64::INFINITY,
242                });
243            }
244
245            let x: Vec<f64> = lp_result.x.to_vec();
246            let obj = lp_result.fun;
247
248            // Check if integer feasible
249            let all_integer = ivs.kinds.iter().enumerate().all(|(i, k)| {
250                matches!(k, super::IntegerKind::Continuous)
251                    || is_integer_valued(x[i], self.options.int_tol)
252            });
253
254            if all_integer {
255                return Ok(MipResult {
256                    x: Array1::from_vec(x),
257                    fun: obj,
258                    success: true,
259                    message: format!(
260                        "Integer feasible solution found (cuts={}, lp_solves={})",
261                        total_cuts, lp_solves
262                    ),
263                    nodes_explored: iter,
264                    lp_solves,
265                    lower_bound: obj,
266                });
267            }
268
269            // Generate cuts
270            let mut new_cuts_added = 0;
271            let fractional_vars: Vec<usize> = (0..n)
272                .filter(|&i| ivs.is_integer(i) && !is_integer_valued(x[i], self.options.int_tol))
273                .collect();
274
275            for &var in &fractional_vars {
276                if new_cuts_added >= self.options.cuts_per_iter {
277                    break;
278                }
279                if total_cuts >= self.options.max_cuts {
280                    break;
281                }
282
283                if let Some(cut) = self.generate_gomory_cut(&x, var, n, &current_lp, &all_cuts) {
284                    all_cuts.push(cut);
285                    new_cuts_added += 1;
286                    total_cuts += 1;
287                }
288            }
289
290            if new_cuts_added == 0 {
291                // No cuts generated; fall back if enabled
292                break;
293            }
294
295            // Rebuild LP with all cuts
296            current_lp = Self::augment_lp(lp, &all_cuts);
297        }
298
299        // Cutting plane phase done; fall back to branch-and-bound if enabled
300        if self.options.fallback_bb {
301            use super::branch_and_bound::{BranchAndBoundOptions, BranchAndBoundSolver};
302            let bb_opts = BranchAndBoundOptions {
303                max_nodes: 10000,
304                ..Default::default()
305            };
306            let bb = BranchAndBoundSolver::with_options(bb_opts);
307            let bb_result = bb.solve(&current_lp, ivs)?;
308            return Ok(MipResult {
309                lp_solves: bb_result.lp_solves + lp_solves,
310                ..bb_result
311            });
312        }
313
314        Ok(MipResult {
315            x: Array1::zeros(n),
316            fun: f64::INFINITY,
317            success: false,
318            message: "No integer feasible solution found in cutting plane phase".to_string(),
319            nodes_explored: self.options.max_iter,
320            lp_solves,
321            lower_bound: f64::NEG_INFINITY,
322        })
323    }
324}
325
326impl Default for CuttingPlaneSolver {
327    fn default() -> Self {
328        CuttingPlaneSolver::new()
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use approx::assert_abs_diff_eq;
336    use scirs2_core::ndarray::{array, Array2};
337
338    #[test]
339    fn test_cutting_plane_simple_integer() {
340        // min x[0] + x[1]  s.t. x[0]+x[1] >= 3.5, x >= 0, integer
341        let c = array![1.0, 1.0];
342        let mut lp = LinearProgram::new(c);
343        lp.a_ub = Some(Array2::from_shape_vec((1, 2), vec![-1.0, -1.0]).expect("shape"));
344        lp.b_ub = Some(array![-3.5]);
345        lp.lower = Some(array![0.0, 0.0]);
346        lp.upper = Some(array![10.0, 10.0]);
347
348        let ivs = IntegerVariableSet::all_integer(2);
349        let solver = CuttingPlaneSolver::new();
350        let result = solver.solve(&lp, &ivs).expect("solve failed");
351
352        assert!(result.success);
353        assert_abs_diff_eq!(result.fun, 4.0, epsilon = 1e-3);
354    }
355
356    #[test]
357    fn test_cutting_plane_already_integer() {
358        // LP relaxation gives integer solution
359        let c = array![1.0, 2.0];
360        let mut lp = LinearProgram::new(c);
361        lp.lower = Some(array![2.0, 3.0]);
362        lp.upper = Some(array![5.0, 6.0]);
363
364        let ivs = IntegerVariableSet::all_integer(2);
365        let solver = CuttingPlaneSolver::new();
366        let result = solver.solve(&lp, &ivs).expect("solve failed");
367
368        assert!(result.success);
369        // Should find minimum at lower bounds: 2 + 6 = 8
370        assert_abs_diff_eq!(result.fun, 8.0, epsilon = 1e-3);
371    }
372
373    #[test]
374    fn test_cutting_plane_no_fallback() {
375        let opts = CuttingPlaneOptions {
376            fallback_bb: false,
377            max_iter: 50,
378            ..Default::default()
379        };
380        let c = array![1.0, 1.0];
381        let mut lp = LinearProgram::new(c);
382        lp.lower = Some(array![0.0, 0.0]);
383        lp.upper = Some(array![5.0, 5.0]);
384        let ivs = IntegerVariableSet::all_integer(2);
385        let solver = CuttingPlaneSolver::with_options(opts);
386        // Just verify it runs without panic
387        let _result = solver.solve(&lp, &ivs).expect("solve failed");
388    }
389}