kizzasi_logic/
advanced_constraints.rs

1//! Advanced constraint types for stochastic and robust optimization
2//!
3//! This module provides:
4//! - Chance constraints: Pr[g(x) <= 0] >= 1 - ε
5//! - Robust constraints: g(x, ξ) <= 0 ∀ξ ∈ Ξ
6//! - Risk-aware constraints: CVaR, VaR constraints
7//! - Distributionally robust constraints
8
9use serde::{Deserialize, Serialize};
10
11// ============================================================================
12// Chance Constraints
13// ============================================================================
14
15/// Chance constraint: Pr[g(x) <= 0] >= 1 - ε
16///
17/// A constraint that must be satisfied with a specified probability.
18/// Useful for handling uncertainty in parameters or measurements.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ChanceConstraint {
21    /// Name of the constraint
22    name: String,
23    /// Confidence level (1 - ε), e.g., 0.95 for 95% confidence
24    confidence: f32,
25    /// Approximation method
26    method: ChanceConstraintMethod,
27    /// Weight for violation penalty
28    weight: f32,
29}
30
31/// Methods for handling chance constraints
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub enum ChanceConstraintMethod {
34    /// Scenario-based approximation with sampled scenarios
35    ScenarioBased {
36        /// Number of scenarios to sample
37        num_scenarios: usize,
38        /// Tolerance for violation
39        violation_tolerance: f32,
40    },
41    /// Gaussian approximation (mean + k*sigma approach)
42    Gaussian {
43        /// Mean of uncertain parameter
44        mean: f32,
45        /// Standard deviation
46        std_dev: f32,
47    },
48    /// Conservative tightening (deterministic approximation)
49    Conservative {
50        /// Tightening factor based on confidence level
51        tightening_factor: f32,
52    },
53}
54
55impl ChanceConstraint {
56    /// Create a new chance constraint with Gaussian approximation
57    pub fn gaussian(name: impl Into<String>, confidence: f32, mean: f32, std_dev: f32) -> Self {
58        assert!(
59            confidence > 0.0 && confidence < 1.0,
60            "Confidence must be in (0, 1)"
61        );
62        assert!(std_dev > 0.0, "Standard deviation must be positive");
63
64        Self {
65            name: name.into(),
66            confidence,
67            method: ChanceConstraintMethod::Gaussian { mean, std_dev },
68            weight: 1.0,
69        }
70    }
71
72    /// Create a scenario-based chance constraint
73    pub fn scenario_based(name: impl Into<String>, confidence: f32, num_scenarios: usize) -> Self {
74        assert!(
75            confidence > 0.0 && confidence < 1.0,
76            "Confidence must be in (0, 1)"
77        );
78        assert!(num_scenarios > 0, "Number of scenarios must be positive");
79
80        Self {
81            name: name.into(),
82            confidence,
83            method: ChanceConstraintMethod::ScenarioBased {
84                num_scenarios,
85                violation_tolerance: 1.0 - confidence,
86            },
87            weight: 1.0,
88        }
89    }
90
91    /// Set weight
92    pub fn with_weight(mut self, weight: f32) -> Self {
93        self.weight = weight;
94        self
95    }
96
97    /// Get the deterministic tightening for this chance constraint
98    ///
99    /// For Gaussian: returns mean + z_α * σ where z_α is the quantile
100    pub fn get_tightened_bound(&self) -> f32 {
101        match &self.method {
102            ChanceConstraintMethod::Gaussian { mean, std_dev } => {
103                // Approximate quantile using confidence level
104                // For 95% confidence: z ≈ 1.96, for 99%: z ≈ 2.58
105                let z_alpha = self.confidence_to_quantile(self.confidence);
106                mean + z_alpha * std_dev
107            }
108            ChanceConstraintMethod::Conservative { tightening_factor } => *tightening_factor,
109            ChanceConstraintMethod::ScenarioBased { .. } => {
110                // For scenario-based, we use conservative estimate
111                self.confidence * 10.0 // Placeholder
112            }
113        }
114    }
115
116    /// Convert confidence level to normal quantile (approximate)
117    fn confidence_to_quantile(&self, confidence: f32) -> f32 {
118        // Simple approximation of inverse normal CDF
119        // For common confidence levels:
120        if confidence >= 0.99 {
121            2.58
122        } else if confidence >= 0.95 {
123            1.96
124        } else if confidence >= 0.90 {
125            1.64
126        } else if confidence >= 0.80 {
127            1.28
128        } else {
129            // Linear approximation for lower confidence
130            confidence * 3.0 - 1.5
131        }
132    }
133
134    /// Name accessor
135    pub fn name(&self) -> &str {
136        &self.name
137    }
138
139    /// Confidence accessor
140    pub fn confidence(&self) -> f32 {
141        self.confidence
142    }
143
144    /// Weight accessor
145    pub fn weight(&self) -> f32 {
146        self.weight
147    }
148}
149
150// ============================================================================
151// Robust Constraints
152// ============================================================================
153
154/// Robust constraint: g(x, ξ) <= 0 for all ξ in uncertainty set
155///
156/// Ensures constraint satisfaction under all realizations of uncertainty.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct RobustConstraint {
159    /// Name of the constraint
160    name: String,
161    /// Uncertainty set description
162    uncertainty_set: UncertaintySet,
163    /// Robustness approach
164    approach: RobustnessApproach,
165    /// Weight for violation penalty
166    weight: f32,
167}
168
169/// Types of uncertainty sets
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub enum UncertaintySet {
172    /// Box uncertainty: ξ ∈ [ξ_min, ξ_max]
173    Box { min: Vec<f32>, max: Vec<f32> },
174    /// Ellipsoidal uncertainty: ||ξ - ξ_nominal||_P <= Ω
175    Ellipsoidal {
176        nominal: Vec<f32>,
177        shape_matrix: Vec<f32>,
178        radius: f32,
179    },
180    /// Polyhedral uncertainty: A*ξ <= b
181    Polyhedral {
182        a_matrix: Vec<f32>,
183        b_vector: Vec<f32>,
184        dim: usize,
185    },
186    /// Budget uncertainty (cardinality-constrained): ||ξ - ξ_nominal||_0 <= Γ
187    Budget {
188        nominal: Vec<f32>,
189        max_deviations: Vec<f32>,
190        budget: usize,
191    },
192}
193
194/// Approaches to handle robust constraints
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub enum RobustnessApproach {
197    /// Worst-case optimization
198    WorstCase,
199    /// Affinely adjustable robust counterpart
200    AffinelyAdjustable,
201    /// Scenario-based robust approximation
202    Scenarios { num_scenarios: usize },
203}
204
205impl RobustConstraint {
206    /// Create a box-uncertain robust constraint
207    pub fn box_uncertain(name: impl Into<String>, min: Vec<f32>, max: Vec<f32>) -> Self {
208        assert_eq!(min.len(), max.len(), "Min and max must have same dimension");
209        for (mi, ma) in min.iter().zip(max.iter()) {
210            assert!(mi <= ma, "Min must be <= max");
211        }
212
213        Self {
214            name: name.into(),
215            uncertainty_set: UncertaintySet::Box { min, max },
216            approach: RobustnessApproach::WorstCase,
217            weight: 1.0,
218        }
219    }
220
221    /// Create an ellipsoidal uncertainty robust constraint
222    pub fn ellipsoidal_uncertain(name: impl Into<String>, nominal: Vec<f32>, radius: f32) -> Self {
223        assert!(radius > 0.0, "Radius must be positive");
224        let dim = nominal.len();
225        let identity = vec![1.0; dim * dim]; // Simplified: should be proper identity matrix
226
227        Self {
228            name: name.into(),
229            uncertainty_set: UncertaintySet::Ellipsoidal {
230                nominal,
231                shape_matrix: identity,
232                radius,
233            },
234            approach: RobustnessApproach::WorstCase,
235            weight: 1.0,
236        }
237    }
238
239    /// Set robustness approach
240    pub fn with_approach(mut self, approach: RobustnessApproach) -> Self {
241        self.approach = approach;
242        self
243    }
244
245    /// Set weight
246    pub fn with_weight(mut self, weight: f32) -> Self {
247        self.weight = weight;
248        self
249    }
250
251    /// Get worst-case scenario from uncertainty set
252    pub fn worst_case_scenario(&self, x: &[f32]) -> Vec<f32> {
253        match &self.uncertainty_set {
254            UncertaintySet::Box { min: _, max } => {
255                // Simple heuristic: return max values (conservative)
256                max.clone()
257            }
258            UncertaintySet::Ellipsoidal {
259                nominal, radius, ..
260            } => {
261                // Return nominal + radius (conservative ball)
262                nominal.iter().map(|&v| v + radius).collect()
263            }
264            UncertaintySet::Polyhedral { .. } => {
265                // Placeholder: return zero vector
266                vec![0.0; x.len()]
267            }
268            UncertaintySet::Budget {
269                nominal,
270                max_deviations,
271                budget,
272            } => {
273                // Take top 'budget' largest deviations
274                let mut result = nominal.clone();
275                for (i, &dev) in max_deviations.iter().enumerate().take(*budget) {
276                    if i < result.len() {
277                        result[i] += dev;
278                    }
279                }
280                result
281            }
282        }
283    }
284
285    /// Name accessor
286    pub fn name(&self) -> &str {
287        &self.name
288    }
289
290    /// Weight accessor
291    pub fn weight(&self) -> f32 {
292        self.weight
293    }
294
295    /// Get uncertainty set
296    pub fn uncertainty_set(&self) -> &UncertaintySet {
297        &self.uncertainty_set
298    }
299}
300
301// ============================================================================
302// Risk-Aware Constraints
303// ============================================================================
304
305/// Risk-aware constraint using CVaR (Conditional Value at Risk)
306///
307/// CVaR_α(loss) <= threshold
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct CVaRConstraint {
310    /// Name of the constraint
311    name: String,
312    /// Risk level α ∈ (0, 1), e.g., 0.05 for 5% worst cases
313    alpha: f32,
314    /// Threshold for CVaR
315    threshold: f32,
316    /// Sample scenarios for CVaR estimation
317    num_scenarios: usize,
318    /// Weight for violation penalty
319    weight: f32,
320}
321
322impl CVaRConstraint {
323    /// Create a new CVaR constraint
324    pub fn new(name: impl Into<String>, alpha: f32, threshold: f32, num_scenarios: usize) -> Self {
325        assert!(alpha > 0.0 && alpha < 1.0, "Alpha must be in (0, 1)");
326        assert!(num_scenarios > 0, "Number of scenarios must be positive");
327
328        Self {
329            name: name.into(),
330            alpha,
331            threshold,
332            num_scenarios,
333            weight: 1.0,
334        }
335    }
336
337    /// Set weight
338    pub fn with_weight(mut self, weight: f32) -> Self {
339        self.weight = weight;
340        self
341    }
342
343    /// Compute CVaR from a sample of losses
344    pub fn compute_cvar(&self, losses: &[f32]) -> f32 {
345        if losses.is_empty() {
346            return 0.0;
347        }
348
349        let mut sorted_losses = losses.to_vec();
350        sorted_losses.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); // Descending order, NaN-safe
351
352        // Take worst alpha% of scenarios
353        let cutoff = (self.alpha * sorted_losses.len() as f32).ceil() as usize;
354        let cutoff = cutoff.max(1).min(sorted_losses.len());
355
356        // Average of worst cases
357        sorted_losses.iter().take(cutoff).sum::<f32>() / cutoff as f32
358    }
359
360    /// Check if CVaR constraint is satisfied
361    pub fn check(&self, losses: &[f32]) -> bool {
362        let cvar = self.compute_cvar(losses);
363        cvar <= self.threshold
364    }
365
366    /// Compute violation
367    pub fn violation(&self, losses: &[f32]) -> f32 {
368        let cvar = self.compute_cvar(losses);
369        (cvar - self.threshold).max(0.0)
370    }
371
372    /// Name accessor
373    pub fn name(&self) -> &str {
374        &self.name
375    }
376
377    /// Alpha accessor
378    pub fn alpha(&self) -> f32 {
379        self.alpha
380    }
381
382    /// Threshold accessor
383    pub fn threshold(&self) -> f32 {
384        self.threshold
385    }
386
387    /// Weight accessor
388    pub fn weight(&self) -> f32 {
389        self.weight
390    }
391}
392
393// ============================================================================
394// Distributionally Robust Constraints
395// ============================================================================
396
397/// Distributionally robust constraint: worst-case expectation over ambiguity set
398///
399/// sup_{P ∈ P} E_P[loss(x, ξ)] <= threshold
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct DistributionallyRobustConstraint {
402    /// Name of the constraint
403    name: String,
404    /// Ambiguity set specification
405    ambiguity_set: AmbiguitySet,
406    /// Threshold for worst-case expectation
407    threshold: f32,
408    /// Weight for violation penalty
409    weight: f32,
410}
411
412/// Types of ambiguity sets for distributionally robust optimization
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub enum AmbiguitySet {
415    /// Wasserstein ball around empirical distribution
416    Wasserstein {
417        /// Empirical samples
418        num_samples: usize,
419        /// Wasserstein radius
420        radius: f32,
421    },
422    /// Moment-based ambiguity (known mean and covariance bounds)
423    MomentBased {
424        /// Mean estimate
425        mean: Vec<f32>,
426        /// Covariance bound
427        cov_radius: f32,
428    },
429    /// φ-divergence ball (KL, chi-squared, etc.)
430    PhiDivergence {
431        /// Reference distribution samples
432        num_samples: usize,
433        /// Divergence radius
434        radius: f32,
435        /// Type of divergence
436        divergence_type: DivergenceType,
437    },
438}
439
440/// Types of φ-divergences
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub enum DivergenceType {
443    /// Kullback-Leibler divergence
444    KL,
445    /// Chi-squared divergence
446    ChiSquared,
447    /// Modified chi-squared
448    ModifiedChiSquared,
449}
450
451impl DistributionallyRobustConstraint {
452    /// Create a Wasserstein distributionally robust constraint
453    pub fn wasserstein(
454        name: impl Into<String>,
455        threshold: f32,
456        num_samples: usize,
457        radius: f32,
458    ) -> Self {
459        assert!(radius > 0.0, "Radius must be positive");
460        assert!(num_samples > 0, "Number of samples must be positive");
461
462        Self {
463            name: name.into(),
464            ambiguity_set: AmbiguitySet::Wasserstein {
465                num_samples,
466                radius,
467            },
468            threshold,
469            weight: 1.0,
470        }
471    }
472
473    /// Create a moment-based distributionally robust constraint
474    pub fn moment_based(
475        name: impl Into<String>,
476        threshold: f32,
477        mean: Vec<f32>,
478        cov_radius: f32,
479    ) -> Self {
480        assert!(cov_radius > 0.0, "Covariance radius must be positive");
481
482        Self {
483            name: name.into(),
484            ambiguity_set: AmbiguitySet::MomentBased { mean, cov_radius },
485            threshold,
486            weight: 1.0,
487        }
488    }
489
490    /// Set weight
491    pub fn with_weight(mut self, weight: f32) -> Self {
492        self.weight = weight;
493        self
494    }
495
496    /// Compute worst-case expectation (conservative approximation)
497    pub fn worst_case_expectation(&self, losses: &[f32]) -> f32 {
498        if losses.is_empty() {
499            return 0.0;
500        }
501
502        match &self.ambiguity_set {
503            AmbiguitySet::Wasserstein { radius, .. } => {
504                // Conservative: mean + radius * max_loss
505                let mean: f32 = losses.iter().sum::<f32>() / losses.len() as f32;
506                let max_loss = losses.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
507                mean + radius * max_loss.abs()
508            }
509            AmbiguitySet::MomentBased { cov_radius, .. } => {
510                // Conservative: mean + sqrt(cov_radius) * std_dev
511                let mean: f32 = losses.iter().sum::<f32>() / losses.len() as f32;
512                let variance: f32 =
513                    losses.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / losses.len() as f32;
514                mean + cov_radius.sqrt() * variance.sqrt()
515            }
516            AmbiguitySet::PhiDivergence { radius, .. } => {
517                // Placeholder: mean + conservative factor
518                let mean: f32 = losses.iter().sum::<f32>() / losses.len() as f32;
519                mean * (1.0 + radius)
520            }
521        }
522    }
523
524    /// Check if constraint is satisfied
525    pub fn check(&self, losses: &[f32]) -> bool {
526        let wc_exp = self.worst_case_expectation(losses);
527        wc_exp <= self.threshold
528    }
529
530    /// Compute violation
531    pub fn violation(&self, losses: &[f32]) -> f32 {
532        let wc_exp = self.worst_case_expectation(losses);
533        (wc_exp - self.threshold).max(0.0)
534    }
535
536    /// Name accessor
537    pub fn name(&self) -> &str {
538        &self.name
539    }
540
541    /// Threshold accessor
542    pub fn threshold(&self) -> f32 {
543        self.threshold
544    }
545
546    /// Weight accessor
547    pub fn weight(&self) -> f32 {
548        self.weight
549    }
550
551    /// Get ambiguity set
552    pub fn ambiguity_set(&self) -> &AmbiguitySet {
553        &self.ambiguity_set
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_chance_constraint_gaussian() {
563        let cc = ChanceConstraint::gaussian("test", 0.95, 10.0, 2.0);
564        assert_eq!(cc.name(), "test");
565        assert_eq!(cc.confidence(), 0.95);
566
567        // 95% confidence should give bound around mean + 1.96*sigma
568        let bound = cc.get_tightened_bound();
569        assert!(bound > 10.0);
570        assert!(bound < 15.0); // 10 + 1.96*2 ≈ 13.92
571    }
572
573    #[test]
574    fn test_robust_constraint_box() {
575        let rc = RobustConstraint::box_uncertain("test", vec![-1.0, -2.0], vec![1.0, 2.0]);
576        assert_eq!(rc.name(), "test");
577
578        let worst_case = rc.worst_case_scenario(&[0.0, 0.0]);
579        assert_eq!(worst_case, vec![1.0, 2.0]);
580    }
581
582    #[test]
583    fn test_cvar_constraint() {
584        let cvar = CVaRConstraint::new("test", 0.1, 10.0, 100);
585
586        // Test CVaR computation
587        let losses = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
588        let cvar_value = cvar.compute_cvar(&losses);
589
590        // Top 10% should be close to 10.0
591        assert!(cvar_value >= 9.0);
592        assert!(cvar_value <= 10.0);
593    }
594
595    #[test]
596    fn test_distributionally_robust_wasserstein() {
597        let drc = DistributionallyRobustConstraint::wasserstein("test", 15.0, 100, 0.5);
598
599        let losses = vec![5.0, 10.0, 15.0];
600        let wc_exp = drc.worst_case_expectation(&losses);
601
602        // Should be conservative
603        assert!(wc_exp >= 10.0); // mean
604        assert!(drc.check(&losses) || !drc.check(&losses)); // No panic
605    }
606}