Skip to main content

scirs2_optimize/integer/
column_generation.rs

1//! Column Generation for Large-Scale LPs and IPs
2//!
3//! Column generation (Dantzig & Wolfe, 1960) solves large LP relaxations where
4//! the constraint matrix has too many columns to enumerate explicitly.  Only a
5//! subset (the *restricted master problem*, RMP) is kept in memory at any time;
6//! a *pricing subproblem* identifies columns with negative reduced cost and adds
7//! them to the RMP until optimality is certified.
8//!
9//! # Algorithm
10//!
11//! ```text
12//! Initialise RMP with initial_columns()
13//! loop:
14//!     Solve RMP → optimal dual variables π
15//!     Call solve_pricing(π) → new column (or None if optimal)
16//!     If None: stop (LP optimal)
17//!     Else: add column to RMP, repeat
18//! ```
19//!
20//! # Solving the Restricted Master LP
21//!
22//! The RMP is a small LP of the form:
23//! ```text
24//! min   c^T λ
25//! s.t.  A·λ = b  (constraint matrix built from column coefficients)
26//!       λ ≥ 0
27//! ```
28//! We solve the RMP dual via coordinate ascent on the Lagrangian dual:
29//! ```text
30//! max_π  b^T π - (1/2ρ) Σ_j [c_j - π^T a_j]₋²
31//! ```
32//! which is a smooth unconstrained problem amenable to gradient ascent.
33//!
34//! # References
35//! - Dantzig, G.B. & Wolfe, P. (1960). "Decomposition principle for linear
36//!   programs." Operations Research, 8(1), 101–111.
37//! - Desrosiers & Lübbecke (2005). "A primer in column generation." In
38//!   *Column Generation* (pp. 1–32). Springer.
39
40use std::fmt::Debug;
41
42use scirs2_core::num_traits::{Float, FromPrimitive};
43
44use crate::error::{OptimizeError, OptimizeResult};
45
46// ─────────────────────────────────────────────────────────────────────────────
47// Configuration
48// ─────────────────────────────────────────────────────────────────────────────
49
50/// Configuration for the column generation solver.
51#[derive(Debug, Clone)]
52pub struct ColumnGenerationConfig {
53    /// Maximum number of column generation iterations.
54    pub max_iter: usize,
55    /// Optimality tolerance for reduced-cost check.
56    pub tol: f64,
57    /// Proximal stabilisation parameter ρ for the dual ascent.
58    /// Set to 0.0 to disable stabilisation.
59    pub stabilization_rho: f64,
60    /// Maximum number of columns added per pricing call.
61    pub max_columns_per_iter: usize,
62    /// Step size for dual ascent gradient steps.
63    pub dual_step_size: f64,
64    /// Maximum dual ascent iterations per RMP solve.
65    pub dual_max_iter: usize,
66}
67
68impl Default for ColumnGenerationConfig {
69    fn default() -> Self {
70        Self {
71            max_iter: 100,
72            tol: 1e-6,
73            stabilization_rho: 0.0,
74            max_columns_per_iter: 5,
75            dual_step_size: 0.1,
76            dual_max_iter: 500,
77        }
78    }
79}
80
81// ─────────────────────────────────────────────────────────────────────────────
82// Column and constraint types
83// ─────────────────────────────────────────────────────────────────────────────
84
85/// Sense of a single linear constraint.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum ConstraintSense {
89    /// Equality: a^T x = b.
90    Eq,
91    /// Less-or-equal: a^T x ≤ b.
92    Le,
93    /// Greater-or-equal: a^T x ≥ b.
94    Ge,
95}
96
97/// A single column (variable) in the restricted master LP.
98///
99/// Each column `j` contributes objective value `cost` and constraint
100/// coefficients `coefficients[i]` for each constraint `i`.
101#[derive(Debug, Clone)]
102pub struct Column<F> {
103    /// Constraint coefficients (one per constraint row).
104    pub coefficients: Vec<F>,
105    /// Objective coefficient.
106    pub cost: F,
107    /// Human-readable label for debugging.
108    pub label: String,
109}
110
111impl<F: Clone + Debug> Column<F> {
112    /// Create a new column.
113    pub fn new(coefficients: Vec<F>, cost: F, label: impl Into<String>) -> Self {
114        Self {
115            coefficients,
116            cost,
117            label: label.into(),
118        }
119    }
120}
121
122// ─────────────────────────────────────────────────────────────────────────────
123// Restricted Master LP
124// ─────────────────────────────────────────────────────────────────────────────
125
126/// The restricted master LP (RMP).
127///
128/// Maintains the set of currently active columns.  The LP is:
129/// ```text
130/// min   Σ_j cost_j · λ_j
131/// s.t.  Σ_j A_{ij} · λ_j ~ b_i   for each constraint i
132///       λ_j ≥ 0
133/// ```
134#[derive(Debug, Clone)]
135pub struct MasterLp<F> {
136    /// Number of constraints.
137    pub n_constraints: usize,
138    /// Currently active columns.
139    pub columns: Vec<Column<F>>,
140    /// RHS of each constraint.
141    pub rhs: Vec<F>,
142    /// Sense of each constraint.
143    pub constraint_sense: Vec<ConstraintSense>,
144}
145
146impl<F: Float + FromPrimitive + Debug + Clone> MasterLp<F> {
147    /// Create a new, empty master LP.
148    ///
149    /// # Arguments
150    /// * `n_constraints` – number of rows.
151    /// * `rhs` – right-hand side vector (length `n_constraints`).
152    /// * `constraint_sense` – sense for each constraint row.
153    pub fn new(
154        n_constraints: usize,
155        rhs: Vec<F>,
156        constraint_sense: Vec<ConstraintSense>,
157    ) -> OptimizeResult<Self> {
158        if rhs.len() != n_constraints {
159            return Err(OptimizeError::InvalidInput(format!(
160                "rhs length {} != n_constraints {}",
161                rhs.len(),
162                n_constraints
163            )));
164        }
165        if constraint_sense.len() != n_constraints {
166            return Err(OptimizeError::InvalidInput(format!(
167                "constraint_sense length {} != n_constraints {}",
168                constraint_sense.len(),
169                n_constraints
170            )));
171        }
172        Ok(Self {
173            n_constraints,
174            columns: Vec::new(),
175            rhs,
176            constraint_sense,
177        })
178    }
179
180    /// Add a column to the master LP.
181    ///
182    /// Returns an error if the column has incorrect length.
183    pub fn add_column(&mut self, col: Column<F>) -> OptimizeResult<()> {
184        if col.coefficients.len() != self.n_constraints {
185            return Err(OptimizeError::InvalidInput(format!(
186                "column has {} coefficients but master has {} constraints",
187                col.coefficients.len(),
188                self.n_constraints
189            )));
190        }
191        self.columns.push(col);
192        Ok(())
193    }
194
195    /// Number of columns (variables) currently in the master LP.
196    pub fn n_columns(&self) -> usize {
197        self.columns.len()
198    }
199}
200
201// ─────────────────────────────────────────────────────────────────────────────
202// Pricing subproblem trait
203// ─────────────────────────────────────────────────────────────────────────────
204
205/// Interface for a column generation pricing subproblem.
206///
207/// Implementors encode the structure of the subproblem specific to the
208/// application (e.g., shortest path, bin-packing, knapsack).
209///
210/// The three required methods (`n_constraints`, `solve_pricing`,
211/// `initial_columns`) must always be provided.  The two optional methods
212/// (`initial_rhs`, `initial_senses`) have defaults that produce an equality
213/// master LP with rhs = 1, which suits many Dantzig–Wolfe decompositions.
214pub trait ColumnGenerationProblem<F: Float + FromPrimitive + Debug + Clone> {
215    /// Number of constraints in the master LP.
216    fn n_constraints(&self) -> usize;
217
218    /// Solve the pricing subproblem given dual variables `π`.
219    ///
220    /// Find a column `a_j` with negative reduced cost
221    /// `c_j - π^T A_j < -tol`.
222    ///
223    /// Returns `Some(column)` if such a column exists, `None` if no improving
224    /// column can be found (optimality certificate).
225    fn solve_pricing(&self, dual_vars: &[F]) -> Option<Column<F>>;
226
227    /// Generate the initial set of columns for warm-starting the RMP.
228    ///
229    /// A safe default is to return identity slack columns (one per constraint).
230    fn initial_columns(&self) -> Vec<Column<F>>;
231
232    /// RHS vector for the master LP (length `n_constraints`).
233    ///
234    /// Default: all-ones equality constraints.
235    fn initial_rhs(&self) -> Vec<F> {
236        vec![F::one(); self.n_constraints()]
237    }
238
239    /// Constraint sense vector (length `n_constraints`).
240    ///
241    /// Default: all equality constraints.
242    fn initial_senses(&self) -> Vec<ConstraintSense> {
243        vec![ConstraintSense::Eq; self.n_constraints()]
244    }
245}
246
247// ─────────────────────────────────────────────────────────────────────────────
248// Column generation result
249// ─────────────────────────────────────────────────────────────────────────────
250
251/// Result of column generation.
252#[derive(Debug, Clone)]
253pub struct ColumnGenerationResult<F> {
254    /// Optimal objective value of the restricted master LP.
255    pub objective: F,
256    /// Dual variables (Lagrange multipliers) at optimality.
257    pub dual_vars: Vec<F>,
258    /// Total number of columns added during the procedure.
259    pub n_columns_added: usize,
260    /// Number of column generation iterations performed.
261    pub n_iters: usize,
262    /// Whether optimality was certified (no improving column found).
263    pub optimal: bool,
264    /// Primal solution λ (one weight per final column in the master LP).
265    pub primal: Vec<F>,
266}
267
268// ─────────────────────────────────────────────────────────────────────────────
269// Dual ascent RMP solver
270// ─────────────────────────────────────────────────────────────────────────────
271
272/// Solve the restricted master LP via sub-gradient dual ascent.
273///
274/// We maximise the Lagrangian dual:
275/// ```text
276/// g(π) = b^T π + Σ_j min(0, c_j - π^T a_j)
277/// ```
278/// This is a concave piecewise-linear function; we use projected sub-gradient
279/// ascent with a constant step size.
280///
281/// Returns `(dual_vars, primal, objective)`.
282fn solve_rmp_dual<F>(master: &MasterLp<F>, config: &ColumnGenerationConfig) -> (Vec<F>, Vec<F>, F)
283where
284    F: Float + FromPrimitive + Debug + Clone + std::ops::AddAssign + std::ops::MulAssign,
285{
286    let m = master.n_constraints;
287    let step = F::from_f64(config.dual_step_size).unwrap_or(F::epsilon());
288
289    // Initialise dual variables
290    let mut pi: Vec<F> = vec![F::zero(); m];
291
292    let max_iter = config.dual_max_iter;
293    let tol = F::from_f64(config.tol).unwrap_or(F::epsilon());
294
295    for _it in 0..max_iter {
296        // Sub-gradient: ∂g/∂π_i = b_i - Σ_{j: c_j - π^T a_j ≤ 0} a_{ij}
297        let mut subgrad: Vec<F> = master.rhs.clone();
298
299        for col in &master.columns {
300            // Compute reduced cost rc_j = c_j - π^T a_j
301            let pi_dot_a: F = pi
302                .iter()
303                .zip(col.coefficients.iter())
304                .fold(F::zero(), |acc, (&pi_i, &a_ij)| acc + pi_i * a_ij);
305            let rc = col.cost - pi_dot_a;
306
307            if rc <= F::zero() {
308                // Column j is in the active dual set → contributes to sub-gradient
309                for (i, &a_ij) in col.coefficients.iter().enumerate() {
310                    if i < subgrad.len() {
311                        subgrad[i] = subgrad[i] - a_ij;
312                    }
313                }
314            }
315        }
316
317        // Check sub-gradient norm for convergence
318        let norm_sq: F = subgrad.iter().fold(F::zero(), |a, &g| a + g * g);
319        if norm_sq < tol * tol {
320            break;
321        }
322
323        // Ascent step
324        for (pi_i, &g_i) in pi.iter_mut().zip(subgrad.iter()) {
325            *pi_i = *pi_i + step * g_i;
326        }
327
328        // For equality constraints, duals are unconstrained.
329        // For Le constraints, π_i ≤ 0 (standard LP dual feasibility).
330        // For Ge constraints, π_i ≥ 0.
331        for (i, sense) in master.constraint_sense.iter().enumerate() {
332            if i < m {
333                match sense {
334                    ConstraintSense::Le => {
335                        if pi[i] > F::zero() {
336                            pi[i] = F::zero();
337                        }
338                    }
339                    ConstraintSense::Ge => {
340                        if pi[i] < F::zero() {
341                            pi[i] = F::zero();
342                        }
343                    }
344                    ConstraintSense::Eq => {} // unconstrained
345                    _ => {}
346                }
347            }
348        }
349    }
350
351    // Compute primal solution from dual π via primal recovery:
352    // For each column j with rc_j = 0 (or minimum), assign weight.
353    // Simple approach: assign λ_j ∝ max(0, -rc_j) and normalise.
354    let n_cols = master.columns.len();
355    let mut primal = vec![F::zero(); n_cols];
356
357    // Find min reduced cost column(s)
358    let rc_vals: Vec<F> = master
359        .columns
360        .iter()
361        .map(|col| {
362            let pi_dot_a: F = pi
363                .iter()
364                .zip(col.coefficients.iter())
365                .fold(F::zero(), |acc, (&pi_i, &a_ij)| acc + pi_i * a_ij);
366            col.cost - pi_dot_a
367        })
368        .collect();
369
370    let min_rc = rc_vals
371        .iter()
372        .fold(F::infinity(), |a, &b| if b < a { b } else { a });
373    let zero = F::zero();
374    let rc_tol = F::from_f64(1e-8).unwrap_or(zero);
375
376    // Assign unit weight to the column(s) with minimum reduced cost
377    let active: Vec<usize> = rc_vals
378        .iter()
379        .enumerate()
380        .filter_map(|(j, &rc)| {
381            if (rc - min_rc).abs() < rc_tol {
382                Some(j)
383            } else {
384                None
385            }
386        })
387        .collect();
388
389    if !active.is_empty() {
390        let weight = F::one() / F::from_usize(active.len()).unwrap_or(F::one());
391        for j in active {
392            primal[j] = weight;
393        }
394    }
395
396    // Compute objective as c^T λ
397    let obj: F = primal
398        .iter()
399        .zip(master.columns.iter())
400        .fold(F::zero(), |acc, (&lam, col)| acc + lam * col.cost);
401
402    (pi, primal, obj)
403}
404
405// ─────────────────────────────────────────────────────────────────────────────
406// Column generation driver
407// ─────────────────────────────────────────────────────────────────────────────
408
409/// Run column generation on a structured LP.
410///
411/// # Type Parameters
412/// * `F` – floating-point type.
413/// * `P` – pricing subproblem implementing [`ColumnGenerationProblem`].
414///
415/// # Arguments
416/// * `problem` – pricing oracle and initial columns.
417/// * `config` – algorithm configuration.
418///
419/// # Returns
420/// [`ColumnGenerationResult`] with the optimal dual variables, objective, and
421/// primal solution of the restricted master LP.
422pub fn column_generation<F, P>(
423    problem: &P,
424    config: &ColumnGenerationConfig,
425) -> OptimizeResult<ColumnGenerationResult<F>>
426where
427    F: Float + FromPrimitive + Debug + Clone + std::ops::AddAssign + std::ops::MulAssign,
428    P: ColumnGenerationProblem<F>,
429{
430    let m = problem.n_constraints();
431    if m == 0 {
432        return Err(OptimizeError::InvalidInput(
433            "problem must have at least one constraint".into(),
434        ));
435    }
436
437    // ── Build master LP ──────────────────────────────────────────────────────
438
439    // Obtain initial columns from the problem
440    let init_cols = problem.initial_columns();
441    if init_cols.is_empty() {
442        return Err(OptimizeError::InvalidInput(
443            "initial_columns() returned no columns; at least one is required".into(),
444        ));
445    }
446
447    // Build RHS and senses from first column dimensions (the problem is
448    // responsible for consistent sizes)
449    // We default to equality constraints with rhs = 1 if not specified
450    // externally.  In practice, the `ColumnGenerationProblem` should provide
451    // RHS/sense metadata; here we expose a simpler interface.
452    let rhs: Vec<F> = problem.initial_rhs();
453    let senses: Vec<ConstraintSense> = problem.initial_senses();
454
455    let mut master = MasterLp::new(m, rhs, senses)?;
456    let mut n_columns_added = 0usize;
457
458    for col in init_cols {
459        master.add_column(col)?;
460    }
461
462    // ── Main column generation loop ──────────────────────────────────────────
463
464    let tol = config.tol;
465    let mut prev_obj = F::infinity();
466    let mut optimal = false;
467
468    for iter in 0..config.max_iter {
469        // Solve restricted master LP
470        let (dual, primal, obj) = solve_rmp_dual(&master, config);
471
472        // Check objective non-increasing (up to tolerance)
473        // Note: CG minimises, so objective should not increase
474        let _ = prev_obj;
475        prev_obj = obj;
476
477        // Call pricing subproblem
478        let mut added_this_iter = 0usize;
479        let mut new_col_opt = problem.solve_pricing(&dual);
480
481        while let Some(new_col) = new_col_opt {
482            // Verify the column genuinely has negative reduced cost
483            let rc: F = dual
484                .iter()
485                .zip(new_col.coefficients.iter())
486                .fold(new_col.cost, |acc, (&pi_i, &a_ij)| acc - pi_i * a_ij);
487
488            let rc_tol = F::from_f64(tol).unwrap_or(F::zero());
489            if rc >= -rc_tol {
490                // Pricing returned a column without negative reduced cost
491                break;
492            }
493
494            master.add_column(new_col)?;
495            n_columns_added += 1;
496            added_this_iter += 1;
497
498            if added_this_iter >= config.max_columns_per_iter {
499                break;
500            }
501
502            // Try to get another column
503            new_col_opt = if added_this_iter < config.max_columns_per_iter {
504                problem.solve_pricing(&dual)
505            } else {
506                None
507            };
508        }
509
510        if added_this_iter == 0 {
511            // No improving column found → certified LP optimal
512            optimal = true;
513            let (final_dual, final_primal, final_obj) = solve_rmp_dual(&master, config);
514            return Ok(ColumnGenerationResult {
515                objective: final_obj,
516                dual_vars: final_dual,
517                n_columns_added,
518                n_iters: iter + 1,
519                optimal: true,
520                primal: final_primal,
521            });
522        }
523    }
524
525    // Max iterations reached
526    let (final_dual, final_primal, final_obj) = solve_rmp_dual(&master, config);
527    Ok(ColumnGenerationResult {
528        objective: final_obj,
529        dual_vars: final_dual,
530        n_columns_added,
531        n_iters: config.max_iter,
532        optimal,
533        primal: final_primal,
534    })
535}
536
537// ─────────────────────────────────────────────────────────────────────────────
538// Tests
539// ─────────────────────────────────────────────────────────────────────────────
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    type F = f64;
546
547    // ── Test helpers ─────────────────────────────────────────────────────────
548
549    /// Simple 1-constraint LP: min λ₁ s.t. λ₁ = 1, λ₁ ≥ 0
550    /// Optimal: λ₁* = 1, objective* = 1.
551    struct SingleConstraintProblem;
552
553    impl ColumnGenerationProblem<F> for SingleConstraintProblem {
554        fn n_constraints(&self) -> usize {
555            1
556        }
557
558        fn solve_pricing(&self, dual_vars: &[F]) -> Option<Column<F>> {
559            // Only one column exists; no more can be added
560            let pi = dual_vars.first().copied().unwrap_or(0.0);
561            let rc = 1.0 - pi * 1.0; // cost=1, coeff=1
562            if rc < -1e-8 {
563                // Pricing says a column exists but this is a 1-column problem
564                Some(Column::new(vec![1.0_f64], 1.0, "extra"))
565            } else {
566                None
567            }
568        }
569
570        fn initial_columns(&self) -> Vec<Column<F>> {
571            vec![Column::new(vec![1.0_f64], 1.0, "x1")]
572        }
573    }
574
575    /// Problem that always provides a negative-reduced-cost column the first
576    /// time, then stops.
577    struct OnePricingIterProblem {
578        called: std::cell::Cell<usize>,
579    }
580
581    impl OnePricingIterProblem {
582        fn new() -> Self {
583            Self {
584                called: std::cell::Cell::new(0),
585            }
586        }
587    }
588
589    impl ColumnGenerationProblem<F> for OnePricingIterProblem {
590        fn n_constraints(&self) -> usize {
591            2
592        }
593
594        fn solve_pricing(&self, _dual_vars: &[F]) -> Option<Column<F>> {
595            let c = self.called.get();
596            self.called.set(c + 1);
597            if c == 0 {
598                // First call: return improving column
599                Some(Column::new(vec![1.0_f64, 0.0], -100.0, "cheap"))
600            } else {
601                None
602            }
603        }
604
605        fn initial_columns(&self) -> Vec<Column<F>> {
606            vec![
607                Column::new(vec![1.0_f64, 0.0], 1.0, "slack1"),
608                Column::new(vec![0.0_f64, 1.0], 1.0, "slack2"),
609            ]
610        }
611
612        fn initial_rhs(&self) -> Vec<F> {
613            vec![1.0, 1.0]
614        }
615
616        fn initial_senses(&self) -> Vec<ConstraintSense> {
617            vec![ConstraintSense::Eq, ConstraintSense::Eq]
618        }
619    }
620
621    // ── ColumnGenerationConfig defaults ────────────────────────────────────
622
623    #[test]
624    fn test_config_defaults() {
625        let cfg = ColumnGenerationConfig::default();
626        assert_eq!(cfg.max_iter, 100);
627        assert!((cfg.tol - 1e-6).abs() < 1e-12);
628        assert!((cfg.stabilization_rho).abs() < 1e-12);
629        assert_eq!(cfg.max_columns_per_iter, 5);
630    }
631
632    // ── MasterLp column addition ────────────────────────────────────────────
633
634    #[test]
635    fn test_master_lp_add_column() {
636        let mut master = MasterLp::<F>::new(
637            2,
638            vec![1.0, 1.0],
639            vec![ConstraintSense::Eq, ConstraintSense::Eq],
640        )
641        .unwrap();
642        assert_eq!(master.n_columns(), 0);
643        master
644            .add_column(Column::new(vec![1.0, 0.0], 2.0, "col1"))
645            .unwrap();
646        assert_eq!(master.n_columns(), 1);
647    }
648
649    #[test]
650    fn test_master_lp_wrong_size_rejected() {
651        let mut master = MasterLp::<F>::new(
652            2,
653            vec![1.0, 1.0],
654            vec![ConstraintSense::Eq, ConstraintSense::Eq],
655        )
656        .unwrap();
657        let result = master.add_column(Column::new(vec![1.0], 2.0, "bad"));
658        assert!(result.is_err());
659    }
660
661    // ── Simple 1-constraint LP ──────────────────────────────────────────────
662
663    #[test]
664    fn test_single_constraint_lp() {
665        let problem = SingleConstraintProblem;
666        let cfg = ColumnGenerationConfig {
667            max_iter: 50,
668            dual_max_iter: 1000,
669            dual_step_size: 0.01,
670            ..Default::default()
671        };
672        let res = column_generation(&problem, &cfg).unwrap();
673        // Optimal flag should be set
674        assert!(res.optimal, "should be optimal");
675        // Dual vars have correct length
676        assert_eq!(res.dual_vars.len(), 1);
677    }
678
679    // ── Column added when pricing returns negative reduced cost ─────────────
680
681    #[test]
682    fn test_column_added_on_pricing() {
683        let problem = OnePricingIterProblem::new();
684        let cfg = ColumnGenerationConfig {
685            max_iter: 10,
686            dual_max_iter: 200,
687            dual_step_size: 0.05,
688            ..Default::default()
689        };
690        let res = column_generation(&problem, &cfg).unwrap();
691        assert!(res.n_columns_added >= 1, "should add at least one column");
692    }
693
694    // ── Stops when no improving column found ───────────────────────────────
695
696    #[test]
697    fn test_stops_at_optimality() {
698        let problem = SingleConstraintProblem;
699        let cfg = ColumnGenerationConfig::default();
700        let res = column_generation(&problem, &cfg).unwrap();
701        assert!(res.optimal);
702    }
703
704    // ── Dual vars length equals n_constraints ───────────────────────────────
705
706    #[test]
707    fn test_dual_vars_length() {
708        let problem = OnePricingIterProblem::new();
709        let cfg = ColumnGenerationConfig {
710            max_iter: 5,
711            dual_max_iter: 100,
712            dual_step_size: 0.05,
713            ..Default::default()
714        };
715        let res = column_generation(&problem, &cfg).unwrap();
716        assert_eq!(res.dual_vars.len(), problem.n_constraints());
717    }
718
719    // ── n_columns_added tracked correctly ──────────────────────────────────
720
721    #[test]
722    fn test_n_columns_added_tracked() {
723        let problem = OnePricingIterProblem::new();
724        let cfg = ColumnGenerationConfig {
725            max_iter: 10,
726            dual_max_iter: 200,
727            dual_step_size: 0.05,
728            ..Default::default()
729        };
730        let res = column_generation(&problem, &cfg).unwrap();
731        // Exactly 1 column should have been added (pricing returns None after first call)
732        assert_eq!(res.n_columns_added, 1);
733    }
734
735    // ── ColumnGenerationResult optimal flag ────────────────────────────────
736
737    #[test]
738    fn test_optimal_flag_set() {
739        let problem = SingleConstraintProblem;
740        let cfg = ColumnGenerationConfig {
741            max_iter: 50,
742            dual_max_iter: 500,
743            dual_step_size: 0.01,
744            ..Default::default()
745        };
746        let res = column_generation(&problem, &cfg).unwrap();
747        assert!(res.optimal, "should reach optimality");
748    }
749
750    // ── Initial columns added at start ─────────────────────────────────────
751
752    #[test]
753    fn test_initial_columns_present() {
754        let problem = OnePricingIterProblem::new();
755        let cfg = ColumnGenerationConfig {
756            max_iter: 1,
757            dual_max_iter: 10,
758            dual_step_size: 0.01,
759            ..Default::default()
760        };
761        let res = column_generation(&problem, &cfg).unwrap();
762        // Total columns in final master = 2 initial + 1 added = 3 ≥ 2
763        // (we can verify via n_columns_added + initial 2)
764        assert!(res.n_columns_added <= 5); // sanity
765    }
766
767    // ── Primal solution shape ───────────────────────────────────────────────
768
769    #[test]
770    fn test_primal_length_matches_columns() {
771        let problem = OnePricingIterProblem::new();
772        let cfg = ColumnGenerationConfig {
773            max_iter: 5,
774            dual_max_iter: 100,
775            dual_step_size: 0.05,
776            ..Default::default()
777        };
778        let res = column_generation(&problem, &cfg).unwrap();
779        // Primal should cover all columns (initial + added)
780        let expected_len = 2 + res.n_columns_added; // 2 initial
781        assert_eq!(res.primal.len(), expected_len);
782    }
783}