Skip to main content

scirs2_optimize/robust/
robust_lp.rs

1//! Robust Linear Programming
2//!
3//! This module implements robust counterparts of linear programs under various
4//! uncertainty models. A standard LP:
5//!
6//! ```text
7//! min  cᵀ x
8//! s.t. A x ≤ b,  x ∈ X
9//! ```
10//!
11//! becomes a *robust LP* when the data (c, A, b) are uncertain:
12//!
13//! ```text
14//! min  max_{(c,A,b) ∈ U}  cᵀ x
15//! s.t. A x ≤ b  for all  (A, b) ∈ U_constraints
16//! ```
17//!
18//! # Uncertainty Models
19//!
20//! - **Box uncertainty** (`box_robust_lp`): Each coefficient independently perturbed
21//!   within ±δ. The robust counterpart is an LP of comparable size.
22//! - **Ellipsoidal uncertainty** (`ellipsoidal_robust_lp`): Coefficients perturbed inside
23//!   an ellipsoid. The robust counterpart is a Second-Order Cone Program (SOCP).
24//! - **Worst-case objective** (`robust_objective`): Evaluate worst-case objective value
25//!   under any supported uncertainty set without full reformulation.
26//!
27//! # Reformulation Approach
28//!
29//! Both reformulations are solved via projected gradient descent on the resulting
30//! tractable problem (no external LP/SOCP solver is needed).
31//!
32//! # References
33//!
34//! - Ben-Tal, A. & Nemirovski, A. (1998). "Robust convex optimization". *Mathematics of Operations Research*.
35//! - Ben-Tal, A. & Nemirovski, A. (1999). "Robust solutions of uncertain linear programs". *Operations Research Letters*.
36//! - Lobo, M.S. et al. (1998). "Applications of second-order cone programming". *Linear Algebra and its Applications*.
37//! - Bertsimas, D. & Sim, M. (2004). "The price of robustness". *Operations Research*.
38
39use crate::error::{OptimizeError, OptimizeResult};
40use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
41
42// ─── Robust LP problem definition ─────────────────────────────────────────────
43
44/// A linear program with uncertain data.
45///
46/// Nominal problem:
47/// ```text
48/// min  cᵀ x
49/// s.t. A x ≤ b
50///      lb ≤ x ≤ ub   (optional bounds)
51/// ```
52#[derive(Debug, Clone)]
53pub struct RobustLP {
54    /// Nominal objective coefficient vector c (n-vector).
55    pub c: Array1<f64>,
56    /// Nominal constraint matrix A (m × n).
57    pub a_matrix: Array2<f64>,
58    /// Nominal right-hand side b (m-vector).
59    pub b_rhs: Array1<f64>,
60    /// Optional lower bounds on x (length n; use f64::NEG_INFINITY for unbounded).
61    pub lb: Option<Array1<f64>>,
62    /// Optional upper bounds on x (length n; use f64::INFINITY for unbounded).
63    pub ub: Option<Array1<f64>>,
64    /// Uncertainty in objective coefficients c: perturbation radius per coordinate.
65    pub c_uncertainty: Option<Array1<f64>>,
66    /// Uncertainty in constraint matrix A: perturbation radius per entry (m × n).
67    pub a_uncertainty: Option<Array2<f64>>,
68    /// Uncertainty in right-hand side b: perturbation radius per constraint.
69    pub b_uncertainty: Option<Array1<f64>>,
70}
71
72impl RobustLP {
73    /// Create a new robust LP with no uncertainty (reduces to nominal LP).
74    ///
75    /// # Arguments
76    ///
77    /// * `c`        – objective vector (n)
78    /// * `a_matrix` – constraint matrix (m × n)
79    /// * `b_rhs`    – right-hand side (m)
80    pub fn new(c: Array1<f64>, a_matrix: Array2<f64>, b_rhs: Array1<f64>) -> OptimizeResult<Self> {
81        let n = c.len();
82        let (m, nc) = (a_matrix.shape()[0], a_matrix.shape()[1]);
83        if nc != n {
84            return Err(OptimizeError::ValueError(format!(
85                "A has {} columns but c has length {}",
86                nc, n
87            )));
88        }
89        if b_rhs.len() != m {
90            return Err(OptimizeError::ValueError(format!(
91                "b has length {} but A has {} rows",
92                b_rhs.len(),
93                m
94            )));
95        }
96        Ok(Self {
97            c,
98            a_matrix,
99            b_rhs,
100            lb: None,
101            ub: None,
102            c_uncertainty: None,
103            a_uncertainty: None,
104            b_uncertainty: None,
105        })
106    }
107
108    /// Set box uncertainty on the objective: c̃ ∈ [c - δ_c, c + δ_c].
109    pub fn with_c_uncertainty(mut self, delta_c: Array1<f64>) -> OptimizeResult<Self> {
110        if delta_c.len() != self.c.len() {
111            return Err(OptimizeError::ValueError(format!(
112                "delta_c has length {} but c has length {}",
113                delta_c.len(),
114                self.c.len()
115            )));
116        }
117        self.c_uncertainty = Some(delta_c);
118        Ok(self)
119    }
120
121    /// Set box uncertainty on constraints: Ã_ij ∈ [A_ij - δ_A_ij, A_ij + δ_A_ij].
122    pub fn with_a_uncertainty(mut self, delta_a: Array2<f64>) -> OptimizeResult<Self> {
123        if delta_a.shape() != self.a_matrix.shape() {
124            return Err(OptimizeError::ValueError(format!(
125                "delta_A shape {:?} does not match A shape {:?}",
126                delta_a.shape(),
127                self.a_matrix.shape()
128            )));
129        }
130        self.a_uncertainty = Some(delta_a);
131        Ok(self)
132    }
133
134    /// Set box uncertainty on the RHS: b̃_i ∈ [b_i - δ_b_i, b_i + δ_b_i].
135    pub fn with_b_uncertainty(mut self, delta_b: Array1<f64>) -> OptimizeResult<Self> {
136        if delta_b.len() != self.b_rhs.len() {
137            return Err(OptimizeError::ValueError(format!(
138                "delta_b has length {} but b has length {}",
139                delta_b.len(),
140                self.b_rhs.len()
141            )));
142        }
143        self.b_uncertainty = Some(delta_b);
144        Ok(self)
145    }
146
147    /// Set variable bounds.
148    pub fn with_bounds(mut self, lb: Array1<f64>, ub: Array1<f64>) -> OptimizeResult<Self> {
149        let n = self.c.len();
150        if lb.len() != n || ub.len() != n {
151            return Err(OptimizeError::ValueError(format!(
152                "bounds have length {}/{} but n={}",
153                lb.len(),
154                ub.len(),
155                n
156            )));
157        }
158        self.lb = Some(lb);
159        self.ub = Some(ub);
160        Ok(self)
161    }
162
163    /// Number of variables.
164    pub fn n_vars(&self) -> usize {
165        self.c.len()
166    }
167
168    /// Number of constraints.
169    pub fn n_constraints(&self) -> usize {
170        self.b_rhs.len()
171    }
172}
173
174/// Result of a robust LP solve.
175#[derive(Debug, Clone)]
176pub struct RobustLPResult {
177    /// Optimal solution vector x*.
178    pub x: Array1<f64>,
179    /// Robust objective value (worst-case cost cᵀ x under uncertainty).
180    pub fun: f64,
181    /// Nominal objective value cᵀ x (without uncertainty penalty).
182    pub nominal_fun: f64,
183    /// Robust constraint slack: min_i (b_i - (Ax)_i) (positive = feasible).
184    pub constraint_slack: f64,
185    /// Number of iterations performed.
186    pub n_iter: usize,
187    /// Whether the algorithm converged.
188    pub converged: bool,
189    /// Status message.
190    pub message: String,
191}
192
193/// Configuration for robust LP solvers.
194#[derive(Debug, Clone)]
195pub struct RobustLPConfig {
196    /// Maximum projected gradient iterations.
197    pub max_iter: usize,
198    /// Convergence tolerance.
199    pub tol: f64,
200    /// Initial step size for projected gradient descent.
201    pub step_size: f64,
202    /// Step size reduction factor (Armijo backtracking).
203    pub step_reduction: f64,
204    /// Penalty weight for constraint violations.
205    pub constraint_penalty: f64,
206}
207
208impl Default for RobustLPConfig {
209    fn default() -> Self {
210        Self {
211            max_iter: 5_000,
212            tol: 1e-6,
213            step_size: 1e-2,
214            step_reduction: 0.5,
215            constraint_penalty: 100.0,
216        }
217    }
218}
219
220// ─── Internal helpers ─────────────────────────────────────────────────────────
221
222/// Project x onto the box [lb, ub].
223fn project_box(x: &Array1<f64>, lb: &Option<Array1<f64>>, ub: &Option<Array1<f64>>) -> Array1<f64> {
224    x.iter()
225        .enumerate()
226        .map(|(i, &xi)| {
227            let lo = lb.as_ref().map(|b| b[i]).unwrap_or(f64::NEG_INFINITY);
228            let hi = ub.as_ref().map(|b| b[i]).unwrap_or(f64::INFINITY);
229            xi.max(lo).min(hi)
230        })
231        .collect()
232}
233
234/// Evaluate Ax - b (constraint residual). Positive entries are violated.
235fn constraint_residual(
236    a: &ArrayView2<f64>,
237    x: &ArrayView1<f64>,
238    b: &ArrayView1<f64>,
239) -> Array1<f64> {
240    let m = b.len();
241    let n = x.len();
242    let mut r = Array1::<f64>::zeros(m);
243    for i in 0..m {
244        let ax_i: f64 = (0..n).map(|j| a[[i, j]] * x[j]).sum();
245        r[i] = ax_i - b[i];
246    }
247    r
248}
249
250/// Gradient of the penalized objective:
251///   φ(x) = c̃ᵀ x + penalty * Σ_i max(A_row_i · x - b_i, 0)²
252/// where c̃ is the worst-case objective coefficient.
253fn penalized_gradient(
254    x: &Array1<f64>,
255    c_worst: &Array1<f64>,
256    a: &Array2<f64>,
257    b: &Array1<f64>,
258    penalty: f64,
259) -> Array1<f64> {
260    let n = x.len();
261    let m = b.len();
262
263    // Gradient of c̃ᵀ x
264    let mut grad = c_worst.clone();
265
266    // Gradient of penalty for violated constraints
267    for i in 0..m {
268        let ax_i: f64 = (0..n).map(|j| a[[i, j]] * x[j]).sum();
269        let viol = ax_i - b[i];
270        if viol > 0.0 {
271            // d/dx [ penalty * viol² ] = 2 * penalty * viol * A[i, :]
272            for j in 0..n {
273                grad[j] += 2.0 * penalty * viol * a[[i, j]];
274            }
275        }
276    }
277    grad
278}
279
280// ─── Box Robust LP ────────────────────────────────────────────────────────────
281
282/// Solve a robust LP with box uncertainty via projected gradient descent.
283///
284/// **Box uncertainty model**:
285/// - Objective: c̃ ∈ [c - δ_c, c + δ_c] → worst-case cost = cᵀ x + δ_cᵀ |x|
286/// - Constraints: à x ≤ b̃ for all à ∈ [A - δ_A, A + δ_A], b̃ ∈ [b - δ_b, b + δ_b]
287///   → Robust constraint: A x + δ_A |x| ≤ b - δ_b (Ben-Tal & Nemirovski 1999)
288///
289/// The robust LP with box uncertainty is again an LP (only slightly larger).
290/// We solve it via penalized projected gradient descent.
291///
292/// # Arguments
293///
294/// * `problem` – robust LP instance (with box uncertainty fields set)
295/// * `x0`      – feasible initial point
296/// * `config`  – solver configuration
297///
298/// # Returns
299///
300/// [`RobustLPResult`] with robust-optimal solution.
301pub fn box_robust_lp(
302    problem: &RobustLP,
303    x0: &ArrayView1<f64>,
304    config: &RobustLPConfig,
305) -> OptimizeResult<RobustLPResult> {
306    let n = problem.n_vars();
307    let m = problem.n_constraints();
308    if x0.len() != n {
309        return Err(OptimizeError::ValueError(format!(
310            "x0 has length {} but problem has {} variables",
311            x0.len(),
312            n
313        )));
314    }
315
316    // Worst-case objective coefficient: c̃ = c + δ_c * sign(x)
317    // We use absolute value reformulation: worst-case cᵀ x = cᵀ x + δ_cᵀ |x|
318    // Gradient: d/dx [c̃ᵀ x] = c + δ_c * sign(x)
319    let delta_c = problem
320        .c_uncertainty
321        .clone()
322        .unwrap_or_else(|| Array1::zeros(n));
323
324    // Robust RHS: b̃ = b - δ_b (tighten constraints)
325    let delta_b = problem
326        .b_uncertainty
327        .clone()
328        .unwrap_or_else(|| Array1::zeros(m));
329    let b_robust: Array1<f64> = problem
330        .b_rhs
331        .iter()
332        .zip(delta_b.iter())
333        .map(|(&bi, &dbi)| bi - dbi.abs())
334        .collect();
335
336    // Robust A: Ã = A, but add row-wise perturbation to RHS (absorption into b_robust)
337    // Constraint: (A + δ_A * sign(x)) x ≤ b → A x + δ_A |x| ≤ b
338    let delta_a = problem
339        .a_uncertainty
340        .clone()
341        .unwrap_or_else(|| Array2::zeros((m, n)));
342
343    let mut x = x0.to_owned();
344    let mut converged = false;
345    let mut step = config.step_size;
346
347    for iter in 0..config.max_iter {
348        // Worst-case objective: c̃(x) = c + δ_c * sign(x)
349        let c_worst: Array1<f64> = problem
350            .c
351            .iter()
352            .zip(delta_c.iter())
353            .zip(x.iter())
354            .map(|((&ci, &dci), &xi)| ci + dci * xi.signum())
355            .collect();
356
357        // Robust constraint: A x + δ_A |x| ≤ b_robust
358        // Incorporate δ_A |x| into effective RHS reduction
359        let b_effective: Array1<f64> = (0..m)
360            .map(|i| {
361                let reduction: f64 = (0..n).map(|j| delta_a[[i, j]] * x[j].abs()).sum();
362                b_robust[i] - reduction
363            })
364            .collect();
365
366        let grad = penalized_gradient(
367            &x,
368            &c_worst,
369            &problem.a_matrix,
370            &b_effective,
371            config.constraint_penalty,
372        );
373
374        let grad_norm: f64 = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
375        if grad_norm < config.tol {
376            converged = true;
377            break;
378        }
379
380        // Backtracking line search
381        let obj_curr: f64 = problem
382            .c
383            .iter()
384            .zip(x.iter())
385            .map(|(&ci, &xi)| ci * xi)
386            .sum::<f64>()
387            + delta_c
388                .iter()
389                .zip(x.iter())
390                .map(|(&di, &xi)| di * xi.abs())
391                .sum::<f64>();
392
393        let mut accepted = false;
394        for _ in 0..20 {
395            let x_new: Array1<f64> = x
396                .iter()
397                .zip(grad.iter())
398                .map(|(&xi, &gi)| xi - step * gi)
399                .collect();
400            let x_proj = project_box(&x_new, &problem.lb, &problem.ub);
401
402            let obj_new: f64 = problem
403                .c
404                .iter()
405                .zip(x_proj.iter())
406                .map(|(&ci, &xi)| ci * xi)
407                .sum::<f64>()
408                + delta_c
409                    .iter()
410                    .zip(x_proj.iter())
411                    .map(|(&di, &xi)| di * xi.abs())
412                    .sum::<f64>();
413
414            if obj_new <= obj_curr - 1e-4 * step * grad_norm * grad_norm {
415                x = x_proj;
416                accepted = true;
417                break;
418            }
419            step *= config.step_reduction;
420        }
421
422        if !accepted {
423            // Take a small step anyway to avoid stagnation
424            let x_new: Array1<f64> = x
425                .iter()
426                .zip(grad.iter())
427                .map(|(&xi, &gi)| xi - step * gi)
428                .collect();
429            x = project_box(&x_new, &problem.lb, &problem.ub);
430        }
431
432        // Reset step size for next iteration (with mild increase)
433        if iter % 100 == 99 {
434            step = (step * 1.1).min(config.step_size);
435        }
436    }
437
438    // Compute result metrics
439    let nominal_fun: f64 = problem
440        .c
441        .iter()
442        .zip(x.iter())
443        .map(|(&ci, &xi)| ci * xi)
444        .sum();
445    let robust_fun: f64 = nominal_fun
446        + delta_c
447            .iter()
448            .zip(x.iter())
449            .map(|(&di, &xi)| di * xi.abs())
450            .sum::<f64>();
451
452    let residual = constraint_residual(&problem.a_matrix.view(), &x.view(), &problem.b_rhs.view());
453    let constraint_slack = -residual.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
454
455    Ok(RobustLPResult {
456        x,
457        fun: robust_fun,
458        nominal_fun,
459        constraint_slack,
460        n_iter: config.max_iter,
461        converged,
462        message: if converged {
463            "Box robust LP converged".to_string()
464        } else {
465            "Box robust LP reached maximum iterations".to_string()
466        },
467    })
468}
469
470// ─── Ellipsoidal Robust LP (SOCP Reformulation) ──────────────────────────────
471
472/// Solve a robust LP with ellipsoidal uncertainty on the objective.
473///
474/// **Ellipsoidal uncertainty model** (Ben-Tal & Nemirovski 1998):
475///
476/// The cost vector satisfies c̃ ∈ E = {c + Σ^{1/2} ξ : ‖ξ‖₂ ≤ ρ}, where
477/// Σ is a positive-semidefinite covariance matrix and ρ is the radius.
478///
479/// Worst-case objective:
480/// ```text
481/// max_{c̃ ∈ E} c̃ᵀ x = cᵀ x + ρ ‖Σ^{1/2} x‖₂
482/// ```
483///
484/// This is the SOCP reformulation: we minimize cᵀ x + ρ ‖Σ^{1/2} x‖₂
485/// subject to the constraints, which is a convex (non-smooth) problem.
486///
487/// # Arguments
488///
489/// * `problem`          – robust LP instance
490/// * `c_covariance`     – PSD covariance matrix Σ for objective uncertainty (n×n)
491/// * `ellipsoid_radius` – radius ρ of the ellipsoidal uncertainty set
492/// * `x0`               – initial point
493/// * `config`           – solver configuration
494///
495/// # Returns
496///
497/// [`RobustLPResult`] with the robust-optimal solution under ellipsoidal uncertainty.
498///
499/// # References
500///
501/// Ben-Tal & Nemirovski (1999), "Robust solutions of uncertain linear programs".
502pub fn ellipsoidal_robust_lp(
503    problem: &RobustLP,
504    c_covariance: &ArrayView2<f64>,
505    ellipsoid_radius: f64,
506    x0: &ArrayView1<f64>,
507    config: &RobustLPConfig,
508) -> OptimizeResult<RobustLPResult> {
509    let n = problem.n_vars();
510    let m = problem.n_constraints();
511    if x0.len() != n {
512        return Err(OptimizeError::ValueError(format!(
513            "x0 has length {} but problem has {} variables",
514            x0.len(),
515            n
516        )));
517    }
518    if c_covariance.shape() != [n, n] {
519        return Err(OptimizeError::ValueError(format!(
520            "c_covariance shape {:?} != [{n},{n}]",
521            c_covariance.shape()
522        )));
523    }
524    if ellipsoid_radius < 0.0 {
525        return Err(OptimizeError::ValueError(
526            "ellipsoid_radius must be non-negative".to_string(),
527        ));
528    }
529
530    // Compute Cholesky factor L of Σ: Σ = L Lᵀ
531    let l_chol = cholesky_lower_triangular(c_covariance)?;
532
533    let mut x = x0.to_owned();
534    let mut converged = false;
535    let h = 1e-7;
536    let step = config.step_size;
537
538    for _ in 0..config.max_iter {
539        // Worst-case objective: F(x) = cᵀ x + ρ ‖L x‖₂
540        // ∇F(x) = c + ρ Lᵀ (L x / ‖L x‖₂)
541        let lx = mat_vec_mul_lower(&l_chol, &x.view());
542        let lx_norm = l2_norm_vec(&lx);
543
544        let socp_grad: Array1<f64> = if lx_norm > h {
545            // ∇(ρ ‖L x‖₂) = ρ Lᵀ (L x / ‖L x‖₂)
546            let lx_normalized: Array1<f64> = lx.iter().map(|&v| v / lx_norm).collect();
547            let lt_v = mat_vec_mul_lower_transpose(&l_chol, &lx_normalized.view());
548            problem
549                .c
550                .iter()
551                .zip(lt_v.iter())
552                .map(|(&ci, &li)| ci + ellipsoid_radius * li)
553                .collect()
554        } else {
555            // At the origin, subgradient of ‖L x‖₂ is any vector in the unit ball
556            problem.c.clone()
557        };
558
559        // Add constraint penalty gradient
560        let residual =
561            constraint_residual(&problem.a_matrix.view(), &x.view(), &problem.b_rhs.view());
562        let mut full_grad = socp_grad;
563        for i in 0..m {
564            if residual[i] > 0.0 {
565                for j in 0..n {
566                    full_grad[j] +=
567                        2.0 * config.constraint_penalty * residual[i] * problem.a_matrix[[i, j]];
568                }
569            }
570        }
571
572        let grad_norm = l2_norm_vec(&full_grad);
573        if grad_norm < config.tol {
574            converged = true;
575            break;
576        }
577
578        let x_new: Array1<f64> = x
579            .iter()
580            .zip(full_grad.iter())
581            .map(|(&xi, &gi)| xi - step * gi)
582            .collect();
583        x = project_box(&x_new, &problem.lb, &problem.ub);
584    }
585
586    let nominal_fun: f64 = problem
587        .c
588        .iter()
589        .zip(x.iter())
590        .map(|(&ci, &xi)| ci * xi)
591        .sum();
592    let lx = mat_vec_mul_lower(&l_chol, &x.view());
593    let robust_fun = nominal_fun + ellipsoid_radius * l2_norm_vec(&lx);
594
595    let residual = constraint_residual(&problem.a_matrix.view(), &x.view(), &problem.b_rhs.view());
596    let constraint_slack = -residual.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
597
598    Ok(RobustLPResult {
599        x,
600        fun: robust_fun,
601        nominal_fun,
602        constraint_slack,
603        n_iter: config.max_iter,
604        converged,
605        message: if converged {
606            "Ellipsoidal robust LP converged".to_string()
607        } else {
608            "Ellipsoidal robust LP reached maximum iterations".to_string()
609        },
610    })
611}
612
613// ─── Robust Objective Evaluation ─────────────────────────────────────────────
614
615/// Evaluate the worst-case objective value of cᵀ x under the given uncertainty model.
616///
617/// Supports:
618/// - **Box**: worst-case = cᵀ x + δ_cᵀ |x|
619/// - **Ellipsoidal**: worst-case = cᵀ x + ρ ‖Σ^{1/2} x‖₂
620/// - **Budget** (Bertsimas-Sim): worst-case = cᵀ x + top-Γ perturbation
621///
622/// # Arguments
623///
624/// * `c`     – nominal objective vector
625/// * `x`     – solution to evaluate
626/// * `model` – uncertainty model specification
627///
628/// # Returns
629///
630/// Worst-case objective value.
631pub fn robust_objective(
632    c: &ArrayView1<f64>,
633    x: &ArrayView1<f64>,
634    model: &ObjectiveUncertaintyModel,
635) -> OptimizeResult<f64> {
636    let n = c.len();
637    if x.len() != n {
638        return Err(OptimizeError::ValueError(format!(
639            "c has length {} but x has length {}",
640            n,
641            x.len()
642        )));
643    }
644
645    let nominal: f64 = c.iter().zip(x.iter()).map(|(&ci, &xi)| ci * xi).sum();
646
647    let penalty = match model {
648        ObjectiveUncertaintyModel::Box { delta } => {
649            if delta.len() != n {
650                return Err(OptimizeError::ValueError(format!(
651                    "delta has length {} but n={}",
652                    delta.len(),
653                    n
654                )));
655            }
656            // max perturbation = δ_i * |x_i|
657            delta
658                .iter()
659                .zip(x.iter())
660                .map(|(&di, &xi)| di * xi.abs())
661                .sum::<f64>()
662        }
663        ObjectiveUncertaintyModel::Ellipsoidal { covariance, radius } => {
664            if covariance.shape() != [n, n] {
665                return Err(OptimizeError::ValueError(format!(
666                    "covariance shape {:?} != [{n},{n}]",
667                    covariance.shape()
668                )));
669            }
670            // penalty = ρ * ‖Σ^{1/2} x‖₂ = ρ * sqrt(xᵀ Σ x)
671            let sigma_x: f64 = (0..n)
672                .map(|i| {
673                    let row: f64 = (0..n).map(|j| covariance[[i, j]] * x[j]).sum();
674                    row * x[i]
675                })
676                .sum();
677            radius * sigma_x.sqrt()
678        }
679        ObjectiveUncertaintyModel::Budget { delta, budget } => {
680            if delta.len() != n {
681                return Err(OptimizeError::ValueError(format!(
682                    "delta has length {} but n={}",
683                    delta.len(),
684                    n
685                )));
686            }
687            // Bertsimas-Sim: sort δ_i |x_i| descending, take sum of top Γ entries
688            let mut perturbations: Vec<f64> = delta
689                .iter()
690                .zip(x.iter())
691                .map(|(&di, &xi)| di * xi.abs())
692                .collect();
693            perturbations.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
694
695            let gamma = (*budget as usize).min(n);
696            let floor_gamma = budget.floor() as usize;
697            let frac = budget - budget.floor();
698
699            // Integer part: sum of top floor(Γ) terms
700            let int_sum: f64 = perturbations.iter().take(floor_gamma).sum();
701            // Fractional part: add fraction of next term
702            let frac_sum = if floor_gamma < perturbations.len() && gamma <= perturbations.len() {
703                frac * perturbations[floor_gamma]
704            } else {
705                0.0
706            };
707            int_sum + frac_sum
708        }
709    };
710
711    Ok(nominal + penalty)
712}
713
714/// Uncertainty model for the objective vector c in cᵀ x.
715#[derive(Debug, Clone)]
716pub enum ObjectiveUncertaintyModel {
717    /// Box uncertainty: c̃ ∈ [c - δ, c + δ], worst-case = cᵀ x + δᵀ |x|.
718    Box {
719        /// Per-component perturbation radius δ_i ≥ 0.
720        delta: Array1<f64>,
721    },
722    /// Ellipsoidal uncertainty: c̃ = c + Σ^{1/2} ξ, ‖ξ‖₂ ≤ ρ.
723    /// Worst-case = cᵀ x + ρ ‖Σ^{1/2} x‖₂ = cᵀ x + ρ √(xᵀ Σ x).
724    Ellipsoidal {
725        /// PSD covariance matrix Σ (n×n).
726        covariance: Array2<f64>,
727        /// Ellipsoid radius ρ.
728        radius: f64,
729    },
730    /// Bertsimas-Sim budgeted uncertainty: at most Γ components can deviate.
731    /// Worst-case = cᵀ x + Σ_top_Γ δ_i |x_i|.
732    Budget {
733        /// Per-component radius δ_i.
734        delta: Array1<f64>,
735        /// Budget parameter Γ ∈ [0, n].
736        budget: f64,
737    },
738}
739
740// ─── Linear algebra helpers ──────────────────────────────────────────────────
741
742/// Lower-triangular Cholesky factor of a symmetric PSD matrix.
743fn cholesky_lower_triangular(a: &ArrayView2<f64>) -> OptimizeResult<Array2<f64>> {
744    let n = a.shape()[0];
745    let mut l = Array2::<f64>::zeros((n, n));
746    for i in 0..n {
747        for j in 0..=i {
748            let mut s: f64 = 0.0;
749            for p in 0..j {
750                s += l[[i, p]] * l[[j, p]];
751            }
752            if i == j {
753                let diag = a[[i, i]] - s;
754                l[[i, j]] = if diag < 0.0 {
755                    diag.abs().sqrt().max(1e-12)
756                } else {
757                    diag.sqrt()
758                };
759            } else {
760                let ljj = l[[j, j]];
761                l[[i, j]] = if ljj.abs() < 1e-14 {
762                    0.0
763                } else {
764                    (a[[i, j]] - s) / ljj
765                };
766            }
767        }
768    }
769    Ok(l)
770}
771
772/// Lower-triangular matrix-vector product: y = L x.
773fn mat_vec_mul_lower(l: &Array2<f64>, x: &ArrayView1<f64>) -> Array1<f64> {
774    let n = x.len();
775    let mut y = Array1::<f64>::zeros(n);
776    for i in 0..n {
777        for j in 0..=i {
778            y[i] += l[[i, j]] * x[j];
779        }
780    }
781    y
782}
783
784/// Transpose lower-triangular matrix-vector product: y = Lᵀ x.
785fn mat_vec_mul_lower_transpose(l: &Array2<f64>, x: &ArrayView1<f64>) -> Array1<f64> {
786    let n = x.len();
787    let mut y = Array1::<f64>::zeros(n);
788    for j in 0..n {
789        for i in j..n {
790            y[j] += l[[i, j]] * x[i];
791        }
792    }
793    y
794}
795
796/// L2 norm of a vector.
797fn l2_norm_vec(v: &Array1<f64>) -> f64 {
798    v.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
799}
800
801// ─── Tests ────────────────────────────────────────────────────────────────────
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use scirs2_core::ndarray::{array, Array2};
807
808    fn simple_lp() -> OptimizeResult<RobustLP> {
809        // min -x[0] - x[1]  s.t. x[0]+x[1] ≤ 1, x[0] ≥ 0, x[1] ≥ 0
810        // Optimal: x=(0.5, 0.5), obj=-1
811        let c = array![-1.0, -1.0];
812        let a = array![[1.0, 1.0]];
813        let b = array![1.0];
814        RobustLP::new(c, a, b)
815    }
816
817    #[test]
818    fn test_robust_lp_new_dimension_mismatch() {
819        let c = array![1.0, 2.0];
820        let a = array![[1.0, 2.0, 3.0]]; // wrong columns
821        let b = array![1.0];
822        assert!(RobustLP::new(c, a, b).is_err());
823    }
824
825    #[test]
826    fn test_box_robust_lp_no_uncertainty() {
827        let problem = simple_lp()
828            .expect("failed to create problem")
829            .with_bounds(array![0.0, 0.0], array![1.0, 1.0])
830            .expect("unexpected None or Err");
831        let x0 = array![0.0, 0.0];
832        let config = RobustLPConfig {
833            max_iter: 3_000,
834            step_size: 1e-2,
835            constraint_penalty: 50.0,
836            ..Default::default()
837        };
838        let result = box_robust_lp(&problem, &x0.view(), &config).expect("failed to create result");
839        // Without uncertainty, robust = nominal
840        assert!(
841            result.nominal_fun < 0.0,
842            "nominal fun should be negative (minimizing -x)"
843        );
844    }
845
846    #[test]
847    fn test_box_robust_lp_with_uncertainty() {
848        let problem = simple_lp()
849            .expect("unexpected None or Err")
850            .with_c_uncertainty(array![0.1, 0.1])
851            .expect("unexpected None or Err")
852            .with_bounds(array![0.0, 0.0], array![1.0, 1.0])
853            .expect("unexpected None or Err");
854        let x0 = array![0.1, 0.1];
855        let config = RobustLPConfig {
856            max_iter: 2_000,
857            step_size: 5e-3,
858            constraint_penalty: 50.0,
859            ..Default::default()
860        };
861        let result = box_robust_lp(&problem, &x0.view(), &config).expect("failed to create result");
862        // Robust objective should be ≥ nominal (uncertainty adds penalty)
863        assert!(
864            result.fun >= result.nominal_fun - 1e-9,
865            "robust obj {} should be ≥ nominal obj {}",
866            result.fun,
867            result.nominal_fun
868        );
869    }
870
871    #[test]
872    fn test_ellipsoidal_robust_lp() {
873        let problem = simple_lp()
874            .expect("unexpected None or Err")
875            .with_bounds(array![0.0, 0.0], array![1.0, 1.0])
876            .expect("unexpected None or Err");
877        let cov = Array2::<f64>::eye(2) * 0.01;
878        let x0 = array![0.1, 0.1];
879        let config = RobustLPConfig {
880            max_iter: 2_000,
881            step_size: 5e-3,
882            constraint_penalty: 50.0,
883            ..Default::default()
884        };
885        let result = ellipsoidal_robust_lp(&problem, &cov.view(), 0.5, &x0.view(), &config)
886            .expect("unexpected None or Err");
887        // Result should be a valid solution
888        assert!(result.x.len() == 2);
889        assert!(result.fun.is_finite());
890    }
891
892    #[test]
893    fn test_robust_objective_box() {
894        let c = array![1.0, 2.0, -1.0];
895        let x = array![1.0, -1.0, 2.0];
896        let model = ObjectiveUncertaintyModel::Box {
897            delta: array![0.1, 0.1, 0.1],
898        };
899        let obj = robust_objective(&c.view(), &x.view(), &model).expect("failed to create obj");
900        // nominal = 1*1 + 2*(-1) + (-1)*2 = 1 - 2 - 2 = -3
901        // penalty = 0.1*1 + 0.1*1 + 0.1*2 = 0.4
902        assert!((obj - (-3.0 + 0.4)).abs() < 1e-9, "box robust obj={obj}");
903    }
904
905    #[test]
906    fn test_robust_objective_ellipsoidal() {
907        let c = array![1.0, 0.0];
908        let x = array![1.0, 0.0];
909        let cov = Array2::<f64>::eye(2);
910        let model = ObjectiveUncertaintyModel::Ellipsoidal {
911            covariance: cov,
912            radius: 1.0,
913        };
914        let obj = robust_objective(&c.view(), &x.view(), &model).expect("failed to create obj");
915        // nominal = 1, penalty = 1 * sqrt(1^2) = 1 → total = 2
916        assert!((obj - 2.0).abs() < 1e-9, "ellipsoidal robust obj={obj}");
917    }
918
919    #[test]
920    fn test_robust_objective_budget() {
921        let c = array![1.0, 1.0, 1.0];
922        let x = array![1.0, 1.0, 1.0]; // all positive
923        let model = ObjectiveUncertaintyModel::Budget {
924            delta: array![0.5, 0.3, 0.2],
925            budget: 2.0, // only top-2 perturb
926        };
927        let obj = robust_objective(&c.view(), &x.view(), &model).expect("failed to create obj");
928        // nominal = 3; top-2 perturbations: 0.5 + 0.3 = 0.8
929        assert!((obj - 3.8).abs() < 1e-9, "budget robust obj={obj}");
930    }
931
932    #[test]
933    fn test_robust_objective_dimension_error() {
934        let c = array![1.0, 2.0];
935        let x = array![1.0]; // wrong length
936        let model = ObjectiveUncertaintyModel::Box {
937            delta: array![0.1, 0.1],
938        };
939        assert!(robust_objective(&c.view(), &x.view(), &model).is_err());
940    }
941}