Skip to main content

scirs2_optimize/robust/
worst_case.rs

1//! Worst-Case Analysis and Scenario-Based Robust Optimization
2//!
3//! This module provides tools for analyzing worst-case performance and
4//! scenario-based approaches to robust optimization.
5//!
6//! # Methods
7//!
8//! - [`worst_case_analysis`]: Enumerate and evaluate all scenarios to find the worst case
9//! - [`affinely_adjustable`]: Affinely Adjustable Robust Counterpart (AARC)
10//! - [`scenario_approach`]: Scenario approach (Campi-Garatti) with sample complexity guarantees
11//! - [`distributionally_robust`]: DRO with Wasserstein ball constraint
12//!
13//! # Background
14//!
15//! The *scenario approach* (Campi & Garatti 2008) provides a distribution-free way
16//! to design robust controllers: given N i.i.d. samples of uncertainty ξ_1, …, ξ_N,
17//! solve the sampled program and obtain *a priori* feasibility guarantees.
18//!
19//! *Distributionally robust optimization* (Wiesemann et al. 2014; Mohajerin Esfahani
20//! & Kuhn 2018) minimizes the worst-case expected loss over all distributions P in a
21//! Wasserstein ball around the empirical distribution.
22//!
23//! # References
24//!
25//! - Campi, M.C. & Garatti, S. (2008). "The exact feasibility of randomized solutions
26//!   of uncertain convex programs". *SIAM Journal on Optimization*.
27//! - Ben-Tal, A. & Goryashko, A. (2004). "Adjustable robust solutions of uncertain LP".
28//!   *Mathematical Programming*.
29//! - Mohajerin Esfahani, P. & Kuhn, D. (2018). "Data-driven distributionally robust
30//!   optimization using the Wasserstein metric". *Mathematical Programming*.
31//! - Shapiro, A. (2017). "Distributionally robust stochastic programming". *SIAM JOPT*.
32
33use crate::error::{OptimizeError, OptimizeResult};
34use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
35
36// ─── Worst-case analysis ──────────────────────────────────────────────────────
37
38/// A single evaluated scenario.
39#[derive(Debug, Clone)]
40pub struct ScenarioResult {
41    /// Scenario parameter vector ξ.
42    pub scenario: Array1<f64>,
43    /// Objective value f(x, ξ).
44    pub obj_value: f64,
45    /// Whether the constraints are satisfied for this scenario.
46    pub feasible: bool,
47    /// Constraint violations (positive = violated).
48    pub violations: Vec<f64>,
49}
50
51/// Result of worst-case analysis over a scenario set.
52#[derive(Debug, Clone)]
53pub struct WorstCaseResult {
54    /// All evaluated scenario results.
55    pub scenarios: Vec<ScenarioResult>,
56    /// Index of the worst scenario (highest objective value).
57    pub worst_index: usize,
58    /// Worst-case objective value.
59    pub worst_obj: f64,
60    /// Best-case objective value.
61    pub best_obj: f64,
62    /// Average objective value over all scenarios.
63    pub avg_obj: f64,
64    /// Standard deviation of objective values.
65    pub std_obj: f64,
66    /// Fraction of scenarios that are feasible.
67    pub feasibility_rate: f64,
68    /// Number of scenarios evaluated.
69    pub n_scenarios: usize,
70}
71
72/// Perform worst-case analysis over an enumerated set of scenarios.
73///
74/// Evaluates the objective f(x, ξ) and optionally constraint functions g_i(x, ξ) ≤ 0
75/// at every provided scenario ξ_j, and reports statistics.
76///
77/// # Arguments
78///
79/// * `obj_fn`      – objective function (x, ξ) → f64
80/// * `x`           – decision variable (fixed)
81/// * `scenarios`   – slice of uncertainty realizations ξ_j
82/// * `constraint_fns` – optional slice of constraint functions (x, ξ) → f64; feasible when ≤ 0
83///
84/// # Returns
85///
86/// [`WorstCaseResult`] summarizing worst-case, best-case, and distributional statistics.
87pub fn worst_case_analysis<F, G>(
88    obj_fn: &F,
89    x: &ArrayView1<f64>,
90    scenarios: &[Array1<f64>],
91    constraint_fns: &[G],
92) -> OptimizeResult<WorstCaseResult>
93where
94    F: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
95    G: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
96{
97    if scenarios.is_empty() {
98        return Err(OptimizeError::ValueError(
99            "scenarios must be non-empty".to_string(),
100        ));
101    }
102
103    let n_scen = scenarios.len();
104    let mut results = Vec::with_capacity(n_scen);
105    let mut sum_obj = 0.0_f64;
106    let mut sum_sq = 0.0_f64;
107    let mut worst_val = f64::NEG_INFINITY;
108    let mut best_val = f64::INFINITY;
109    let mut worst_idx = 0_usize;
110    let mut n_feasible = 0_usize;
111
112    for (j, scenario) in scenarios.iter().enumerate() {
113        let obj = obj_fn(x, &scenario.view());
114        let violations: Vec<f64> = constraint_fns
115            .iter()
116            .map(|g| g(x, &scenario.view()))
117            .collect();
118        let feasible = violations.iter().all(|&v| v <= 0.0);
119
120        sum_obj += obj;
121        sum_sq += obj * obj;
122        if obj > worst_val {
123            worst_val = obj;
124            worst_idx = j;
125        }
126        if obj < best_val {
127            best_val = obj;
128        }
129        if feasible {
130            n_feasible += 1;
131        }
132
133        results.push(ScenarioResult {
134            scenario: scenario.clone(),
135            obj_value: obj,
136            feasible,
137            violations,
138        });
139    }
140
141    let avg = sum_obj / n_scen as f64;
142    let variance = (sum_sq / n_scen as f64 - avg * avg).max(0.0);
143    let std_dev = variance.sqrt();
144    let feasibility_rate = n_feasible as f64 / n_scen as f64;
145
146    Ok(WorstCaseResult {
147        scenarios: results,
148        worst_index: worst_idx,
149        worst_obj: worst_val,
150        best_obj: best_val,
151        avg_obj: avg,
152        std_obj: std_dev,
153        feasibility_rate,
154        n_scenarios: n_scen,
155    })
156}
157
158// ─── Affinely Adjustable Robust Counterpart ────────────────────────────────
159
160/// Configuration for the affinely adjustable robust counterpart (AARC).
161#[derive(Debug, Clone)]
162pub struct AARCConfig {
163    /// Dimension of the recourse variable y.
164    pub recourse_dim: usize,
165    /// Maximum iterations for the saddle-point solve.
166    pub max_iter: usize,
167    /// Convergence tolerance.
168    pub tol: f64,
169    /// Step size for the outer (robust) minimization.
170    pub step_size: f64,
171    /// Number of uncertainty samples for the inner maximization.
172    pub n_uncertainty_samples: usize,
173    /// Finite-difference step.
174    pub fd_step: f64,
175}
176
177impl Default for AARCConfig {
178    fn default() -> Self {
179        Self {
180            recourse_dim: 1,
181            max_iter: 2_000,
182            tol: 1e-5,
183            step_size: 1e-3,
184            n_uncertainty_samples: 100,
185            fd_step: 1e-5,
186        }
187    }
188}
189
190/// Result of the AARC solve.
191#[derive(Debug, Clone)]
192pub struct AARCResult {
193    /// First-stage decision x*.
194    pub x: Array1<f64>,
195    /// Affine recourse policy: y(ξ) = K x + L ξ + m.
196    /// Returned as the matrices K (recourse_dim × x_dim), L (recourse_dim × xi_dim), and m.
197    pub k_matrix: Array2<f64>,
198    pub l_matrix: Array2<f64>,
199    pub m_vector: Array1<f64>,
200    /// Worst-case objective value.
201    pub worst_obj: f64,
202    /// Number of iterations.
203    pub n_iter: usize,
204    /// Whether the algorithm converged.
205    pub converged: bool,
206    /// Status message.
207    pub message: String,
208}
209
210/// Solve an Affinely Adjustable Robust Counterpart (AARC).
211///
212/// The AARC replaces the static robust program with a two-stage problem where
213/// a *recourse variable* y can be adjusted as an affine function of the realized
214/// uncertainty ξ:
215///
216/// ```text
217/// y(ξ) = K x + L ξ + m
218/// ```
219///
220/// The optimization seeks (x, K, L, m) minimizing worst-case cost:
221///
222/// ```text
223/// min_{x, K, L, m}  max_{ξ ∈ U}  f(x, y(ξ), ξ)
224/// ```
225///
226/// This implementation uses projected gradient descent on (x, K, L, m) with
227/// sampled inner maximization.
228///
229/// # Arguments
230///
231/// * `obj_fn`    – (x, y, ξ) → objective value
232/// * `x0`        – initial first-stage decision (x_dim)
233/// * `xi_samples`– samples from the uncertainty set U
234/// * `xi_dim`    – dimension of ξ
235/// * `config`    – AARC configuration
236///
237/// # Returns
238///
239/// [`AARCResult`] containing the robust recourse policy.
240///
241/// # References
242///
243/// Ben-Tal, A. & Goryashko, A. (2004). "Adjustable robust solutions of uncertain LP".
244pub fn affinely_adjustable<F>(
245    obj_fn: &F,
246    x0: &ArrayView1<f64>,
247    xi_samples: &[Array1<f64>],
248    xi_dim: usize,
249    config: &AARCConfig,
250) -> OptimizeResult<AARCResult>
251where
252    F: Fn(&ArrayView1<f64>, &ArrayView1<f64>, &ArrayView1<f64>) -> f64,
253{
254    if xi_samples.is_empty() {
255        return Err(OptimizeError::ValueError(
256            "xi_samples must be non-empty".to_string(),
257        ));
258    }
259    let x_dim = x0.len();
260    let y_dim = config.recourse_dim;
261    if y_dim == 0 {
262        return Err(OptimizeError::ValueError(
263            "recourse_dim must be positive".to_string(),
264        ));
265    }
266
267    // Policy parameters: K (y_dim × x_dim), L (y_dim × xi_dim), m (y_dim)
268    let mut k_matrix = Array2::<f64>::zeros((y_dim, x_dim));
269    let mut l_matrix = Array2::<f64>::zeros((y_dim, xi_dim));
270    let mut m_vector = Array1::<f64>::zeros(y_dim);
271    let mut x = x0.to_owned();
272    let h = config.fd_step;
273
274    let mut converged = false;
275
276    // Evaluate policy: y(ξ) = K x + L ξ + m
277    let eval_policy = |x_cur: &Array1<f64>,
278                       k: &Array2<f64>,
279                       l: &Array2<f64>,
280                       m: &Array1<f64>,
281                       xi: &Array1<f64>|
282     -> Array1<f64> {
283        let mut y = m.clone();
284        for i in 0..y_dim {
285            for j in 0..x_dim {
286                y[i] += k[[i, j]] * x_cur[j];
287            }
288            for j in 0..xi.len().min(xi_dim) {
289                y[i] += l[[i, j]] * xi[j];
290            }
291        }
292        y
293    };
294
295    // Worst-case objective over samples
296    let worst_obj_fn =
297        |x_cur: &Array1<f64>, k: &Array2<f64>, l: &Array2<f64>, m: &Array1<f64>| -> f64 {
298            xi_samples
299                .iter()
300                .map(|xi| {
301                    let y = eval_policy(x_cur, k, l, m, xi);
302                    obj_fn(&x_cur.view(), &y.view(), &xi.view())
303                })
304                .fold(f64::NEG_INFINITY, f64::max)
305        };
306
307    for _ in 0..config.max_iter {
308        let f_curr = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_vector);
309
310        // Finite-difference gradients for x
311        let mut grad_x = Array1::<f64>::zeros(x_dim);
312        for j in 0..x_dim {
313            let mut x_fwd = x.clone();
314            x_fwd[j] += h;
315            let f_fwd = worst_obj_fn(&x_fwd, &k_matrix, &l_matrix, &m_vector);
316            grad_x[j] = (f_fwd - f_curr) / h;
317        }
318
319        // Finite-difference gradients for K
320        let mut grad_k = Array2::<f64>::zeros((y_dim, x_dim));
321        for i in 0..y_dim {
322            for j in 0..x_dim {
323                let mut k_fwd = k_matrix.clone();
324                k_fwd[[i, j]] += h;
325                let f_fwd = worst_obj_fn(&x, &k_fwd, &l_matrix, &m_vector);
326                grad_k[[i, j]] = (f_fwd - f_curr) / h;
327            }
328        }
329
330        // Finite-difference gradients for L
331        let mut grad_l = Array2::<f64>::zeros((y_dim, xi_dim));
332        for i in 0..y_dim {
333            for j in 0..xi_dim {
334                let mut l_fwd = l_matrix.clone();
335                l_fwd[[i, j]] += h;
336                let f_fwd = worst_obj_fn(&x, &k_matrix, &l_fwd, &m_vector);
337                grad_l[[i, j]] = (f_fwd - f_curr) / h;
338            }
339        }
340
341        // Finite-difference gradients for m
342        let mut grad_m = Array1::<f64>::zeros(y_dim);
343        for i in 0..y_dim {
344            let mut m_fwd = m_vector.clone();
345            m_fwd[i] += h;
346            let f_fwd = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_fwd);
347            grad_m[i] = (f_fwd - f_curr) / h;
348        }
349
350        // Gradient norms for convergence
351        let gx_norm = l2_norm_arr1(&grad_x);
352        let gk_norm = l2_norm_arr2(&grad_k);
353        let gl_norm = l2_norm_arr2(&grad_l);
354        let gm_norm = l2_norm_arr1(&grad_m);
355        let total_norm = gx_norm + gk_norm + gl_norm + gm_norm;
356
357        if total_norm < config.tol {
358            converged = true;
359            break;
360        }
361
362        // Gradient descent step
363        let alpha = config.step_size;
364        for j in 0..x_dim {
365            x[j] -= alpha * grad_x[j];
366        }
367        for i in 0..y_dim {
368            for j in 0..x_dim {
369                k_matrix[[i, j]] -= alpha * grad_k[[i, j]];
370            }
371            for j in 0..xi_dim {
372                l_matrix[[i, j]] -= alpha * grad_l[[i, j]];
373            }
374            m_vector[i] -= alpha * grad_m[i];
375        }
376    }
377
378    let worst_obj = worst_obj_fn(&x, &k_matrix, &l_matrix, &m_vector);
379
380    Ok(AARCResult {
381        x,
382        k_matrix,
383        l_matrix,
384        m_vector,
385        worst_obj,
386        n_iter: config.max_iter,
387        converged,
388        message: if converged {
389            "AARC converged".to_string()
390        } else {
391            "AARC reached maximum iterations".to_string()
392        },
393    })
394}
395
396// ─── Scenario Approach (Campi-Garatti) ────────────────────────────────────────
397
398/// Configuration for the scenario approach.
399#[derive(Debug, Clone)]
400pub struct ScenarioApproachConfig {
401    /// Number of scenarios N (sample complexity). Should satisfy theoretical bound.
402    pub n_scenarios: usize,
403    /// Confidence parameter β ∈ (0, 1): the solution is feasible with prob. ≥ 1 - β.
404    pub confidence: f64,
405    /// Maximum inner optimization iterations.
406    pub max_iter: usize,
407    /// Inner optimization convergence tolerance.
408    pub tol: f64,
409    /// Step size for inner optimization.
410    pub step_size: f64,
411    /// Finite-difference step.
412    pub fd_step: f64,
413}
414
415impl Default for ScenarioApproachConfig {
416    fn default() -> Self {
417        Self {
418            n_scenarios: 500,
419            confidence: 0.95,
420            max_iter: 2_000,
421            tol: 1e-5,
422            step_size: 1e-3,
423            fd_step: 1e-5,
424        }
425    }
426}
427
428/// Result of a scenario approach solve.
429#[derive(Debug, Clone)]
430pub struct ScenarioApproachResult {
431    /// Optimal solution x* of the sampled program.
432    pub x: Array1<f64>,
433    /// Optimal objective value of the sampled program.
434    pub fun: f64,
435    /// Number of active (binding) scenarios (support scenarios).
436    pub n_support_scenarios: usize,
437    /// A priori probability guarantee: feasibility probability ≥ this value.
438    pub feasibility_guarantee: f64,
439    /// Number of scenarios used.
440    pub n_scenarios: usize,
441    /// Number of iterations.
442    pub n_iter: usize,
443    /// Whether the inner optimization converged.
444    pub converged: bool,
445    /// Status message.
446    pub message: String,
447}
448
449/// Solve a robust optimization problem via the Campi-Garatti scenario approach.
450///
451/// The scenario approach solves the *sampled* robust program:
452///
453/// ```text
454/// min_x  f₀(x)
455/// s.t.  f_i(x, ξ_j) ≤ 0  for all j = 1 … N, i = 1 … m
456/// ```
457///
458/// By the Campi-Garatti theorem, with confidence ≥ 1 - β, the solution is
459/// feasible for all future realizations of ξ, provided N ≥ N*(n, ε, β) where:
460///
461/// ```text
462/// N*(n, ε, β) = ⌈(2/ε) · (ln(1/β) + n)⌉
463/// ```
464///
465/// and n = dim(x), ε = violation probability.
466///
467/// The inner problem is solved with projected gradient descent.
468///
469/// # Arguments
470///
471/// * `obj_fn`        – deterministic objective (does NOT depend on ξ): x → f64
472/// * `constraint_fn` – constraint (x, ξ) → f64; feasible when ≤ 0
473/// * `sample_fn`     – draws one sample ξ ~ P
474/// * `x0`            – initial point
475/// * `config`        – scenario approach configuration
476///
477/// # Returns
478///
479/// [`ScenarioApproachResult`] with the scenario-optimal solution and guarantees.
480///
481/// # References
482///
483/// Campi, M.C. & Garatti, S. (2008). "The exact feasibility of randomized solutions
484/// of uncertain convex programs". *SIAM Journal on Optimization*, 19(3), 1211–1230.
485pub fn scenario_approach<F, G, H>(
486    obj_fn: &F,
487    constraint_fn: &G,
488    sample_fn: &mut H,
489    x0: &ArrayView1<f64>,
490    config: &ScenarioApproachConfig,
491) -> OptimizeResult<ScenarioApproachResult>
492where
493    F: Fn(&ArrayView1<f64>) -> f64,
494    G: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
495    H: FnMut() -> Array1<f64>,
496{
497    let n = x0.len();
498    if n == 0 {
499        return Err(OptimizeError::ValueError(
500            "x0 must be non-empty".to_string(),
501        ));
502    }
503    if !(0.0 < config.confidence && config.confidence < 1.0) {
504        return Err(OptimizeError::ValueError(format!(
505            "confidence must be in (0,1), got {}",
506            config.confidence
507        )));
508    }
509
510    // Draw N scenarios
511    let scenarios: Vec<Array1<f64>> = (0..config.n_scenarios).map(|_| sample_fn()).collect();
512
513    let h = config.fd_step;
514    let penalty = 1e3_f64; // constraint penalty weight
515
516    let mut x = x0.to_owned();
517    let mut converged = false;
518
519    // Penalized objective: f₀(x) + penalty * Σ_j max(g(x, ξ_j), 0)²
520    let penalized_obj = |x_cur: &ArrayView1<f64>| -> f64 {
521        let base = obj_fn(x_cur);
522        let viol: f64 = scenarios
523            .iter()
524            .map(|xi| constraint_fn(x_cur, &xi.view()).max(0.0).powi(2))
525            .sum();
526        base + penalty * viol
527    };
528
529    for _ in 0..config.max_iter {
530        let f0 = penalized_obj(&x.view());
531
532        // Finite-difference gradient
533        let mut grad = Array1::<f64>::zeros(n);
534        let mut x_fwd = x.clone();
535        for j in 0..n {
536            x_fwd[j] += h;
537            let f_fwd = penalized_obj(&x_fwd.view());
538            grad[j] = (f_fwd - f0) / h;
539            x_fwd[j] = x[j];
540        }
541
542        let gn = l2_norm_arr1(&grad);
543        if gn < config.tol {
544            converged = true;
545            break;
546        }
547
548        // Backtracking line search (Armijo condition) to prevent divergence
549        let mut alpha = config.step_size;
550        let mut x_trial = x.clone();
551        let armijo_c = 0.5_f64;
552        let backtrack_factor = 0.5_f64;
553        let max_backtracks = 30_usize;
554        for _ in 0..max_backtracks {
555            for j in 0..n {
556                x_trial[j] = x[j] - alpha * grad[j];
557            }
558            let f_trial = penalized_obj(&x_trial.view());
559            if f_trial <= f0 - armijo_c * alpha * gn * gn {
560                break;
561            }
562            alpha *= backtrack_factor;
563        }
564        x = x_trial;
565    }
566
567    let fun = obj_fn(&x.view());
568
569    // Count support scenarios (binding constraints: g(x*, ξ_j) ≈ 0)
570    let tol_support = 1e-3;
571    let n_support = scenarios
572        .iter()
573        .filter(|xi| constraint_fn(&x.view(), &xi.view()).abs() < tol_support)
574        .count();
575
576    // A priori guarantee: P[feasibility] ≥ 1 - Σ_{k=0}^{n-1} C(N,k) ε^k (1-ε)^{N-k}
577    // For the simplified Campi-Garatti bound with ε = n/N (approximation):
578    let beta = 1.0 - config.confidence;
579    let epsilon_cg = if config.n_scenarios > n {
580        // Campi-Garatti: ε such that N ≥ (2/ε)(ln(1/β) + n)
581        // → ε = 2(ln(1/β) + n) / N
582        2.0 * (beta.recip().ln() + n as f64) / config.n_scenarios as f64
583    } else {
584        1.0 // trivial bound if too few samples
585    };
586    let feasibility_guarantee = (1.0 - epsilon_cg).max(0.0).min(1.0);
587
588    Ok(ScenarioApproachResult {
589        x,
590        fun,
591        n_support_scenarios: n_support,
592        feasibility_guarantee,
593        n_scenarios: config.n_scenarios,
594        n_iter: config.max_iter,
595        converged,
596        message: if converged {
597            "Scenario approach inner optimization converged".to_string()
598        } else {
599            "Scenario approach reached maximum iterations".to_string()
600        },
601    })
602}
603
604// ─── Distributionally Robust Optimization (Wasserstein DRO) ─────────────────
605
606/// Configuration for Wasserstein DRO.
607#[derive(Debug, Clone)]
608pub struct WassersteinDROConfig {
609    /// Wasserstein ball radius ε > 0.
610    pub epsilon: f64,
611    /// Wasserstein order p (1 or 2).
612    pub p_order: u32,
613    /// Regularization penalty for Wasserstein constraint.
614    pub lambda: f64,
615    /// Maximum iterations.
616    pub max_iter: usize,
617    /// Convergence tolerance.
618    pub tol: f64,
619    /// Step size.
620    pub step_size: f64,
621    /// Finite-difference step.
622    pub fd_step: f64,
623}
624
625impl Default for WassersteinDROConfig {
626    fn default() -> Self {
627        Self {
628            epsilon: 0.1,
629            p_order: 1,
630            lambda: 10.0,
631            max_iter: 2_000,
632            tol: 1e-5,
633            step_size: 1e-3,
634            fd_step: 1e-5,
635        }
636    }
637}
638
639/// Result of a Wasserstein DRO solve.
640#[derive(Debug, Clone)]
641pub struct WassersteinDROResult {
642    /// Robust-optimal decision x*.
643    pub x: Array1<f64>,
644    /// Worst-case expected loss under the Wasserstein ball.
645    pub worst_case_loss: f64,
646    /// Empirical loss (average over training scenarios).
647    pub empirical_loss: f64,
648    /// Estimated Lipschitz constant of the loss function.
649    pub lipschitz_estimate: f64,
650    /// Number of iterations.
651    pub n_iter: usize,
652    /// Whether the algorithm converged.
653    pub converged: bool,
654    /// Status message.
655    pub message: String,
656}
657
658/// Solve a distributionally robust optimization problem with a Wasserstein ball.
659///
660/// **Problem formulation** (Mohajerin Esfahani & Kuhn 2018):
661///
662/// ```text
663/// min_x  max_{P : W_p(P, P̂_N) ≤ ε}  E_P[f(x, ξ)]
664/// ```
665///
666/// where P̂_N = (1/N) Σ_i δ_{ξ_i} is the empirical distribution and
667/// W_p is the p-Wasserstein distance.
668///
669/// **Tractable reformulation** (finite-sample, p=1):
670///
671/// The worst-case expected loss has the upper bound (Kuhn et al. 2019):
672///
673/// ```text
674/// sup_{P ∈ Bε} E_P[f(x,ξ)] ≤ (1/N) Σ_i f(x, ξ_i) + ε · L(x)
675/// ```
676///
677/// where L(x) is the Lipschitz constant of ξ ↦ f(x, ξ).
678///
679/// We minimize this tractable upper bound, estimating L(x) empirically.
680///
681/// # Arguments
682///
683/// * `loss_fn`   – loss (x, ξ) → f64 (should be Lipschitz in ξ for theoretical guarantees)
684/// * `x0`        – initial decision variable
685/// * `scenarios` – empirical samples ξ_1, …, ξ_N
686/// * `config`    – DRO configuration
687///
688/// # Returns
689///
690/// [`WassersteinDROResult`] with robust-optimal solution.
691///
692/// # References
693///
694/// Mohajerin Esfahani, P. & Kuhn, D. (2018). "Data-driven distributionally robust
695/// optimization using the Wasserstein metric". *Mathematical Programming*, 171, 115–166.
696pub fn distributionally_robust<F>(
697    loss_fn: &F,
698    x0: &ArrayView1<f64>,
699    scenarios: &[Array1<f64>],
700    config: &WassersteinDROConfig,
701) -> OptimizeResult<WassersteinDROResult>
702where
703    F: Fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64,
704{
705    if scenarios.is_empty() {
706        return Err(OptimizeError::ValueError(
707            "scenarios must be non-empty".to_string(),
708        ));
709    }
710    let n = x0.len();
711    if n == 0 {
712        return Err(OptimizeError::ValueError(
713            "x0 must be non-empty".to_string(),
714        ));
715    }
716    if config.epsilon < 0.0 {
717        return Err(OptimizeError::ValueError(format!(
718            "epsilon must be non-negative, got {}",
719            config.epsilon
720        )));
721    }
722
723    let n_scen = scenarios.len();
724    let h = config.fd_step;
725    let mut x = x0.to_owned();
726    let mut converged = false;
727
728    // Estimate Lipschitz constant: L(x) ≈ max_{i≠j} |f(x,ξ_i)-f(x,ξ_j)| / ‖ξ_i - ξ_j‖_p
729    let estimate_lipschitz = |x_cur: &Array1<f64>| -> f64 {
730        if n_scen < 2 {
731            return 1.0; // fallback
732        }
733        // Only use a subset for efficiency
734        let n_pairs = n_scen.min(20);
735        let mut max_lip = 0.0_f64;
736        for i in 0..n_pairs {
737            for j in (i + 1)..n_pairs.min(i + 5) {
738                let fi = loss_fn(&x_cur.view(), &scenarios[i].view());
739                let fj = loss_fn(&x_cur.view(), &scenarios[j].view());
740                let dist: f64 = if config.p_order == 1 {
741                    scenarios[i]
742                        .iter()
743                        .zip(scenarios[j].iter())
744                        .map(|(&a, &b)| (a - b).abs())
745                        .sum()
746                } else {
747                    scenarios[i]
748                        .iter()
749                        .zip(scenarios[j].iter())
750                        .map(|(&a, &b)| (a - b).powi(2))
751                        .sum::<f64>()
752                        .sqrt()
753                };
754                if dist > 1e-12 {
755                    max_lip = max_lip.max((fi - fj).abs() / dist);
756                }
757            }
758        }
759        max_lip.max(1e-3) // ensure positive
760    };
761
762    // DRO objective: (1/N) Σ_i f(x, ξ_i) + ε * L(x)
763    // + regularization λ * ‖x‖² to control Lipschitz
764    let dro_objective = |x_cur: &Array1<f64>| -> f64 {
765        let empirical: f64 = scenarios
766            .iter()
767            .map(|xi| loss_fn(&x_cur.view(), &xi.view()))
768            .sum::<f64>()
769            / n_scen as f64;
770        let lip = estimate_lipschitz(x_cur);
771        let reg: f64 = x_cur.iter().map(|xi| xi * xi).sum::<f64>();
772        empirical + config.epsilon * lip + config.lambda * 1e-4 * reg
773    };
774
775    for _ in 0..config.max_iter {
776        let f0 = dro_objective(&x);
777
778        // Finite-difference gradient
779        let mut grad = Array1::<f64>::zeros(n);
780        let mut x_fwd = x.clone();
781        for j in 0..n {
782            x_fwd[j] += h;
783            let f_fwd = dro_objective(&x_fwd);
784            grad[j] = (f_fwd - f0) / h;
785            x_fwd[j] = x[j];
786        }
787
788        let gn = l2_norm_arr1(&grad);
789        if gn < config.tol {
790            converged = true;
791            break;
792        }
793
794        for j in 0..n {
795            x[j] -= config.step_size * grad[j];
796        }
797    }
798
799    let empirical_loss: f64 = scenarios
800        .iter()
801        .map(|xi| loss_fn(&x.view(), &xi.view()))
802        .sum::<f64>()
803        / n_scen as f64;
804    let lip = estimate_lipschitz(&x);
805    let worst_case_loss = empirical_loss + config.epsilon * lip;
806
807    Ok(WassersteinDROResult {
808        x,
809        worst_case_loss,
810        empirical_loss,
811        lipschitz_estimate: lip,
812        n_iter: config.max_iter,
813        converged,
814        message: if converged {
815            "Wasserstein DRO converged".to_string()
816        } else {
817            "Wasserstein DRO reached maximum iterations".to_string()
818        },
819    })
820}
821
822// ─── Internal helpers ─────────────────────────────────────────────────────────
823
824fn l2_norm_arr1(v: &Array1<f64>) -> f64 {
825    v.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
826}
827
828fn l2_norm_arr2(m: &Array2<f64>) -> f64 {
829    m.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
830}
831
832// ─── Tests ────────────────────────────────────────────────────────────────────
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use scirs2_core::ndarray::array;
838
839    fn quadratic_loss(x: &ArrayView1<f64>, xi: &ArrayView1<f64>) -> f64 {
840        (x[0] - xi[0]).powi(2)
841    }
842
843    fn make_uniform_scenarios(n: usize) -> Vec<Array1<f64>> {
844        // Deterministic quasi-uniform grid on [0, 1]
845        (0..n)
846            .map(|i| array![(i as f64 + 0.5) / n as f64])
847            .collect()
848    }
849
850    #[test]
851    fn test_worst_case_analysis_basic() {
852        let x = array![0.5];
853        let scenarios = make_uniform_scenarios(10);
854        let obj_fn = |x: &ArrayView1<f64>, xi: &ArrayView1<f64>| (x[0] - xi[0]).powi(2);
855        let constraints: &[fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64] = &[];
856        let result = worst_case_analysis(&obj_fn, &x.view(), &scenarios, constraints)
857            .expect("failed to create result");
858
859        assert_eq!(result.n_scenarios, 10);
860        assert!(result.worst_obj >= result.best_obj);
861        assert!(result.avg_obj >= result.best_obj);
862        assert!((result.feasibility_rate - 1.0).abs() < 1e-9);
863    }
864
865    #[test]
866    fn test_worst_case_analysis_with_constraints() {
867        let x = array![0.5];
868        let scenarios = make_uniform_scenarios(5);
869        let obj_fn = |_x: &ArrayView1<f64>, xi: &ArrayView1<f64>| xi[0];
870        // Constraint: ξ ≤ 0.5 → only first half feasible
871        let constraints = vec![|_x: &ArrayView1<f64>, xi: &ArrayView1<f64>| xi[0] - 0.5];
872        let result = worst_case_analysis(&obj_fn, &x.view(), &scenarios, &constraints)
873            .expect("failed to create result");
874        // Scenarios: 0.1, 0.3, 0.5, 0.7, 0.9 → 0.5 border, > 0.5 infeasible
875        assert!(result.feasibility_rate <= 1.0);
876        assert!(result.feasibility_rate >= 0.0);
877    }
878
879    #[test]
880    fn test_scenario_approach_basic() {
881        // min_x x² s.t. (x - ξ)² ≤ 1 for all ξ
882        let obj_fn = |x: &ArrayView1<f64>| x[0].powi(2);
883        // Constraint: (x - ξ)² - 1 ≤ 0
884        let constraint_fn =
885            |x: &ArrayView1<f64>, xi: &ArrayView1<f64>| (x[0] - xi[0]).powi(2) - 1.0;
886
887        let mut idx = 0_usize;
888        let grid: Vec<f64> = (0..50).map(|i| (i as f64) / 49.0).collect();
889        let mut sample_fn = || {
890            let v = grid[idx % grid.len()];
891            idx += 1;
892            array![v]
893        };
894
895        let x0 = array![2.0];
896        let config = ScenarioApproachConfig {
897            n_scenarios: 50,
898            confidence: 0.9,
899            max_iter: 1_000,
900            tol: 1e-4,
901            step_size: 1e-2,
902            ..Default::default()
903        };
904        let result =
905            scenario_approach(&obj_fn, &constraint_fn, &mut sample_fn, &x0.view(), &config)
906                .expect("unexpected None or Err");
907
908        assert!(result.x[0].abs() <= 3.0, "x* should be bounded");
909        assert!(result.feasibility_guarantee >= 0.0);
910        assert!(result.feasibility_guarantee <= 1.0);
911    }
912
913    #[test]
914    fn test_distributionally_robust_basic() {
915        // min_x E[(x - ξ)²] under Wasserstein ball; optimum near E[ξ]
916        let scenarios = make_uniform_scenarios(20);
917        let x0 = array![0.0];
918        let config = WassersteinDROConfig {
919            epsilon: 0.05,
920            p_order: 1,
921            lambda: 1.0,
922            max_iter: 1_000,
923            tol: 1e-4,
924            step_size: 1e-2,
925            ..Default::default()
926        };
927        let result = distributionally_robust(&quadratic_loss, &x0.view(), &scenarios, &config)
928            .expect("unexpected None or Err");
929
930        // DRO minimizer should be in [0, 1]
931        assert!(
932            result.x[0] >= -0.5 && result.x[0] <= 1.5,
933            "DRO minimizer {} should be near [0,1]",
934            result.x[0]
935        );
936        assert!(result.worst_case_loss >= 0.0);
937    }
938
939    #[test]
940    fn test_affinely_adjustable_basic() {
941        // Simple AARC: obj(x, y, ξ) = (x + y - ξ)²
942        // With affine policy y(ξ) = L ξ + m, optimal L=1, m=0 → y(ξ)=ξ → obj=x²
943        let obj_fn = |x: &ArrayView1<f64>, y: &ArrayView1<f64>, xi: &ArrayView1<f64>| {
944            (x[0] + y[0] - xi[0]).powi(2)
945        };
946        let xi_samples = make_uniform_scenarios(10);
947        let x0 = array![0.0];
948        let config = AARCConfig {
949            recourse_dim: 1,
950            max_iter: 200,
951            tol: 1e-4,
952            step_size: 1e-3,
953            ..Default::default()
954        };
955        let result = affinely_adjustable(&obj_fn, &x0.view(), &xi_samples, 1, &config)
956            .expect("failed to create result");
957        assert!(result.worst_obj >= 0.0);
958        assert_eq!(result.k_matrix.shape(), [1, 1]);
959        assert_eq!(result.l_matrix.shape(), [1, 1]);
960    }
961
962    #[test]
963    fn test_worst_case_empty_scenarios() {
964        let x = array![0.0];
965        let empty: Vec<Array1<f64>> = vec![];
966        let obj = |_x: &ArrayView1<f64>, _xi: &ArrayView1<f64>| 0.0;
967        let constraints: &[fn(&ArrayView1<f64>, &ArrayView1<f64>) -> f64] = &[];
968        assert!(worst_case_analysis(&obj, &x.view(), &empty, constraints).is_err());
969    }
970
971    #[test]
972    fn test_distributionally_robust_empty_scenarios() {
973        let x0 = array![0.0];
974        let config = WassersteinDROConfig::default();
975        let empty: Vec<Array1<f64>> = vec![];
976        assert!(distributionally_robust(&quadratic_loss, &x0.view(), &empty, &config).is_err());
977    }
978}