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}