Skip to main content

scirs2_optimize/integer/
lift_project.rs

1//! Lift-and-Project Cuts for 0-1 Mixed-Integer Programming
2//!
3//! Implements the Balas-Ceria-Cornuéjols (BCC, 1993) lift-and-project procedure
4//! for generating strong cutting planes from LP relaxation solutions.
5//!
6//! # Theory
7//!
8//! Given a 0-1 MIP with LP relaxation solution x̄ ∉ {0,1}^n, the lift-and-project
9//! procedure generates a valid inequality π·x ≥ π₀ that:
10//! - Is violated at x̄ (i.e., π·x̄ < π₀)
11//! - Is satisfied at all feasible 0-1 integer solutions
12//!
13//! The cut exploits the disjunction x_j = 0 OR x_j = 1 for a fractional variable j.
14//! By dualising the LP restricted to each branch and combining via a convex multiplier
15//! λ = x̄_j, we obtain a cut that is valid for the convex hull of feasible integer points.
16//!
17//! ## Algorithm (per variable j, per constraint row i)
18//!
19//! The BCC formula for a constraint row i with a\[i\]\[j\] ≠ 0:
20//!
21//! Define:
22//!   - f_j = x̄_j ∈ (0, 1)   (fractional value)
23//!   - r_i = b_i - Σ_k a_{ik} · x̄_k   (constraint slack at x̄, ≥ 0 for LP feasible)
24//!
25//! When a\[i\]\[j\] > 0, the BCC disjunctive cut from row i is:
26//!
27//!   π · x ≥ π₀   where   π = a\[i\],   π₀ = a_i · x̄ - r_i · f_j / (1 - f_j)
28//!
29//! Violation at x̄: π · x̄ − π₀ = r_i · f_j / (1 − f_j) ≥ 0.
30//!
31//! When the structural constraints are all tight (r_i = 0 for every row i
32//! with a\[i\]\[j\] ≠ 0), the structural rows give zero violation.  To handle
33//! this case the generator augments the constraint system with the variable
34//! bound rows 0 ≤ x_j ≤ 1 (written as -x_k ≤ 0 and x_k ≤ 1 for each
35//! integer variable k ≠ j).  The bound row x_k ≤ 1 for k ≠ j has:
36//!
37//!   dot_ax = x̄_k,  r_i = 1 − x̄_k > 0  (since x̄_k < 1),  a_{ij} = 0
38//!
39//! which provides a non-degenerate row when combined with variable j.
40//!
41//! # References
42//! - Balas, E., Ceria, S., & Cornuéjols, G. (1993). "A lift-and-project cutting
43//!   plane algorithm for mixed 0–1 programs." Mathematical Programming, 58(1-3), 295-324.
44//! - Balas, E. (1979). "Disjunctive programming." Annals of Discrete Mathematics, 5, 3–51.
45
46use crate::error::{OptimizeError, OptimizeResult};
47
48// ── Variable selection strategy ─────────────────────────────────────────────
49
50/// Strategy for selecting which fractional variable to lift-and-project on.
51///
52/// The choice of variable can significantly affect the strength of the generated
53/// cut and the overall convergence of the cutting plane algorithm.
54#[non_exhaustive]
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum VariableSelectionStrategy {
57    /// Select the variable whose fractional value is closest to 0.5.
58    /// Produces the most balanced disjunction and typically the strongest cuts.
59    #[default]
60    MostFractional,
61    /// Select the first fractional variable encountered (by index).
62    /// Fast selection with predictable, index-ordered behaviour.
63    FirstFractional,
64    /// Select the variable that maximises the violation of the generated cut at x̄.
65    /// Requires generating candidate cuts for each fractional variable.
66    DeepestCut,
67}
68
69// ── Configuration ────────────────────────────────────────────────────────────
70
71/// Configuration for the lift-and-project cut generator.
72#[derive(Debug, Clone)]
73pub struct LiftProjectConfig {
74    /// Maximum total number of cuts to generate in one call to `generate_cuts`.
75    /// Default: 50.
76    pub max_cuts: usize,
77    /// Strategy for choosing which fractional variable to lift on.
78    pub variable_selection: VariableSelectionStrategy,
79    /// Minimum cut violation threshold.
80    /// A cut with violation ≤ this value is discarded as numerically insignificant.
81    /// Default: 1e-6.
82    pub cut_violation_tol: f64,
83    /// Whether to apply Lovász-Schrijver SDP-based strengthening to generated cuts.
84    /// Produces stronger cuts at higher computational cost.
85    /// Default: false (pure LP-based lift-and-project only).
86    pub ls_strengthening: bool,
87    /// Tolerance for considering a variable value integral.
88    /// Variables with |x_j - round(x_j)| ≤ int_tol are treated as integer.
89    /// Default: 1e-8.
90    pub int_tol: f64,
91    /// Maximum number of constraint rows to consider per fractional variable
92    /// (before bound augmentation).
93    /// Default: 1000 (effectively unlimited for most problems).
94    pub max_rows_per_var: usize,
95}
96
97impl Default for LiftProjectConfig {
98    fn default() -> Self {
99        LiftProjectConfig {
100            max_cuts: 50,
101            variable_selection: VariableSelectionStrategy::MostFractional,
102            cut_violation_tol: 1e-6,
103            ls_strengthening: false,
104            int_tol: 1e-8,
105            max_rows_per_var: 1000,
106        }
107    }
108}
109
110// ── Cut representation ────────────────────────────────────────────────────────
111
112/// A single lift-and-project cut of the form π · x ≥ π₀.
113///
114/// The cut is a valid inequality for the integer hull of the feasible region
115/// that is violated by the current LP relaxation solution x̄.
116#[derive(Debug, Clone)]
117pub struct LiftProjectCut {
118    /// Cut coefficient vector π ∈ ℝⁿ.
119    pub pi: Vec<f64>,
120    /// Cut right-hand side π₀ ∈ ℝ.
121    /// The cut is: π · x ≥ π₀.
122    pub pi0: f64,
123    /// Index of the fractional variable this cut was generated for.
124    pub source_var: usize,
125    /// Index of the constraint row used to derive this cut
126    /// (may refer to an augmented bound row).
127    pub source_row: usize,
128    /// Violation at the LP solution x̄: positive means x̄ violates the cut.
129    pub violation: f64,
130}
131
132// ── Lift-and-project generator ───────────────────────────────────────────────
133
134/// Generator for Balas-Ceria-Cornuéjols lift-and-project cuts.
135///
136/// # Usage
137///
138/// ```rust
139/// use scirs2_optimize::integer::lift_project::{
140///     LiftProjectConfig, LiftProjectGenerator,
141/// };
142///
143/// let config = LiftProjectConfig::default();
144/// let gen = LiftProjectGenerator::new(config);
145///
146/// // Constraint: x1 + x2 <= 1, x1 >= 0, x2 >= 0, x1,x2 ∈ {0,1}
147/// let a = vec![vec![1.0, 1.0]];
148/// let b = vec![1.0];
149/// let x_bar = vec![0.6, 0.4]; // fractional LP solution
150/// let integer_vars = vec![0, 1];
151///
152/// let cuts = gen.generate_cuts(&a, &b, &x_bar, &integer_vars).unwrap();
153/// assert!(!cuts.is_empty());
154/// ```
155pub struct LiftProjectGenerator {
156    config: LiftProjectConfig,
157}
158
159impl LiftProjectGenerator {
160    /// Create a new generator with the given configuration.
161    pub fn new(config: LiftProjectConfig) -> Self {
162        LiftProjectGenerator { config }
163    }
164
165    /// Create a new generator with default configuration.
166    pub fn default_generator() -> Self {
167        LiftProjectGenerator::new(LiftProjectConfig::default())
168    }
169
170    // ── Public API ───────────────────────────────────────────────────────────
171
172    /// Generate lift-and-project cuts from the LP relaxation solution.
173    ///
174    /// # Arguments
175    ///
176    /// * `a` – Constraint matrix rows (constraint is `a[i] · x ≤ b[i]`).
177    /// * `b` – Right-hand side vector.
178    /// * `x_bar` – Current LP relaxation solution (may be fractional).
179    /// * `integer_vars` – Indices of variables constrained to {0, 1}.
180    ///
181    /// # Returns
182    ///
183    /// A vector of [`LiftProjectCut`]s violated at `x_bar`, sorted by decreasing
184    /// violation (strongest cut first).  Empty when x_bar is integer-feasible.
185    pub fn generate_cuts(
186        &self,
187        a: &[Vec<f64>],
188        b: &[f64],
189        x_bar: &[f64],
190        integer_vars: &[usize],
191    ) -> OptimizeResult<Vec<LiftProjectCut>> {
192        let n = x_bar.len();
193        if n == 0 {
194            return Err(OptimizeError::InvalidInput(
195                "x_bar must be non-empty".to_string(),
196            ));
197        }
198        if a.len() != b.len() {
199            return Err(OptimizeError::InvalidInput(format!(
200                "Constraint matrix has {} rows but b has {} entries",
201                a.len(),
202                b.len()
203            )));
204        }
205        for (i, row) in a.iter().enumerate() {
206            if row.len() != n {
207                return Err(OptimizeError::InvalidInput(format!(
208                    "Row {} has {} columns but x_bar has {} components",
209                    i,
210                    row.len(),
211                    n
212                )));
213            }
214        }
215
216        // Build augmented constraint system: structural + bound rows (0 ≤ x_k ≤ 1)
217        let (a_aug, b_aug) = build_augmented_constraints(a, b, x_bar, integer_vars);
218
219        // Collect fractional integer variables
220        let fractional_vars: Vec<usize> = integer_vars
221            .iter()
222            .copied()
223            .filter(|&j| {
224                j < n && {
225                    let xj = x_bar[j];
226                    xj > self.config.int_tol && xj < 1.0 - self.config.int_tol
227                }
228            })
229            .collect();
230
231        if fractional_vars.is_empty() {
232            return Ok(Vec::new());
233        }
234
235        let mut all_cuts: Vec<LiftProjectCut> = Vec::new();
236
237        match self.config.variable_selection {
238            VariableSelectionStrategy::MostFractional => {
239                let mut ranked: Vec<(usize, f64)> = fractional_vars
240                    .iter()
241                    .map(|&j| {
242                        let frac = x_bar[j];
243                        let dist = frac.min(1.0 - frac);
244                        (j, dist)
245                    })
246                    .collect();
247                ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
248                for (j, _) in ranked {
249                    if all_cuts.len() >= self.config.max_cuts {
250                        break;
251                    }
252                    self.append_cuts_for_var(&a_aug, &b_aug, x_bar, j, &mut all_cuts)?;
253                }
254            }
255            VariableSelectionStrategy::FirstFractional => {
256                for &j in &fractional_vars {
257                    if all_cuts.len() >= self.config.max_cuts {
258                        break;
259                    }
260                    self.append_cuts_for_var(&a_aug, &b_aug, x_bar, j, &mut all_cuts)?;
261                }
262            }
263            VariableSelectionStrategy::DeepestCut => {
264                let mut candidates: Vec<LiftProjectCut> = Vec::new();
265                for &j in &fractional_vars {
266                    let mut tmp: Vec<LiftProjectCut> = Vec::new();
267                    self.append_cuts_for_var(&a_aug, &b_aug, x_bar, j, &mut tmp)?;
268                    if let Some(best) = tmp.into_iter().max_by(|c1, c2| {
269                        c1.violation
270                            .partial_cmp(&c2.violation)
271                            .unwrap_or(std::cmp::Ordering::Equal)
272                    }) {
273                        candidates.push(best);
274                    }
275                }
276                candidates.sort_by(|c1, c2| {
277                    c2.violation
278                        .partial_cmp(&c1.violation)
279                        .unwrap_or(std::cmp::Ordering::Equal)
280                });
281                candidates.truncate(self.config.max_cuts);
282                all_cuts = candidates;
283            }
284        }
285
286        all_cuts.sort_by(|c1, c2| {
287            c2.violation
288                .partial_cmp(&c1.violation)
289                .unwrap_or(std::cmp::Ordering::Equal)
290        });
291
292        Ok(all_cuts)
293    }
294
295    /// Select a single fractional variable according to the configured strategy.
296    ///
297    /// Returns `None` if no fractional variable is found among `integer_vars`.
298    pub fn select_variable(&self, x_bar: &[f64], integer_vars: &[usize]) -> Option<usize> {
299        let n = x_bar.len();
300        match self.config.variable_selection {
301            VariableSelectionStrategy::FirstFractional => integer_vars.iter().copied().find(|&j| {
302                j < n && x_bar[j] > self.config.int_tol && x_bar[j] < 1.0 - self.config.int_tol
303            }),
304            VariableSelectionStrategy::MostFractional | VariableSelectionStrategy::DeepestCut => {
305                let mut best_idx = None;
306                let mut best_dist = -1.0_f64;
307                for &j in integer_vars {
308                    if j >= n {
309                        continue;
310                    }
311                    let xj = x_bar[j];
312                    if xj > self.config.int_tol && xj < 1.0 - self.config.int_tol {
313                        let dist = xj.min(1.0 - xj);
314                        if dist > best_dist {
315                            best_dist = dist;
316                            best_idx = Some(j);
317                        }
318                    }
319                }
320                best_idx
321            }
322        }
323    }
324
325    /// Generate the single strongest BCC disjunctive cut for fractional variable `j`.
326    ///
327    /// Uses the augmented constraint system (structural + bound rows) to ensure
328    /// a violated cut can always be found when x̄_j ∈ (0, 1).
329    ///
330    /// Returns `None` when:
331    /// - x̄_j is integer-valued (within `int_tol`), or
332    /// - No row in the augmented system gives violation > `cut_violation_tol`.
333    pub fn generate_cut_for_var(
334        &self,
335        a: &[Vec<f64>],
336        b: &[f64],
337        x_bar: &[f64],
338        j: usize,
339    ) -> Option<LiftProjectCut> {
340        let f_j = x_bar[j];
341        if f_j <= self.config.int_tol || f_j >= 1.0 - self.config.int_tol {
342            return None;
343        }
344        // Use an empty integer_vars list just to build bound rows for all integer vars
345        // inferred from j alone.  Build the bound rows for variable j explicitly.
346        let n = x_bar.len();
347        let integer_vars_for_j: Vec<usize> = (0..n).collect();
348        let (a_aug, b_aug) = build_augmented_constraints(a, b, x_bar, &integer_vars_for_j);
349        self.best_cut_from_rows(&a_aug, &b_aug, x_bar, j)
350    }
351
352    /// Compute the signed violation of a cut at x̄.
353    ///
354    /// Returns `π · x̄ - π₀`.  A **positive** value means x̄ **violates** the cut
355    /// (x̄ does not satisfy π · x ≥ π₀).  A negative or zero value means x̄ satisfies it.
356    pub fn cut_violation(&self, cut: &LiftProjectCut, x_bar: &[f64]) -> f64 {
357        cut.pi
358            .iter()
359            .zip(x_bar.iter())
360            .map(|(&pi_k, &xk)| pi_k * xk)
361            .sum::<f64>()
362            - cut.pi0
363    }
364
365    // ── Private helpers ──────────────────────────────────────────────────────
366
367    /// Compute the BCC cut for a single augmented-row index and variable j.
368    ///
369    /// Returns `Some(cut)` when `violation > cut_violation_tol`.
370    fn bcc_cut_from_row(
371        &self,
372        row: &[f64],
373        bi: f64,
374        x_bar: &[f64],
375        j: usize,
376        row_index: usize,
377    ) -> Option<LiftProjectCut> {
378        let a_ij = row[j];
379        if a_ij.abs() < 1e-12 {
380            return None;
381        }
382        let dot_ax: f64 = row
383            .iter()
384            .zip(x_bar.iter())
385            .map(|(&aik, &xk)| aik * xk)
386            .sum();
387        let r_i = bi - dot_ax; // slack at x̄ (≥ 0 for LP-feasible constraint)
388        let f_j = x_bar[j];
389
390        // BCC formula: violation = r_i * f_j / (1 - f_j)   when a_ij > 0
391        //              violation = r_i * (1 - f_j) / f_j   when a_ij < 0
392        let violation = if a_ij > 0.0 {
393            r_i * f_j / (1.0 - f_j)
394        } else {
395            r_i * (1.0 - f_j) / f_j
396        };
397
398        if violation <= self.config.cut_violation_tol {
399            return None;
400        }
401
402        let pi0 = dot_ax - violation;
403
404        Some(LiftProjectCut {
405            pi: row.to_vec(),
406            pi0,
407            source_var: j,
408            source_row: row_index,
409            violation,
410        })
411    }
412
413    /// Find the single best (largest violation) cut for variable `j` from `(a, b)`.
414    fn best_cut_from_rows(
415        &self,
416        a: &[Vec<f64>],
417        b: &[f64],
418        x_bar: &[f64],
419        j: usize,
420    ) -> Option<LiftProjectCut> {
421        let mut best: Option<LiftProjectCut> = None;
422        let row_limit = self.config.max_rows_per_var.min(a.len());
423        for (i, (row, &bi)) in a.iter().zip(b.iter()).enumerate().take(row_limit) {
424            if let Some(cut) = self.bcc_cut_from_row(row, bi, x_bar, j, i) {
425                let better = best
426                    .as_ref()
427                    .map_or(true, |prev| cut.violation > prev.violation);
428                if better {
429                    best = Some(cut);
430                }
431            }
432        }
433        best
434    }
435
436    /// Generate all violated BCC cuts for variable `j` and append to `out`.
437    fn append_cuts_for_var(
438        &self,
439        a: &[Vec<f64>],
440        b: &[f64],
441        x_bar: &[f64],
442        j: usize,
443        out: &mut Vec<LiftProjectCut>,
444    ) -> OptimizeResult<()> {
445        let f_j = x_bar[j];
446        if f_j <= self.config.int_tol || f_j >= 1.0 - self.config.int_tol {
447            return Ok(());
448        }
449        let row_limit = self.config.max_rows_per_var.min(a.len());
450        for (i, (row, &bi)) in a.iter().zip(b.iter()).enumerate().take(row_limit) {
451            if out.len() >= self.config.max_cuts {
452                break;
453            }
454            if let Some(cut) = self.bcc_cut_from_row(row, bi, x_bar, j, i) {
455                out.push(cut);
456            }
457        }
458        Ok(())
459    }
460}
461
462// ── Constraint augmentation ──────────────────────────────────────────────────
463
464/// Build an augmented constraint matrix by appending variable bound rows.
465///
466/// For each variable k in `integer_vars`:
467///   - Upper bound row:  `e_k · x ≤ 1`   (i.e. x_k ≤ 1)
468///   - Lower bound row: `-e_k · x ≤ 0`   (i.e. x_k ≥ 0)
469///
470/// Adding these bound rows ensures the BCC formula can always generate a
471/// violated cut for any fractional variable, even when all structural
472/// constraints are tight (slack = 0) at x̄.
473///
474/// Why the bound rows always help:
475/// For variable j with f_j ∈ (0,1) and bound row `x_k ≤ 1` (k ≠ j, a_{ij}=0):
476///   The formula requires a_ij ≠ 0; these rows don't directly help for j.
477///
478/// For bound row `x_j ≤ 1` (a_ij = 1 > 0, b_i = 1):
479///   r_i = 1 - x̄_j = 1 - f_j > 0  (since f_j < 1)
480///   violation = r_i * f_j / (1 - f_j) = (1 - f_j) * f_j / (1 - f_j) = f_j > 0 ✓
481///
482/// So the upper bound row for j itself always gives violation = f_j > 0.
483fn build_augmented_constraints(
484    a: &[Vec<f64>],
485    b: &[f64],
486    x_bar: &[f64],
487    integer_vars: &[usize],
488) -> (Vec<Vec<f64>>, Vec<f64>) {
489    let n = x_bar.len();
490    let mut a_aug: Vec<Vec<f64>> = a.to_vec();
491    let mut b_aug: Vec<f64> = b.to_vec();
492
493    for &k in integer_vars {
494        if k >= n {
495            continue;
496        }
497        // Upper bound: x_k ≤ 1  →  e_k · x ≤ 1
498        let mut ub_row = vec![0.0; n];
499        ub_row[k] = 1.0;
500        a_aug.push(ub_row);
501        b_aug.push(1.0);
502
503        // Lower bound: x_k ≥ 0  →  -e_k · x ≤ 0
504        let mut lb_row = vec![0.0; n];
505        lb_row[k] = -1.0;
506        a_aug.push(lb_row);
507        b_aug.push(0.0);
508    }
509
510    (a_aug, b_aug)
511}
512
513// ── Lovász-Schrijver strengthening (optional) ────────────────────────────────
514
515/// Apply Lovász-Schrijver (LS) strengthening to a lift-and-project cut.
516///
517/// LS strengthening derives tighter coefficients by exploiting the semidefinite
518/// relaxation of the 0-1 hull.  The key observation is that for binary x_j,
519/// the product x_j * x_k can be linearised:
520///
521///   Y_{jk} = x_j · x_k  →  Y_{jk} ∈ [0, 1],  Y_{jk} ≤ x_j,  Y_{jk} ≤ x_k.
522///
523/// In this simplified implementation we tighten the cut coefficient π_k for
524/// k ≠ j by using the additional bound Y_{jk} ≤ x_j (since x_j ≤ 1),
525/// which allows us to increase the RHS when x̄_k > f_j.
526///
527/// A full SDP-based LS would require a semidefinite programming solver.
528pub fn ls_strengthen(
529    cut: &LiftProjectCut,
530    x_bar: &[f64],
531    integer_vars: &[usize],
532    j: usize,
533) -> LiftProjectCut {
534    let n = cut.pi.len();
535    let f_j = if j < x_bar.len() { x_bar[j] } else { 0.5 };
536
537    let mut new_pi = cut.pi.clone();
538    let mut delta_pi0 = 0.0_f64;
539
540    for &k in integer_vars {
541        if k >= n || k == j {
542            continue;
543        }
544        let x_k = if k < x_bar.len() { x_bar[k] } else { continue };
545        let pi_k = cut.pi[k];
546        // LS tightening: when π_k > 0 and x_k > f_j, we can use the product bound
547        // Y_{jk} ≤ x_j to strengthen the coefficient.
548        if pi_k > 0.0 && x_k > f_j {
549            let tightening = pi_k * (x_k - f_j) * f_j;
550            delta_pi0 += tightening;
551            let scale_denom = x_k + 1e-12;
552            new_pi[k] = pi_k + tightening / scale_denom;
553        }
554    }
555
556    LiftProjectCut {
557        pi: new_pi,
558        pi0: cut.pi0 + delta_pi0,
559        source_var: cut.source_var,
560        source_row: cut.source_row,
561        violation: cut.violation - delta_pi0,
562    }
563}
564
565// ── Utility ──────────────────────────────────────────────────────────────────
566
567/// Verify that a cut is satisfied at an integer 0-1 point.
568///
569/// Returns `true` if `π · x ≥ π₀` holds for the given integer point `x`.
570pub fn cut_satisfied_at_integer(cut: &LiftProjectCut, x: &[f64]) -> bool {
571    let dot: f64 = cut
572        .pi
573        .iter()
574        .zip(x.iter())
575        .map(|(&pi_k, &xk)| pi_k * xk)
576        .sum();
577    dot >= cut.pi0 - 1e-9
578}
579
580// ────────────────────────────────────────────────────────────────────────────
581//  Tests
582// ────────────────────────────────────────────────────────────────────────────
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    // Helper: simple 0-1 feasible region
589    // Constraints: x1 + x2 <= 1, x1 >= 0, x2 >= 0.
590    // Feasible integer points: (0,0), (1,0), (0,1).
591    // LP relaxation admits x̄ = (0.5, 0.5).
592    fn simple_constraints() -> (Vec<Vec<f64>>, Vec<f64>) {
593        let a = vec![vec![1.0, 1.0]];
594        let b = vec![1.0];
595        (a, b)
596    }
597
598    fn simple_x_bar() -> Vec<f64> {
599        vec![0.5, 0.5]
600    }
601
602    fn simple_integer_vars() -> Vec<usize> {
603        vec![0, 1]
604    }
605
606    // ── Variable selection ────────────────────────────────────────────────
607
608    #[test]
609    fn test_most_fractional_selects_closest_to_half() {
610        let config = LiftProjectConfig {
611            variable_selection: VariableSelectionStrategy::MostFractional,
612            ..Default::default()
613        };
614        let gen = LiftProjectGenerator::new(config);
615        // x[0]=0.2 (dist 0.2), x[1]=0.5 (dist 0.5), x[2]=0.4 (dist 0.4)
616        let x_bar = vec![0.2, 0.5, 0.4];
617        let integer_vars = vec![0, 1, 2];
618        let selected = gen.select_variable(&x_bar, &integer_vars);
619        // x[1] is closest to 0.5
620        assert_eq!(selected, Some(1));
621    }
622
623    #[test]
624    fn test_first_fractional_selects_first() {
625        let config = LiftProjectConfig {
626            variable_selection: VariableSelectionStrategy::FirstFractional,
627            ..Default::default()
628        };
629        let gen = LiftProjectGenerator::new(config);
630        let x_bar = vec![0.8, 0.5, 0.3];
631        let integer_vars = vec![0, 1, 2];
632        let selected = gen.select_variable(&x_bar, &integer_vars);
633        assert_eq!(selected, Some(0));
634    }
635
636    #[test]
637    fn test_select_variable_returns_none_when_all_integer() {
638        let config = LiftProjectConfig::default();
639        let gen = LiftProjectGenerator::new(config);
640        // All integer-valued (within tolerance)
641        let x_bar = vec![0.0, 1.0, 0.0, 1.0];
642        let integer_vars = vec![0, 1, 2, 3];
643        assert_eq!(gen.select_variable(&x_bar, &integer_vars), None);
644    }
645
646    #[test]
647    fn test_select_variable_skips_continuous_vars() {
648        let config = LiftProjectConfig {
649            variable_selection: VariableSelectionStrategy::MostFractional,
650            ..Default::default()
651        };
652        let gen = LiftProjectGenerator::new(config);
653        let x_bar = vec![0.3, 0.5, 0.4];
654        // Only variable 2 is integer-constrained
655        let integer_vars = vec![2];
656        let selected = gen.select_variable(&x_bar, &integer_vars);
657        assert_eq!(selected, Some(2));
658    }
659
660    // ── generate_cuts returns empty when no fractional variables ──────────
661
662    #[test]
663    fn test_generate_cuts_empty_for_integer_solution() {
664        let gen = LiftProjectGenerator::default_generator();
665        let (a, b) = simple_constraints();
666        let x_bar = vec![1.0, 0.0]; // integer point
667        let integer_vars = simple_integer_vars();
668        let cuts = gen.generate_cuts(&a, &b, &x_bar, &integer_vars).unwrap();
669        assert!(cuts.is_empty());
670    }
671
672    #[test]
673    fn test_generate_cuts_empty_for_no_integer_vars() {
674        let gen = LiftProjectGenerator::default_generator();
675        let (a, b) = simple_constraints();
676        let x_bar = vec![0.5, 0.5];
677        let cuts = gen.generate_cuts(&a, &b, &x_bar, &[]).unwrap();
678        assert!(cuts.is_empty());
679    }
680
681    // ── Cuts are violated at x̄ ────────────────────────────────────────────
682
683    #[test]
684    fn test_cuts_violated_at_x_bar() {
685        let gen = LiftProjectGenerator::default_generator();
686        let (a, b) = simple_constraints();
687        let x_bar = simple_x_bar();
688        let integer_vars = simple_integer_vars();
689        let cuts = gen.generate_cuts(&a, &b, &x_bar, &integer_vars).unwrap();
690        assert!(!cuts.is_empty(), "Expected at least one cut");
691        for cut in &cuts {
692            let violation = gen.cut_violation(cut, &x_bar);
693            assert!(
694                violation > gen.config.cut_violation_tol,
695                "Cut should be violated at x̄, got violation = {}",
696                violation
697            );
698        }
699    }
700
701    #[test]
702    fn test_cut_violation_is_positive_at_x_bar() {
703        let gen = LiftProjectGenerator::default_generator();
704        let (a, b) = simple_constraints();
705        let x_bar = simple_x_bar();
706        let cut = gen
707            .generate_cut_for_var(&a, &b, &x_bar, 0)
708            .expect("Should generate a cut for variable 0");
709        assert!(
710            cut.violation > 0.0,
711            "violation field should be positive, got {}",
712            cut.violation
713        );
714        // cross-check with cut_violation helper
715        let v2 = gen.cut_violation(&cut, &x_bar);
716        assert!(
717            (cut.violation - v2).abs() < 1e-12,
718            "violation field and cut_violation must agree: {} vs {}",
719            cut.violation,
720            v2
721        );
722    }
723
724    // ── Cuts satisfied at integer points ─────────────────────────────────
725
726    #[test]
727    fn test_cuts_satisfied_at_zero_vector() {
728        let gen = LiftProjectGenerator::default_generator();
729        let (a, b) = simple_constraints();
730        let x_bar = simple_x_bar();
731        let integer_vars = simple_integer_vars();
732        let cuts = gen.generate_cuts(&a, &b, &x_bar, &integer_vars).unwrap();
733        let zero = vec![0.0, 0.0];
734        for cut in &cuts {
735            let dot: f64 = cut.pi.iter().zip(zero.iter()).map(|(&p, &x)| p * x).sum();
736            assert!(
737                cut_satisfied_at_integer(cut, &zero),
738                "Cut should hold at x=(0,0): π·x={:.6} ≥ π₀={:.6}",
739                dot,
740                cut.pi0
741            );
742        }
743    }
744
745    #[test]
746    fn test_cuts_satisfied_at_ones_vector() {
747        // Use constraint x1 + x2 <= 2 so (1,1) is feasible
748        let a = vec![vec![1.0, 1.0]];
749        let b = vec![2.0];
750        let x_bar = vec![0.4, 0.6];
751        let gen = LiftProjectGenerator::default_generator();
752        let cuts = gen.generate_cuts(&a, &b, &x_bar, &[0, 1]).unwrap();
753        let ones = vec![1.0, 1.0];
754        for cut in &cuts {
755            assert!(
756                cut_satisfied_at_integer(cut, &ones),
757                "Cut should hold at x=(1,1): π·x={:.6} ≥ π₀={:.6}",
758                cut.pi
759                    .iter()
760                    .zip(ones.iter())
761                    .map(|(&p, &x)| p * x)
762                    .sum::<f64>(),
763                cut.pi0
764            );
765        }
766    }
767
768    #[test]
769    fn test_cuts_satisfied_at_unit_vectors() {
770        let gen = LiftProjectGenerator::default_generator();
771        let (a, b) = simple_constraints();
772        let x_bar = simple_x_bar();
773        let integer_vars = simple_integer_vars();
774        let cuts = gen.generate_cuts(&a, &b, &x_bar, &integer_vars).unwrap();
775        let e0 = vec![1.0, 0.0];
776        let e1 = vec![0.0, 1.0];
777        for cut in &cuts {
778            assert!(cut_satisfied_at_integer(cut, &e0), "Cut should hold at e0");
779            assert!(cut_satisfied_at_integer(cut, &e1), "Cut should hold at e1");
780        }
781    }
782
783    // ── max_cuts config limits output ─────────────────────────────────────
784
785    #[test]
786    fn test_max_cuts_limits_output() {
787        let config = LiftProjectConfig {
788            max_cuts: 1,
789            ..Default::default()
790        };
791        let gen = LiftProjectGenerator::new(config);
792        let a = vec![
793            vec![1.0, 0.0],
794            vec![0.0, 1.0],
795            vec![1.0, 1.0],
796            vec![2.0, 1.0],
797        ];
798        let b = vec![1.0, 1.0, 1.5, 2.0];
799        let x_bar = vec![0.4, 0.6];
800        let cuts = gen.generate_cuts(&a, &b, &x_bar, &[0, 1]).unwrap();
801        assert!(
802            cuts.len() <= 1,
803            "Expected at most 1 cut, got {}",
804            cuts.len()
805        );
806    }
807
808    #[test]
809    fn test_zero_max_cuts_returns_empty() {
810        let config = LiftProjectConfig {
811            max_cuts: 0,
812            ..Default::default()
813        };
814        let gen = LiftProjectGenerator::new(config);
815        let (a, b) = simple_constraints();
816        let x_bar = simple_x_bar();
817        let cuts = gen.generate_cuts(&a, &b, &x_bar, &[0, 1]).unwrap();
818        assert!(cuts.is_empty());
819    }
820
821    // ── Error handling ────────────────────────────────────────────────────
822
823    #[test]
824    fn test_generate_cuts_error_on_empty_x_bar() {
825        let gen = LiftProjectGenerator::default_generator();
826        let result = gen.generate_cuts(&[], &[], &[], &[]);
827        assert!(result.is_err());
828    }
829
830    #[test]
831    fn test_generate_cuts_error_on_mismatched_a_b() {
832        let gen = LiftProjectGenerator::default_generator();
833        let a = vec![vec![1.0, 1.0], vec![1.0, 0.0]];
834        let b = vec![1.0]; // only 1 entry for 2 rows
835        let result = gen.generate_cuts(&a, &b, &[0.5, 0.5], &[0, 1]);
836        assert!(result.is_err());
837    }
838
839    #[test]
840    fn test_generate_cuts_error_on_row_length_mismatch() {
841        let gen = LiftProjectGenerator::default_generator();
842        let a = vec![vec![1.0, 1.0, 0.5]]; // 3 columns
843        let b = vec![1.0];
844        let x_bar = vec![0.5, 0.5]; // only 2 variables
845        let result = gen.generate_cuts(&a, &b, &x_bar, &[0, 1]);
846        assert!(result.is_err());
847    }
848
849    // ── DeepestCut strategy ───────────────────────────────────────────────
850
851    #[test]
852    fn test_deepest_cut_strategy_returns_most_violated() {
853        let config = LiftProjectConfig {
854            variable_selection: VariableSelectionStrategy::DeepestCut,
855            max_cuts: 10,
856            ..Default::default()
857        };
858        let gen = LiftProjectGenerator::new(config);
859        let a = vec![vec![1.0, 1.0], vec![2.0, 1.0], vec![1.0, 2.0]];
860        let b = vec![1.5, 2.0, 2.0];
861        let x_bar = vec![0.4, 0.6];
862        let cuts = gen.generate_cuts(&a, &b, &x_bar, &[0, 1]).unwrap();
863        // Verify cuts are sorted by violation descending
864        for w in cuts.windows(2) {
865            assert!(
866                w[0].violation >= w[1].violation - 1e-12,
867                "Cuts should be sorted by decreasing violation"
868            );
869        }
870    }
871
872    // ── generate_cut_for_var internals ────────────────────────────────────
873
874    #[test]
875    fn test_generate_cut_for_var_negative_coefficient() {
876        let gen = LiftProjectGenerator::default_generator();
877        // Constraint: -x1 + x2 <= 0.5, x̄ = (0.3, 0.7)
878        // a[0][0] = -1.0, so negative coefficient path is exercised
879        let a = vec![vec![-1.0, 1.0]];
880        let b = vec![0.5];
881        let x_bar = vec![0.3, 0.7];
882        let cut = gen.generate_cut_for_var(&a, &b, &x_bar, 0);
883        // May or may not produce a cut (depends on violation), just ensure no panic
884        if let Some(c) = cut {
885            assert_eq!(c.pi.len(), 2);
886            assert!(c.violation >= 0.0);
887        }
888    }
889
890    #[test]
891    fn test_generate_cut_for_var_no_cut_when_no_structural_row_has_coeff_but_bound_exists() {
892        let gen = LiftProjectGenerator::default_generator();
893        // No structural constraint involves x0 (only x1)
894        let a = vec![vec![0.0, 1.0]];
895        let b = vec![0.8];
896        let x_bar = vec![0.4, 0.6];
897        // The bound row x0 ≤ 1 has a_ij = 1 > 0 for j=0, so a cut CAN be generated
898        // from the augmented system.
899        let cut = gen.generate_cut_for_var(&a, &b, &x_bar, 0);
900        // Bound row x0 <= 1: r_i = 1 - 0.4 = 0.6, violation = 0.6*0.4/0.6 = 0.4 > tol
901        assert!(
902            cut.is_some(),
903            "Should get a cut from the bound row for variable 0"
904        );
905    }
906
907    // ── LS strengthening ─────────────────────────────────────────────────
908
909    #[test]
910    fn test_ls_strengthen_does_not_decrease_coefficients_to_negative() {
911        let gen = LiftProjectGenerator::default_generator();
912        let (a, b) = simple_constraints();
913        let x_bar = simple_x_bar();
914        let cut = gen
915            .generate_cut_for_var(&a, &b, &x_bar, 0)
916            .expect("Should generate a cut for variable 0");
917        let strengthened = ls_strengthen(&cut, &x_bar, &[0, 1], 0);
918        // All coefficients should remain non-negative (since original are non-negative)
919        for &pi_k in &strengthened.pi {
920            assert!(
921                pi_k >= 0.0 - 1e-12,
922                "Coefficient should not become negative: {}",
923                pi_k
924            );
925        }
926    }
927
928    // ── cut_satisfied_at_integer utility ─────────────────────────────────
929
930    #[test]
931    fn test_cut_satisfied_at_integer_utility() {
932        let cut = LiftProjectCut {
933            pi: vec![1.0, 1.0],
934            pi0: 0.0,
935            source_var: 0,
936            source_row: 0,
937            violation: 0.5,
938        };
939        assert!(cut_satisfied_at_integer(&cut, &[0.0, 0.0]));
940        assert!(cut_satisfied_at_integer(&cut, &[1.0, 0.0]));
941        assert!(cut_satisfied_at_integer(&cut, &[0.0, 1.0]));
942        assert!(cut_satisfied_at_integer(&cut, &[1.0, 1.0]));
943    }
944
945    // ── BCC formula correctness check ─────────────────────────────────────
946
947    #[test]
948    fn test_bcc_violation_equals_stored_violation() {
949        let gen = LiftProjectGenerator::default_generator();
950        let a = vec![vec![2.0, 1.0], vec![1.0, 2.0]];
951        let b = vec![3.0, 3.0];
952        let x_bar = vec![0.6, 0.8];
953        let cuts = gen.generate_cuts(&a, &b, &x_bar, &[0, 1]).unwrap();
954        for cut in &cuts {
955            let recomputed = gen.cut_violation(cut, &x_bar);
956            assert!(
957                (cut.violation - recomputed).abs() < 1e-12,
958                "Stored violation {} != recomputed {}",
959                cut.violation,
960                recomputed
961            );
962        }
963    }
964
965    // ── Augmented system includes bound rows ──────────────────────────────
966
967    #[test]
968    fn test_augmented_system_size() {
969        let a = vec![vec![1.0, 1.0]];
970        let b = vec![1.5];
971        let x_bar = vec![0.5, 0.5];
972        let integer_vars = vec![0, 1];
973        let (a_aug, b_aug) = build_augmented_constraints(&a, &b, &x_bar, &integer_vars);
974        // 1 structural + 2 vars * 2 bound rows = 5 total
975        assert_eq!(a_aug.len(), 5);
976        assert_eq!(b_aug.len(), 5);
977    }
978}