1use crate::{Constraint, LogicError, LogicResult};
36use scirs2_core::ndarray::Array1;
37
38#[derive(Debug, Clone)]
40pub struct SensitivityAnalysis {
41 pub shadow_prices: Vec<f32>,
44
45 pub slacks: Vec<f32>,
48
49 pub tight_constraints: Vec<bool>,
51
52 pub critical_constraints: Vec<usize>,
54
55 pub activity_levels: Vec<f32>,
57
58 pub perturbation_sensitivity: Vec<f32>,
60
61 pub importance_ranking: Vec<usize>,
63}
64
65impl SensitivityAnalysis {
66 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 pub fn most_critical(&self) -> Option<usize> {
81 self.importance_ranking.first().copied()
82 }
83
84 pub fn tight_count(&self) -> usize {
86 self.tight_constraints.iter().filter(|&&t| t).count()
87 }
88
89 pub fn total_slack(&self) -> f32 {
91 self.slacks.iter().sum()
92 }
93
94 pub fn min_slack(&self) -> f32 {
96 self.slacks.iter().copied().fold(f32::INFINITY, f32::min)
97 }
98
99 pub fn is_critical(&self, index: usize) -> bool {
101 self.critical_constraints.contains(&index)
102 }
103}
104
105pub struct ConstraintSensitivityAnalyzer {
109 tightness_tolerance: f32,
111
112 perturbation_step: f32,
114
115 perturbation_samples: usize,
117
118 critical_threshold: f32,
120}
121
122impl Default for ConstraintSensitivityAnalyzer {
123 fn default() -> Self {
124 Self::new()
125 }
126}
127
128impl ConstraintSensitivityAnalyzer {
129 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 pub fn with_tightness_tolerance(mut self, tol: f32) -> Self {
141 self.tightness_tolerance = tol;
142 self
143 }
144
145 pub fn with_perturbation_step(mut self, step: f32) -> Self {
147 self.perturbation_step = step;
148 self
149 }
150
151 pub fn with_perturbation_samples(mut self, samples: usize) -> Self {
153 self.perturbation_samples = samples;
154 self
155 }
156
157 pub fn with_critical_threshold(mut self, threshold: f32) -> Self {
159 self.critical_threshold = threshold;
160 self
161 }
162
163 pub fn analyze(&self, constraints: &[Constraint], point: &Array1<f32>) -> SensitivityAnalysis {
165 let n = constraints.len();
166 let mut analysis = SensitivityAnalysis::new(n);
167
168 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 analysis.activity_levels[i] = self.compute_activity(slack);
176 }
177
178 analysis.shadow_prices = self.estimate_shadow_prices(constraints, point);
180
181 analysis.perturbation_sensitivity =
183 self.compute_perturbation_sensitivity(constraints, point);
184
185 analysis.critical_constraints = self.identify_critical_constraints(&analysis);
187
188 analysis.importance_ranking = self.rank_constraints(&analysis);
190
191 analysis
192 }
193
194 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 violation > 0.0 {
210 -violation
211 } else {
212 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 fn compute_activity(&self, slack: f32) -> f32 {
235 if slack < 0.0 {
236 1.0
238 } else if slack < self.tightness_tolerance {
239 1.0 - (slack / self.tightness_tolerance)
241 } else {
242 (1.0 / (1.0 + slack)).min(1.0)
244 }
245 }
246
247 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 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 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_prices[i] = sensitivity.abs();
272 }
273 }
274 }
275
276 shadow_prices
277 }
278
279 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 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 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 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 sensitivities[i] = 0.0;
326 }
327 }
328 }
329
330 sensitivities
331 }
332
333 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 if activity > self.critical_threshold || (is_tight && has_high_sensitivity) {
346 critical.push(i);
347 }
348 }
349
350 critical
351 }
352
353 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 let activity = analysis.activity_levels[i];
360 let shadow = analysis.shadow_prices[i];
361 let perturbation = analysis.perturbation_sensitivity[i];
362
363 let score = activity * 0.5 + shadow * 0.3 + perturbation * 0.2;
365 (i, score)
366 })
367 .collect();
368
369 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 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 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 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 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 pub fn robustness_margin(&self, constraints: &[Constraint], point: &Array1<f32>) -> f32 {
435 if constraints.is_empty() {
436 return f32::INFINITY;
437 }
438
439 constraints
441 .iter()
442 .map(|c| self.compute_slack(c, point))
443 .fold(f32::INFINITY, f32::min)
444 }
445}
446
447#[derive(Debug, Clone)]
449pub struct LocalSensitivity {
450 pub constraint_idx: usize,
452
453 pub base_slack: f32,
455
456 pub positive_changes: Vec<(f32, f32)>,
458
459 pub negative_changes: Vec<(f32, f32)>,
461}
462
463impl LocalSensitivity {
464 pub fn gradient_estimate(&self) -> f32 {
466 if self.positive_changes.is_empty() {
467 return 0.0;
468 }
469
470 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 pub fn is_linear(&self) -> bool {
488 let grad = self.gradient_estimate();
489 let tolerance = 0.01;
490
491 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 ); 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 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 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 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 assert!(grad.abs() < 2.0); }
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 ); }
707}