Skip to main content

scirs2_optimize/robust/
mod.rs

1//! Robust Optimization and Distributionally Robust Optimization
2//!
3//! This module provides robust optimization methods that hedge against uncertainty
4//! in problem data. These algorithms are essential for risk-aware decision making
5//! under uncertainty.
6//!
7//! # Uncertainty Models
8//!
9//! - **Box uncertainty**: Each parameter perturbed independently within ±δ
10//! - **Ellipsoidal uncertainty**: Parameters perturbed inside an ellipsoid defined by a covariance matrix
11//! - **Polyhedral uncertainty**: Parameters perturbed within a polytope (Aξ ≤ b)
12//! - **Budgeted uncertainty**: Bertsimas-Sim model with total budget Γ
13//!
14//! # Algorithms
15//!
16//! - [`box_robust`]: Worst-case evaluation over box uncertainty set
17//! - [`ellipsoidal_robust`]: Worst-case evaluation over ellipsoidal uncertainty set
18//! - [`distributionally_robust_cvar`]: CVaR-based distributionally robust objective
19//! - [`saa_solve`]: Sample Average Approximation (Kleywegt-Shapiro-Homem-de-Mello)
20//!
21//! # Sub-modules
22//!
23//! - [`minimax`]: Minimax optimization (subgradient, bundle, Nesterov smoothing, fictitious play)
24//! - [`robust_lp`]: Robust linear programming (box, ellipsoidal, budgeted uncertainty)
25//! - [`worst_case`]: Worst-case analysis, AARC, scenario approach, Wasserstein DRO
26//!
27//! # References
28//!
29//! - Ben-Tal, A., El Ghaoui, L., & Nemirovski, A. (2009). *Robust Optimization*.
30//! - Bertsimas, D. & Sim, M. (2004). "The price of robustness". *Operations Research*.
31//! - Shapiro, A., Dentcheva, D., & Ruszczyński, A. (2014). *Lectures on Stochastic Programming*.
32
33pub mod minimax;
34pub mod robust_lp;
35pub mod worst_case;
36
37use crate::error::{OptimizeError, OptimizeResult};
38use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
39
40/// High-level configuration for robust optimization.
41///
42/// Wraps the uncertainty set type and robustness parameter in a single
43/// convenient struct for use with high-level robust optimization APIs.
44#[derive(Debug, Clone)]
45pub struct RobustConfig {
46    /// Type of uncertainty set.
47    pub uncertainty_type: UncertaintyType,
48    /// Robustness parameter ρ: controls the size / conservativeness of the uncertainty set.
49    /// Interpretation depends on `uncertainty_type`:
50    /// - Box: ρ is the uniform box radius (all δ_i = ρ)
51    /// - Ellipsoidal: ρ is the ellipsoid radius
52    /// - Budgeted: ρ is the budget Γ
53    pub robustness_parameter: f64,
54    /// Whether to use CVaR (true) or worst-case (false) as the robustness criterion.
55    pub use_cvar: bool,
56    /// CVaR confidence level α ∈ (0,1) (used when `use_cvar = true`).
57    pub cvar_alpha: f64,
58    /// Number of inner samples for sampling-based solvers.
59    pub n_inner_samples: usize,
60}
61
62impl Default for RobustConfig {
63    fn default() -> Self {
64        Self {
65            uncertainty_type: UncertaintyType::Box,
66            robustness_parameter: 0.1,
67            use_cvar: false,
68            cvar_alpha: 0.95,
69            n_inner_samples: 200,
70        }
71    }
72}
73
74/// Type of uncertainty set used in robust optimization.
75#[derive(Debug, Clone, PartialEq)]
76pub enum UncertaintyType {
77    /// Axis-aligned box: each parameter can vary independently within ±ρ.
78    Box,
79    /// Ellipsoidal: parameters vary inside an ellipsoid with radius ρ.
80    Ellipsoidal,
81    /// Polyhedral: parameters vary inside a polytope (custom A, b).
82    Polyhedral,
83    /// Bertsimas-Sim budgeted: at most ρ (= Γ) parameters deviate simultaneously.
84    Budgeted,
85}
86
87/// Describes the geometry of an uncertainty set for robust optimization.
88#[derive(Debug, Clone)]
89pub enum UncertaintySet {
90    /// Axis-aligned box: ‖ξ‖∞ ≤ δ, i.e. |ξ_i| ≤ delta_i for each component.
91    Box {
92        /// Per-component radius (length must equal problem dimension).
93        delta: Array1<f64>,
94    },
95    /// Ellipsoidal set: {ξ : ξᵀ Σ⁻¹ ξ ≤ ρ²} where Σ is a positive-definite covariance matrix.
96    Ellipsoidal {
97        /// Ellipsoid centre (shift from nominal).
98        center: Array1<f64>,
99        /// Covariance / shape matrix Σ (n×n positive definite).
100        covariance: Array2<f64>,
101        /// Ellipsoid radius ρ.
102        radius: f64,
103    },
104    /// Polyhedral set: {ξ : A ξ ≤ b}.
105    Polyhedral {
106        /// Constraint matrix A (m×n).
107        a_matrix: Array2<f64>,
108        /// Right-hand side b (m-vector).
109        b_vector: Array1<f64>,
110    },
111    /// Bertsimas-Sim budgeted uncertainty:
112    /// at most Γ of the n parameters can deviate by their full radius δ_i.
113    BudgetedUncertainty {
114        /// Per-component radius.
115        delta: Array1<f64>,
116        /// Budget parameter Γ ∈ [0, n].
117        budget: f64,
118    },
119}
120
121/// Configuration for a robust optimization problem.
122///
123/// Wraps a nominal objective together with an uncertainty set; the robust problem
124/// seeks the solution that minimises the *worst-case* objective over the uncertainty set.
125#[derive(Debug, Clone)]
126pub struct RobustProblem {
127    /// Description of the uncertainty set.
128    pub uncertainty_set: UncertaintySet,
129    /// Number of auxiliary inner maximisation samples used by sampling-based solvers.
130    pub n_inner_samples: usize,
131    /// Absolute tolerance for the inner maximisation.
132    pub inner_tol: f64,
133}
134
135impl Default for RobustProblem {
136    fn default() -> Self {
137        Self {
138            uncertainty_set: UncertaintySet::Box {
139                delta: Array1::zeros(0),
140            },
141            n_inner_samples: 200,
142            inner_tol: 1e-8,
143        }
144    }
145}
146
147/// Evaluate the worst-case objective over an *axis-aligned box* uncertainty set.
148///
149/// For each dimension i, the perturbed parameter x̃_i is chosen from {x_i - δ_i, x_i + δ_i}
150/// to maximise f.  The full worst-case is approximated by multi-start local search over the
151/// 2n vertex candidates plus random interior samples.
152///
153/// # Arguments
154///
155/// * `f`     – objective function (lower is better; we find the *maximum*)
156/// * `x`     – nominal parameter vector (length n)
157/// * `delta` – per-component box radius (length n)
158///
159/// # Returns
160///
161/// The worst-case value max_{ξ : |ξ_i| ≤ δ_i} f(x + ξ).
162pub fn box_robust<F>(f: &F, x: &ArrayView1<f64>, delta: &ArrayView1<f64>) -> OptimizeResult<f64>
163where
164    F: Fn(&ArrayView1<f64>) -> f64,
165{
166    let n = x.len();
167    if delta.len() != n {
168        return Err(OptimizeError::ValueError(format!(
169            "delta length {} does not match x length {}",
170            delta.len(),
171            n
172        )));
173    }
174
175    // Check all deltas non-negative
176    for i in 0..n {
177        if delta[i] < 0.0 {
178            return Err(OptimizeError::ValueError(format!(
179                "delta[{}] = {} is negative",
180                i, delta[i]
181            )));
182        }
183    }
184
185    // Evaluate at all 2n vertices (corner-optimality of linear functions guarantees
186    // the worst case is attained at a vertex for linear f; for general f we use this
187    // as a strong heuristic starting set plus random interior samples).
188    let mut worst = f64::NEG_INFINITY;
189
190    // --- vertex evaluation ------------------------------------------------
191    let mut perturbed = x.to_owned();
192    for i in 0..n {
193        // +δ_i direction
194        perturbed[i] = x[i] + delta[i];
195        let val_pos = f(&perturbed.view());
196        if val_pos > worst {
197            worst = val_pos;
198        }
199        // -δ_i direction
200        perturbed[i] = x[i] - delta[i];
201        let val_neg = f(&perturbed.view());
202        if val_neg > worst {
203            worst = val_neg;
204        }
205        // restore
206        perturbed[i] = x[i];
207    }
208
209    // --- random interior samples ------------------------------------------
210    // Use a deterministic quasi-random sequence (uniform grid per dimension) to
211    // avoid external RNG state dependencies while still covering the interior.
212    let n_grid: usize = 5.min(100 / n.max(1));
213    let steps = if n_grid < 2 { 1.0 } else { n_grid as f64 - 1.0 };
214    let mut buf = x.to_owned();
215    // Iterate over a coarse grid (product grid capped to n_grid points per dim)
216    let total = n_grid.pow(n.min(8) as u32); // cap at dim 8 to avoid explosion
217    for sample_idx in 0..total.min(200) {
218        let mut idx = sample_idx;
219        for i in 0..n {
220            let dim_idx = idx % n_grid;
221            idx /= n_grid;
222            let t = if n_grid <= 1 {
223                0.0
224            } else {
225                (dim_idx as f64 / steps) * 2.0 - 1.0 // in [-1, 1]
226            };
227            buf[i] = x[i] + t * delta[i];
228        }
229        let val = f(&buf.view());
230        if val > worst {
231            worst = val;
232        }
233    }
234
235    Ok(worst)
236}
237
238/// Evaluate the worst-case objective over an *ellipsoidal* uncertainty set.
239///
240/// The uncertainty set is E = {x + Σ^{1/2} ξ : ‖ξ‖₂ ≤ ρ}.
241/// For general (non-linear) f, the worst-case direction is approximated by
242/// gradient-based power iteration followed by projected gradient ascent.
243///
244/// # Arguments
245///
246/// * `f`          – objective function
247/// * `x`          – nominal parameter vector (n-vector)
248/// * `center`     – shift of ellipsoid centre relative to x (n-vector; often zero)
249/// * `covariance` – positive-definite shape matrix Σ (n×n)
250/// * `radius`     – ellipsoid radius ρ
251///
252/// # Returns
253///
254/// Worst-case value max_{ξ : ξᵀ Σ⁻¹ ξ ≤ ρ²} f(x + center + ξ).
255pub fn ellipsoidal_robust<F>(
256    f: &F,
257    x: &ArrayView1<f64>,
258    center: &ArrayView1<f64>,
259    covariance: &Array2<f64>,
260    radius: f64,
261) -> OptimizeResult<f64>
262where
263    F: Fn(&ArrayView1<f64>) -> f64,
264{
265    let n = x.len();
266    if center.len() != n {
267        return Err(OptimizeError::ValueError(format!(
268            "center length {} != x length {}",
269            center.len(),
270            n
271        )));
272    }
273    if covariance.shape() != [n, n] {
274        return Err(OptimizeError::ValueError(format!(
275            "covariance shape {:?} != [{n}, {n}]",
276            covariance.shape()
277        )));
278    }
279    if radius < 0.0 {
280        return Err(OptimizeError::ValueError(
281            "radius must be non-negative".to_string(),
282        ));
283    }
284
285    // Nominal shifted point
286    let x_shifted: Array1<f64> = x
287        .iter()
288        .zip(center.iter())
289        .map(|(&xi, &ci)| xi + ci)
290        .collect();
291
292    // Cholesky factor L such that Σ = L Lᵀ  (approximate via diagonal if needed)
293    let chol = cholesky_lower(covariance)?;
294
295    // Projected gradient ascent on ξ ∈ {‖ξ‖₂ ≤ ρ} (in the whitened space η = L⁻¹ ξ)
296    // f(x + center + L η) subject to ‖η‖₂ ≤ ρ
297    let h = 1e-5; // finite-difference step
298    let step_size = 0.1 * radius;
299    let max_iter = 200;
300
301    // Start from multiple candidate directions
302    let mut best_val = f(&x_shifted.view());
303
304    let start_dirs: Vec<Array1<f64>> = {
305        let mut dirs = Vec::with_capacity(2 * n + 1);
306        // zero perturbation (nominal)
307        dirs.push(Array1::zeros(n));
308        // ± unit vectors in η space
309        for i in 0..n {
310            let mut v = Array1::zeros(n);
311            v[i] = radius;
312            dirs.push(v.clone());
313            v[i] = -radius;
314            dirs.push(v);
315        }
316        dirs
317    };
318
319    for init_eta in start_dirs {
320        let mut eta = project_onto_ball(&init_eta.view(), radius);
321
322        for _ in 0..max_iter {
323            // Compute ξ = L η
324            let xi = mat_vec_lower(&chol, &eta.view());
325            // Compute perturbed point
326            let x_pert: Array1<f64> = x_shifted
327                .iter()
328                .zip(xi.iter())
329                .map(|(&a, &b)| a + b)
330                .collect();
331
332            // Finite-difference gradient w.r.t. η
333            let mut grad_eta = Array1::<f64>::zeros(n);
334            for j in 0..n {
335                let mut eta_fwd = eta.clone();
336                eta_fwd[j] += h;
337                let xi_fwd = mat_vec_lower(&chol, &eta_fwd.view());
338                let x_fwd: Array1<f64> = x_shifted
339                    .iter()
340                    .zip(xi_fwd.iter())
341                    .map(|(&a, &b)| a + b)
342                    .collect();
343                grad_eta[j] = (f(&x_fwd.view()) - f(&x_pert.view())) / h;
344            }
345
346            // Gradient ascent step + projection
347            let eta_new: Array1<f64> = eta
348                .iter()
349                .zip(grad_eta.iter())
350                .map(|(&e, &g)| e + step_size * g)
351                .collect();
352            eta = project_onto_ball(&eta_new.view(), radius);
353
354            let xi_new = mat_vec_lower(&chol, &eta.view());
355            let x_new: Array1<f64> = x_shifted
356                .iter()
357                .zip(xi_new.iter())
358                .map(|(&a, &b)| a + b)
359                .collect();
360            let val = f(&x_new.view());
361            if val > best_val {
362                best_val = val;
363            }
364        }
365    }
366
367    Ok(best_val)
368}
369
370/// Distributionally robust CVaR (Conditional Value-at-Risk) objective.
371///
372/// Computes the empirical CVaR at level α over a set of discrete scenarios,
373/// implementing the Rockafellar-Uryasev linear programming formula:
374///
375/// CVaR_α(f(x, ξ)) = min_{t} { t + (1/(1-α)) E[max(f(x,ξ) - t, 0)] }
376///
377/// The minimisation over t is performed by exact line search over the sorted
378/// scenario losses (the optimal t is always a scenario value).
379///
380/// # Arguments
381///
382/// * `f`         – scenario loss function: (x, scenario) → loss value
383/// * `x`         – decision variable
384/// * `scenarios` – slice of scenario parameter vectors (each has length equal to scenario_dim)
385/// * `alpha`     – CVaR confidence level ∈ (0, 1) (typical values: 0.9, 0.95, 0.99)
386///
387/// # Returns
388///
389/// The CVaR_α value at x.
390pub fn distributionally_robust_cvar<F>(
391    f: &F,
392    x: &ArrayView1<f64>,
393    scenarios: &[Array1<f64>],
394    alpha: f64,
395) -> OptimizeResult<f64>
396where
397    F: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
398{
399    if scenarios.is_empty() {
400        return Err(OptimizeError::ValueError(
401            "scenarios must be non-empty".to_string(),
402        ));
403    }
404    if !(0.0 < alpha && alpha < 1.0) {
405        return Err(OptimizeError::ValueError(format!(
406            "alpha must be in (0,1), got {}",
407            alpha
408        )));
409    }
410
411    // Evaluate all scenario losses
412    let mut losses: Vec<f64> = scenarios.iter().map(|s| f(x, &s.view())).collect();
413
414    losses.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
415
416    let n = losses.len();
417
418    // Rockafellar-Uryasev: CVaR_α = min_t { t + 1/(1-α) * mean(max(loss - t, 0)) }
419    // The minimum is attained at t = VaR_α (the α-quantile).
420    // We evaluate at all n candidate t values (each scenario loss) and pick the best.
421    let scale = 1.0 / ((1.0 - alpha) * n as f64);
422
423    let best_cvar = losses
424        .iter()
425        .map(|&t| {
426            let excess: f64 = losses.iter().map(|&l| (l - t).max(0.0)).sum();
427            t + scale * excess
428        })
429        .fold(f64::INFINITY, f64::min);
430
431    Ok(best_cvar)
432}
433
434/// Result of a Sample Average Approximation solve.
435#[derive(Debug, Clone)]
436pub struct SaaResult {
437    /// Approximate optimal solution.
438    pub x: Array1<f64>,
439    /// Optimal SAA objective value (average loss over samples).
440    pub fun: f64,
441    /// Number of SAA iterations performed.
442    pub n_iter: usize,
443    /// Whether the SAA problem converged.
444    pub success: bool,
445    /// Status message.
446    pub message: String,
447}
448
449/// Options for the SAA solver.
450#[derive(Debug, Clone)]
451pub struct SaaConfig {
452    /// Total number of Monte-Carlo samples.
453    pub n_samples: usize,
454    /// Maximum SAA outer iterations (re-sampling rounds).
455    pub max_outer_iter: usize,
456    /// Inner optimisation tolerance.
457    pub tol: f64,
458    /// Inner optimisation maximum iterations.
459    pub inner_max_iter: usize,
460    /// Step size for gradient descent inner solve.
461    pub step_size: f64,
462}
463
464impl Default for SaaConfig {
465    fn default() -> Self {
466        Self {
467            n_samples: 500,
468            max_outer_iter: 10,
469            tol: 1e-6,
470            inner_max_iter: 500,
471            step_size: 1e-3,
472        }
473    }
474}
475
476/// Solve a stochastic program via Sample Average Approximation (SAA).
477///
478/// The stochastic program is:
479///   min_x E_{ξ}[f(x, ξ)]
480///
481/// SAA replaces the expectation with a sample average:
482///   min_x (1/N) Σ_i f(x, ξ_i)
483///
484/// The inner deterministic problem is solved with projected gradient descent
485/// using finite-difference gradients.
486///
487/// # Arguments
488///
489/// * `f`                – per-sample loss: (x, sample) → f64
490/// * `sample_generator` – draws one Monte-Carlo sample ξ ~ P (called n_samples times)
491/// * `x0`               – starting point
492/// * `config`           – SAA configuration
493///
494/// # Returns
495///
496/// [`SaaResult`] containing the approximate minimiser.
497pub fn saa_solve<F, G>(
498    f: &F,
499    sample_generator: &mut G,
500    x0: &ArrayView1<f64>,
501    config: &SaaConfig,
502) -> OptimizeResult<SaaResult>
503where
504    F: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
505    G: FnMut() -> Array1<f64>,
506{
507    let n = x0.len();
508    if n == 0 {
509        return Err(OptimizeError::ValueError(
510            "x0 must be non-empty".to_string(),
511        ));
512    }
513
514    let mut x = x0.to_owned();
515    let h = 1e-5; // finite-difference step
516    let mut converged = false;
517    let mut outer_iter = 0;
518
519    for outer in 0..config.max_outer_iter {
520        outer_iter = outer + 1;
521
522        // Draw fresh samples
523        let samples: Vec<Array1<f64>> = (0..config.n_samples).map(|_| sample_generator()).collect();
524
525        // Inner gradient descent on SAA objective
526        for _ in 0..config.inner_max_iter {
527            // Compute SAA gradient via finite differences
528            let f_x: f64 = samples.iter().map(|s| f(&x.view(), &s.view())).sum::<f64>()
529                / config.n_samples as f64;
530
531            let mut grad = Array1::<f64>::zeros(n);
532            for j in 0..n {
533                let mut x_fwd = x.clone();
534                x_fwd[j] += h;
535                let f_fwd: f64 = samples
536                    .iter()
537                    .map(|s| f(&x_fwd.view(), &s.view()))
538                    .sum::<f64>()
539                    / config.n_samples as f64;
540                grad[j] = (f_fwd - f_x) / h;
541            }
542
543            let grad_norm = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
544            if grad_norm < config.tol {
545                converged = true;
546                break;
547            }
548
549            // Gradient descent step
550            for j in 0..n {
551                x[j] -= config.step_size * grad[j];
552            }
553        }
554
555        if converged {
556            break;
557        }
558    }
559
560    // Final objective value
561    let final_samples: Vec<Array1<f64>> = (0..config.n_samples.min(100))
562        .map(|_| sample_generator())
563        .collect();
564    let fun = if final_samples.is_empty() {
565        0.0
566    } else {
567        final_samples
568            .iter()
569            .map(|s| f(&x.view(), &s.view()))
570            .sum::<f64>()
571            / final_samples.len() as f64
572    };
573
574    Ok(SaaResult {
575        x,
576        fun,
577        n_iter: outer_iter,
578        success: converged,
579        message: if converged {
580            "SAA converged".to_string()
581        } else {
582            "SAA reached maximum outer iterations".to_string()
583        },
584    })
585}
586
587// ─── Internal helpers ────────────────────────────────────────────────────────
588
589/// Project a vector onto the L2-ball of given radius.
590fn project_onto_ball(v: &ArrayView1<f64>, radius: f64) -> Array1<f64> {
591    let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
592    if norm <= radius || norm == 0.0 {
593        v.to_owned()
594    } else {
595        v.mapv(|x| x * radius / norm)
596    }
597}
598
599/// Lower-triangular matrix–vector product: y = L x.
600fn mat_vec_lower(l: &Array2<f64>, x: &ArrayView1<f64>) -> Array1<f64> {
601    let n = x.len();
602    let mut y = Array1::<f64>::zeros(n);
603    for i in 0..n {
604        for j in 0..=i {
605            y[i] += l[[i, j]] * x[j];
606        }
607    }
608    y
609}
610
611/// Incomplete (lower-triangular) Cholesky of a symmetric positive-definite matrix.
612/// Returns the lower-triangular factor L such that A ≈ L Lᵀ.
613/// Falls back to the diagonal square root if the matrix is not positive definite.
614fn cholesky_lower(a: &Array2<f64>) -> OptimizeResult<Array2<f64>> {
615    let n = a.shape()[0];
616    let mut l = Array2::<f64>::zeros((n, n));
617
618    for i in 0..n {
619        for j in 0..=i {
620            let mut sum = 0.0;
621            for k in 0..j {
622                sum += l[[i, k]] * l[[j, k]];
623            }
624            if i == j {
625                let diag = a[[i, i]] - sum;
626                if diag < 0.0 {
627                    // Fall back: use absolute value (matrix is near-singular)
628                    l[[i, j]] = diag.abs().sqrt().max(1e-12);
629                } else {
630                    l[[i, j]] = diag.sqrt();
631                }
632            } else {
633                let ljj = l[[j, j]];
634                if ljj.abs() < 1e-14 {
635                    l[[i, j]] = 0.0;
636                } else {
637                    l[[i, j]] = (a[[i, j]] - sum) / ljj;
638                }
639            }
640        }
641    }
642    Ok(l)
643}
644
645// ─── Tests ───────────────────────────────────────────────────────────────────
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use scirs2_core::ndarray::{array, Array2};
651
652    fn quadratic(x: &ArrayView1<f64>) -> f64 {
653        x.iter().map(|xi| xi * xi).sum()
654    }
655
656    #[test]
657    fn test_box_robust_basic() {
658        // f(x) = x₀² + x₁²; worst case over box |ξ| ≤ 1 around (0,0) should be 2.0
659        let x = array![0.0, 0.0];
660        let delta = array![1.0, 1.0];
661        let val = box_robust(&quadratic, &x.view(), &delta.view()).expect("failed to create val");
662        assert!((val - 2.0).abs() < 1e-9, "expected 2.0, got {val}");
663    }
664
665    #[test]
666    fn test_box_robust_shifted() {
667        // f(x) = x²; worst case over box |ξ| ≤ 0.5 around x=1 should be (1.5)²=2.25
668        let x = array![1.0];
669        let delta = array![0.5];
670        let val = box_robust(&|v: &ArrayView1<f64>| v[0] * v[0], &x.view(), &delta.view())
671            .expect("unexpected None or Err");
672        assert!((val - 2.25).abs() < 1e-9, "expected 2.25, got {val}");
673    }
674
675    #[test]
676    fn test_box_robust_bad_delta() {
677        let x = array![1.0];
678        let delta = array![-0.1];
679        assert!(box_robust(&quadratic, &x.view(), &delta.view()).is_err());
680    }
681
682    #[test]
683    fn test_ellipsoidal_robust_identity() {
684        // Ellipsoidal with Σ = I, ρ=1: worst case of f(x)=‖x‖² around x=0 is ρ²=1
685        let x = array![0.0, 0.0];
686        let center = array![0.0, 0.0];
687        let cov = Array2::<f64>::eye(2);
688        let val = ellipsoidal_robust(&quadratic, &x.view(), &center.view(), &cov, 1.0)
689            .expect("unexpected None or Err");
690        // worst case: move distance 1 in any direction → ‖x+ξ‖²= 1
691        assert!((val - 1.0).abs() < 0.05, "expected ~1.0, got {val}");
692    }
693
694    #[test]
695    fn test_cvar_basic() {
696        // 5 scenarios with losses [0,1,2,3,4]; CVaR_{0.8} = mean of top 20% = 4.0
697        let x = array![0.0];
698        let scenarios: Vec<Array1<f64>> = (0..5).map(|i| array![i as f64]).collect();
699        let f = |_x: &ArrayView1<f64>, s: &ArrayView1<f64>| s[0];
700        let cvar = distributionally_robust_cvar(&f, &x.view(), &scenarios, 0.8)
701            .expect("failed to create cvar");
702        assert!((cvar - 4.0).abs() < 1e-9, "expected 4.0, got {cvar}");
703    }
704
705    #[test]
706    fn test_cvar_alpha_error() {
707        let x = array![0.0];
708        let scenarios = vec![array![1.0]];
709        let f = |_: &ArrayView1<f64>, s: &ArrayView1<f64>| s[0];
710        assert!(distributionally_robust_cvar(&f, &x.view(), &scenarios, 0.0).is_err());
711        assert!(distributionally_robust_cvar(&f, &x.view(), &scenarios, 1.0).is_err());
712    }
713
714    #[test]
715    fn test_saa_quadratic() {
716        // min_x E[(x - ξ)²] where ξ ~ Uniform[0, 2]; optimum at x* = E[ξ] = 1
717        let f = |x: &ArrayView1<f64>, xi: &ArrayView1<f64>| {
718            let diff = x[0] - xi[0];
719            diff * diff
720        };
721        let mut rng_state = 42u64;
722        let mut sample_gen = || {
723            // Simple LCG pseudo-random in [0, 2]
724            rng_state = rng_state
725                .wrapping_mul(6364136223846793005)
726                .wrapping_add(1442695040888963407);
727            // Take upper 32 bits to get a full u32-range value, then normalize to [0, 2]
728            let t = ((rng_state >> 32) as u32 as f64) / (u32::MAX as f64) * 2.0;
729            array![t]
730        };
731        let x0 = array![0.0];
732        let config = SaaConfig {
733            n_samples: 200,
734            max_outer_iter: 5,
735            tol: 1e-4,
736            inner_max_iter: 200,
737            step_size: 5e-3,
738        };
739        let result =
740            saa_solve(&f, &mut sample_gen, &x0.view(), &config).expect("failed to create result");
741        assert!(
742            (result.x[0] - 1.0).abs() < 0.15,
743            "expected x* ≈ 1.0, got {}",
744            result.x[0]
745        );
746    }
747}