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(), ¢er.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}