kizzasi_logic/
constraint_sensitivity.rs

1//! Constraint Sensitivity Analysis
2//!
3//! This module provides tools for analyzing how solutions are affected by constraints:
4//! - Shadow prices (dual values) for constraint relaxation
5//! - Constraint tightness and slack analysis
6//! - Perturbation analysis for robustness
7//! - Critical constraint identification
8//!
9//! # Examples
10//!
11//! ```
12//! use kizzasi_logic::{ConstraintBuilder, ConstraintSensitivityAnalyzer};
13//! use scirs2_core::ndarray::Array1;
14//!
15//! // Create constraints
16//! let c1 = ConstraintBuilder::new()
17//!     .name("upper_bound")
18//!     .less_than(10.0)
19//!     .build()
20//!     .unwrap();
21//!
22//! // Analyze sensitivity at a solution point
23//! let analyzer = ConstraintSensitivityAnalyzer::new();
24//! let point = Array1::from_vec(vec![8.0]);
25//! let analysis = analyzer.analyze(&[c1], &point);
26//!
27//! // Check which constraints are tight (nearly violated)
28//! for (idx, &is_tight) in analysis.tight_constraints.iter().enumerate() {
29//!     if is_tight {
30//!         println!("Constraint {} is tight", idx);
31//!     }
32//! }
33//! ```
34
35use crate::{Constraint, LogicError, LogicResult};
36use scirs2_core::ndarray::Array1;
37
38/// Result of constraint sensitivity analysis
39#[derive(Debug, Clone)]
40pub struct SensitivityAnalysis {
41    /// Shadow prices (dual values) for each constraint
42    /// Indicates how much the objective would improve if constraint i were relaxed by one unit
43    pub shadow_prices: Vec<f32>,
44
45    /// Slack for each constraint (distance to violation)
46    /// Positive means satisfied with margin, negative means violated
47    pub slacks: Vec<f32>,
48
49    /// Whether each constraint is "tight" (close to being violated)
50    pub tight_constraints: Vec<bool>,
51
52    /// Critical constraints (tight and sensitive)
53    pub critical_constraints: Vec<usize>,
54
55    /// Constraint activity levels (0.0 = not active, 1.0 = fully active/tight)
56    pub activity_levels: Vec<f32>,
57
58    /// Perturbation sensitivity: how much solution would change if constraint i changes
59    pub perturbation_sensitivity: Vec<f32>,
60
61    /// Ranking of constraints by importance (most critical first)
62    pub importance_ranking: Vec<usize>,
63}
64
65impl SensitivityAnalysis {
66    /// Create a new sensitivity analysis result
67    pub fn new(num_constraints: usize) -> Self {
68        Self {
69            shadow_prices: vec![0.0; num_constraints],
70            slacks: vec![0.0; num_constraints],
71            tight_constraints: vec![false; num_constraints],
72            critical_constraints: Vec::new(),
73            activity_levels: vec![0.0; num_constraints],
74            perturbation_sensitivity: vec![0.0; num_constraints],
75            importance_ranking: Vec::new(),
76        }
77    }
78
79    /// Get the most critical constraint index
80    pub fn most_critical(&self) -> Option<usize> {
81        self.importance_ranking.first().copied()
82    }
83
84    /// Get the number of tight constraints
85    pub fn tight_count(&self) -> usize {
86        self.tight_constraints.iter().filter(|&&t| t).count()
87    }
88
89    /// Get the total slack across all constraints
90    pub fn total_slack(&self) -> f32 {
91        self.slacks.iter().sum()
92    }
93
94    /// Get the minimum slack (constraint closest to violation)
95    pub fn min_slack(&self) -> f32 {
96        self.slacks.iter().copied().fold(f32::INFINITY, f32::min)
97    }
98
99    /// Check if a specific constraint is critical
100    pub fn is_critical(&self, index: usize) -> bool {
101        self.critical_constraints.contains(&index)
102    }
103}
104
105/// Constraint Sensitivity Analyzer
106///
107/// Analyzes how solutions are affected by constraints and their perturbations.
108pub struct ConstraintSensitivityAnalyzer {
109    /// Tolerance for considering a constraint as "tight"
110    tightness_tolerance: f32,
111
112    /// Step size for finite difference approximation
113    perturbation_step: f32,
114
115    /// Number of samples for perturbation analysis
116    perturbation_samples: usize,
117
118    /// Minimum activity level to be considered critical
119    critical_threshold: f32,
120}
121
122impl Default for ConstraintSensitivityAnalyzer {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl ConstraintSensitivityAnalyzer {
129    /// Create a new sensitivity analyzer with default parameters
130    pub fn new() -> Self {
131        Self {
132            tightness_tolerance: 0.1,
133            perturbation_step: 0.01,
134            perturbation_samples: 10,
135            critical_threshold: 0.8,
136        }
137    }
138
139    /// Set the tolerance for considering a constraint as tight
140    pub fn with_tightness_tolerance(mut self, tol: f32) -> Self {
141        self.tightness_tolerance = tol;
142        self
143    }
144
145    /// Set the step size for perturbation analysis
146    pub fn with_perturbation_step(mut self, step: f32) -> Self {
147        self.perturbation_step = step;
148        self
149    }
150
151    /// Set the number of samples for perturbation analysis
152    pub fn with_perturbation_samples(mut self, samples: usize) -> Self {
153        self.perturbation_samples = samples;
154        self
155    }
156
157    /// Set the threshold for critical constraints
158    pub fn with_critical_threshold(mut self, threshold: f32) -> Self {
159        self.critical_threshold = threshold;
160        self
161    }
162
163    /// Analyze constraints at a given solution point
164    pub fn analyze(&self, constraints: &[Constraint], point: &Array1<f32>) -> SensitivityAnalysis {
165        let n = constraints.len();
166        let mut analysis = SensitivityAnalysis::new(n);
167
168        // Compute slacks and identify tight constraints
169        for (i, constraint) in constraints.iter().enumerate() {
170            let slack = self.compute_slack(constraint, point);
171            analysis.slacks[i] = slack;
172            analysis.tight_constraints[i] = slack.abs() < self.tightness_tolerance;
173
174            // Activity level: 0.0 for large slack, 1.0 for tight
175            analysis.activity_levels[i] = self.compute_activity(slack);
176        }
177
178        // Estimate shadow prices using finite differences
179        analysis.shadow_prices = self.estimate_shadow_prices(constraints, point);
180
181        // Compute perturbation sensitivity
182        analysis.perturbation_sensitivity =
183            self.compute_perturbation_sensitivity(constraints, point);
184
185        // Identify critical constraints
186        analysis.critical_constraints = self.identify_critical_constraints(&analysis);
187
188        // Rank constraints by importance
189        analysis.importance_ranking = self.rank_constraints(&analysis);
190
191        analysis
192    }
193
194    /// Compute slack for a constraint at a point
195    /// Positive slack means satisfied with margin, negative means violated
196    ///
197    /// For x < b: slack = b - x (positive when satisfied)
198    /// For x > b: slack = x - b (positive when satisfied)
199    fn compute_slack(&self, constraint: &Constraint, point: &Array1<f32>) -> f32 {
200        if let Some(dim) = constraint.dimension() {
201            if dim < point.len() {
202                let value = point[dim];
203                let violation = constraint.violation(value);
204
205                // If violated, slack is negative of violation
206                // If satisfied, we need to compute the distance to boundary
207                // We'll use: slack = -violation when violated
208                // For satisfied: we compute based on boundary
209                if violation > 0.0 {
210                    -violation
211                } else {
212                    // Satisfied: compute distance to boundary
213                    // Project to boundary and measure distance
214                    let boundary = constraint.project(value + 1000.0);
215                    (boundary - value).abs()
216                }
217            } else {
218                f32::NEG_INFINITY
219            }
220        } else {
221            let value = point[0];
222            let violation = constraint.violation(value);
223
224            if violation > 0.0 {
225                -violation
226            } else {
227                let boundary = constraint.project(value + 1000.0);
228                (boundary - value).abs()
229            }
230        }
231    }
232
233    /// Compute activity level from slack (0.0 = inactive, 1.0 = tight)
234    fn compute_activity(&self, slack: f32) -> f32 {
235        if slack < 0.0 {
236            // Violated: fully active
237            1.0
238        } else if slack < self.tightness_tolerance {
239            // Tight: high activity
240            1.0 - (slack / self.tightness_tolerance)
241        } else {
242            // Not tight: activity decreases with slack
243            (1.0 / (1.0 + slack)).min(1.0)
244        }
245    }
246
247    /// Estimate shadow prices using finite differences
248    fn estimate_shadow_prices(&self, constraints: &[Constraint], point: &Array1<f32>) -> Vec<f32> {
249        let n = constraints.len();
250        let mut shadow_prices = vec![0.0; n];
251
252        // For each constraint, estimate the shadow price
253        // Shadow price ≈ change in objective / change in constraint bound
254        // We approximate this by measuring constraint violation sensitivity
255        for i in 0..n {
256            let constraint = &constraints[i];
257
258            if let Some(dim) = constraint.dimension() {
259                if dim < point.len() {
260                    let base_violation = constraint.violation(point[dim]);
261
262                    // Perturb the point slightly and measure change
263                    let mut perturbed_point = point.clone();
264                    perturbed_point[dim] += self.perturbation_step;
265
266                    let perturbed_violation = constraint.violation(perturbed_point[dim]);
267                    let sensitivity =
268                        (perturbed_violation - base_violation) / self.perturbation_step;
269
270                    // Shadow price is related to the constraint sensitivity
271                    shadow_prices[i] = sensitivity.abs();
272                }
273            }
274        }
275
276        shadow_prices
277    }
278
279    /// Compute perturbation sensitivity for each constraint
280    fn compute_perturbation_sensitivity(
281        &self,
282        constraints: &[Constraint],
283        point: &Array1<f32>,
284    ) -> Vec<f32> {
285        let n = constraints.len();
286        let mut sensitivities = vec![0.0; n];
287
288        for i in 0..n {
289            let constraint = &constraints[i];
290
291            if let Some(dim) = constraint.dimension() {
292                if dim >= point.len() {
293                    continue;
294                }
295
296                let base_value = point[dim];
297                let base_satisfied = constraint.check(base_value);
298
299                // Test sensitivity by perturbing in both directions
300                let mut sensitivity_sum = 0.0;
301                let mut count = 0;
302
303                for j in 1..=self.perturbation_samples {
304                    let delta = self.perturbation_step * j as f32;
305
306                    // Positive perturbation
307                    let pos_satisfied = constraint.check(base_value + delta);
308                    if pos_satisfied != base_satisfied {
309                        sensitivity_sum += 1.0 / delta;
310                        count += 1;
311                    }
312
313                    // Negative perturbation
314                    let neg_satisfied = constraint.check(base_value - delta);
315                    if neg_satisfied != base_satisfied {
316                        sensitivity_sum += 1.0 / delta;
317                        count += 1;
318                    }
319                }
320
321                if count > 0 {
322                    sensitivities[i] = sensitivity_sum / count as f32;
323                } else {
324                    // If no satisfaction changes, sensitivity is low
325                    sensitivities[i] = 0.0;
326                }
327            }
328        }
329
330        sensitivities
331    }
332
333    /// Identify critical constraints based on multiple criteria
334    fn identify_critical_constraints(&self, analysis: &SensitivityAnalysis) -> Vec<usize> {
335        let mut critical = Vec::new();
336
337        for i in 0..analysis.activity_levels.len() {
338            let activity = analysis.activity_levels[i];
339            let is_tight = analysis.tight_constraints[i];
340            let has_high_sensitivity = analysis.perturbation_sensitivity[i] > 0.1;
341
342            // A constraint is critical if:
343            // 1. It has high activity level, OR
344            // 2. It's tight AND has high perturbation sensitivity
345            if activity > self.critical_threshold || (is_tight && has_high_sensitivity) {
346                critical.push(i);
347            }
348        }
349
350        critical
351    }
352
353    /// Rank constraints by importance (most important first)
354    fn rank_constraints(&self, analysis: &SensitivityAnalysis) -> Vec<usize> {
355        let n = analysis.activity_levels.len();
356        let mut rankings: Vec<(usize, f32)> = (0..n)
357            .map(|i| {
358                // Importance score combines multiple factors
359                let activity = analysis.activity_levels[i];
360                let shadow = analysis.shadow_prices[i];
361                let perturbation = analysis.perturbation_sensitivity[i];
362
363                // Weighted combination
364                let score = activity * 0.5 + shadow * 0.3 + perturbation * 0.2;
365                (i, score)
366            })
367            .collect();
368
369        // Sort by score (descending)
370        rankings.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
371
372        rankings.into_iter().map(|(idx, _)| idx).collect()
373    }
374
375    /// Perform local sensitivity analysis by varying constraints
376    pub fn local_sensitivity(
377        &self,
378        constraints: &[Constraint],
379        point: &Array1<f32>,
380        constraint_idx: usize,
381    ) -> LogicResult<LocalSensitivity> {
382        if constraint_idx >= constraints.len() {
383            return Err(LogicError::InvalidInput(format!(
384                "Constraint index {} out of bounds (max {})",
385                constraint_idx,
386                constraints.len() - 1
387            )));
388        }
389
390        let constraint = &constraints[constraint_idx];
391        let dim = constraint.dimension().ok_or_else(|| {
392            LogicError::InvalidInput("Constraint has no dimension specified".to_string())
393        })?;
394
395        if dim >= point.len() {
396            return Err(LogicError::DimensionMismatch {
397                expected: dim + 1,
398                got: point.len(),
399            });
400        }
401
402        let base_value = point[dim];
403        let base_slack = self.compute_slack(constraint, point);
404
405        // Compute sensitivity in both directions
406        let mut positive_changes = Vec::new();
407        let mut negative_changes = Vec::new();
408
409        for i in 1..=self.perturbation_samples {
410            let delta = self.perturbation_step * i as f32;
411
412            // Positive direction
413            let mut pos_point = point.clone();
414            pos_point[dim] = base_value + delta;
415            let pos_slack = self.compute_slack(constraint, &pos_point);
416            positive_changes.push((delta, pos_slack - base_slack));
417
418            // Negative direction
419            let mut neg_point = point.clone();
420            neg_point[dim] = base_value - delta;
421            let neg_slack = self.compute_slack(constraint, &neg_point);
422            negative_changes.push((-delta, neg_slack - base_slack));
423        }
424
425        Ok(LocalSensitivity {
426            constraint_idx,
427            base_slack,
428            positive_changes,
429            negative_changes,
430        })
431    }
432
433    /// Compute robustness margin: how much all constraints can be perturbed simultaneously
434    pub fn robustness_margin(&self, constraints: &[Constraint], point: &Array1<f32>) -> f32 {
435        if constraints.is_empty() {
436            return f32::INFINITY;
437        }
438
439        // Minimum slack is the robustness margin
440        constraints
441            .iter()
442            .map(|c| self.compute_slack(c, point))
443            .fold(f32::INFINITY, f32::min)
444    }
445}
446
447/// Local sensitivity analysis result for a single constraint
448#[derive(Debug, Clone)]
449pub struct LocalSensitivity {
450    /// Index of the constraint being analyzed
451    pub constraint_idx: usize,
452
453    /// Base slack value at the solution point
454    pub base_slack: f32,
455
456    /// Changes in slack for positive perturbations: (delta, slack_change)
457    pub positive_changes: Vec<(f32, f32)>,
458
459    /// Changes in slack for negative perturbations: (delta, slack_change)
460    pub negative_changes: Vec<(f32, f32)>,
461}
462
463impl LocalSensitivity {
464    /// Estimate the gradient of slack with respect to the variable
465    pub fn gradient_estimate(&self) -> f32 {
466        if self.positive_changes.is_empty() {
467            return 0.0;
468        }
469
470        // Use linear regression or simple average of slopes
471        let mut slopes = Vec::new();
472        for (delta, change) in &self.positive_changes {
473            slopes.push(change / delta);
474        }
475        for (delta, change) in &self.negative_changes {
476            slopes.push(change / delta);
477        }
478
479        if slopes.is_empty() {
480            0.0
481        } else {
482            slopes.iter().sum::<f32>() / slopes.len() as f32
483        }
484    }
485
486    /// Check if the constraint exhibits linear behavior
487    pub fn is_linear(&self) -> bool {
488        let grad = self.gradient_estimate();
489        let tolerance = 0.01;
490
491        // Check if all slopes are similar to the estimated gradient
492        for (delta, change) in &self.positive_changes {
493            let slope = change / delta;
494            if (slope - grad).abs() > tolerance {
495                return false;
496            }
497        }
498
499        for (delta, change) in &self.negative_changes {
500            let slope = change / delta;
501            if (slope - grad).abs() > tolerance {
502                return false;
503            }
504        }
505
506        true
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::ConstraintBuilder;
514
515    #[test]
516    fn test_sensitivity_analysis_basic() {
517        let c1 = ConstraintBuilder::new()
518            .name("upper")
519            .dimension(0)
520            .less_than(10.0)
521            .build()
522            .unwrap();
523
524        let analyzer = ConstraintSensitivityAnalyzer::new();
525        let point = Array1::from_vec(vec![8.0]);
526        let analysis = analyzer.analyze(&[c1], &point);
527
528        assert_eq!(analysis.slacks.len(), 1);
529        assert!(
530            analysis.slacks[0] > 0.0,
531            "Slack should be positive: {}",
532            analysis.slacks[0]
533        ); // Should have positive slack
534        assert_eq!(analysis.shadow_prices.len(), 1);
535    }
536
537    #[test]
538    fn test_tight_constraint_detection() {
539        let c1 = ConstraintBuilder::new()
540            .name("upper")
541            .dimension(0)
542            .less_than(10.0)
543            .build()
544            .unwrap();
545
546        let analyzer = ConstraintSensitivityAnalyzer::new().with_tightness_tolerance(0.5);
547
548        // Point very close to boundary
549        let point = Array1::from_vec(vec![9.95]);
550        let analysis = analyzer.analyze(&[c1], &point);
551
552        assert!(analysis.tight_constraints[0], "Constraint should be tight");
553        assert_eq!(analysis.tight_count(), 1);
554    }
555
556    #[test]
557    fn test_critical_constraint_identification() {
558        let c1 = ConstraintBuilder::new()
559            .name("tight_constraint")
560            .dimension(0)
561            .less_than(10.0)
562            .build()
563            .unwrap();
564
565        let c2 = ConstraintBuilder::new()
566            .name("loose_constraint")
567            .dimension(0)
568            .less_than(100.0)
569            .build()
570            .unwrap();
571
572        let analyzer = ConstraintSensitivityAnalyzer::new();
573        let point = Array1::from_vec(vec![9.5]);
574        let analysis = analyzer.analyze(&[c1, c2], &point);
575
576        // First constraint should be more critical (closer to boundary)
577        assert!(
578            analysis.activity_levels[0] > analysis.activity_levels[1],
579            "Activity levels: c1={}, c2={}",
580            analysis.activity_levels[0],
581            analysis.activity_levels[1]
582        );
583    }
584
585    #[test]
586    fn test_importance_ranking() {
587        let c1 = ConstraintBuilder::new()
588            .name("c1")
589            .dimension(0)
590            .less_than(10.0)
591            .build()
592            .unwrap();
593
594        let c2 = ConstraintBuilder::new()
595            .name("c2")
596            .dimension(0)
597            .greater_than(0.0)
598            .build()
599            .unwrap();
600
601        let analyzer = ConstraintSensitivityAnalyzer::new();
602        let point = Array1::from_vec(vec![9.0]);
603        let analysis = analyzer.analyze(&[c1, c2], &point);
604
605        assert_eq!(analysis.importance_ranking.len(), 2);
606        assert!(analysis.importance_ranking.contains(&0));
607        assert!(analysis.importance_ranking.contains(&1));
608    }
609
610    #[test]
611    fn test_local_sensitivity() {
612        let c1 = ConstraintBuilder::new()
613            .name("test")
614            .dimension(0)
615            .less_than(10.0)
616            .build()
617            .unwrap();
618
619        let analyzer = ConstraintSensitivityAnalyzer::new();
620        let point = Array1::from_vec(vec![5.0]);
621
622        let local = analyzer.local_sensitivity(&[c1], &point, 0).unwrap();
623
624        assert_eq!(local.constraint_idx, 0);
625        assert!(local.base_slack > 0.0);
626        assert!(!local.positive_changes.is_empty());
627        assert!(!local.negative_changes.is_empty());
628    }
629
630    #[test]
631    fn test_robustness_margin() {
632        let c1 = ConstraintBuilder::new()
633            .name("c1")
634            .dimension(0)
635            .less_than(10.0)
636            .build()
637            .unwrap();
638
639        let c2 = ConstraintBuilder::new()
640            .name("c2")
641            .dimension(0)
642            .greater_than(0.0)
643            .build()
644            .unwrap();
645
646        let analyzer = ConstraintSensitivityAnalyzer::new();
647
648        // Point at [5.0] has slack 5.0 for both constraints
649        let point = Array1::from_vec(vec![5.0]);
650        let margin = analyzer.robustness_margin(&[c1, c2], &point);
651
652        assert!(margin > 0.0, "Margin should be positive: {}", margin);
653        assert!(margin <= 5.0, "Margin should be at most 5.0: {}", margin);
654    }
655
656    #[test]
657    fn test_gradient_estimate() {
658        let c1 = ConstraintBuilder::new()
659            .name("linear")
660            .dimension(0)
661            .less_than(10.0)
662            .build()
663            .unwrap();
664
665        let analyzer = ConstraintSensitivityAnalyzer::new();
666        let point = Array1::from_vec(vec![5.0]);
667
668        let local = analyzer.local_sensitivity(&[c1], &point, 0).unwrap();
669        let grad = local.gradient_estimate();
670
671        // For a linear constraint x < 10, gradient should be approximately -1
672        assert!(grad.abs() < 2.0); // Reasonable range for numerical approximation
673    }
674
675    #[test]
676    fn test_min_slack() {
677        let c1 = ConstraintBuilder::new()
678            .name("c1")
679            .dimension(0)
680            .less_than(10.0)
681            .build()
682            .unwrap();
683
684        let c2 = ConstraintBuilder::new()
685            .name("c2")
686            .dimension(0)
687            .less_than(100.0)
688            .build()
689            .unwrap();
690
691        let analyzer = ConstraintSensitivityAnalyzer::new();
692        let point = Array1::from_vec(vec![8.0]);
693        let analysis = analyzer.analyze(&[c1, c2], &point);
694
695        let min_slack = analysis.min_slack();
696        assert!(
697            min_slack > 0.0,
698            "Min slack should be positive: {}",
699            min_slack
700        );
701        assert!(
702            min_slack <= 3.0,
703            "Min slack should be at most 3.0 (was {})",
704            min_slack
705        ); // Slack for c1 should be ~2.0
706    }
707}