scirs2_optimize/constrained/feasibility_rules.rs
1//! Feasibility-rules and constraint-handling strategies for evolutionary
2//! optimization.
3//!
4//! Provides deterministic and stochastic rules for comparing candidate
5//! solutions in the presence of constraints — without relying on explicit
6//! penalty functions.
7//!
8//! # Strategies
9//!
10//! | Strategy | Description |
11//! |----------|-------------|
12//! | [`FeasibilityRule`] | Deb's feasibility tournament rule (2002) |
13//! | [`EpsilonFeasibility`] | ε-constrained feasibility (Takahama & Sakai 2006) |
14//! | [`StochasticRanking`] | Stochastic ranking (Runarsson & Yao 2000) |
15//! | [`AdaptiveFeasibility`] | Adaptive feasibility pressure with generation control |
16//!
17//! # References
18//!
19//! - Deb, K. (2000). An efficient constraint handling method for genetic
20//! algorithms. *Computer Methods in Applied Mechanics and Engineering*,
21//! 186(2–4), 311–338.
22//! - Takahama, T. & Sakai, S. (2006). Constrained optimization by the ε
23//! constrained differential evolution with gradient-based mutation and
24//! feasible elites. *IEEE CEC 2006*.
25//! - Runarsson, T.P. & Yao, X. (2000). Stochastic ranking for constrained
26//! evolutionary optimization. *IEEE Transactions on Evolutionary
27//! Computation*, 4(3), 284–294.
28
29use crate::error::{OptimizeError, OptimizeResult};
30
31// ─────────────────────────────────────────────────────────────────────────────
32// Constraint violation summary
33// ─────────────────────────────────────────────────────────────────────────────
34
35/// Summary of constraint violations for a single candidate solution.
36///
37/// Violations are computed as `max(0, g_i(x))` for inequality constraints
38/// (`g_i(x) <= 0`) and `|h_j(x)|` for equality constraints.
39#[derive(Debug, Clone, PartialEq)]
40pub struct ViolationSummary {
41 /// L1 sum of constraint violations.
42 pub total_violation: f64,
43 /// Maximum single-constraint violation.
44 pub max_violation: f64,
45 /// Number of violated constraints.
46 pub n_violated: usize,
47 /// Per-constraint violations (non-negative).
48 pub violations: Vec<f64>,
49}
50
51impl ViolationSummary {
52 /// Create a `ViolationSummary` from a vector of violation amounts.
53 ///
54 /// # Arguments
55 /// * `violations` — Non-negative violation per constraint.
56 /// For inequality `g_i(x) <= 0`: pass `max(0, g_i(x))`.
57 /// For equality `h_j(x) = 0`: pass `|h_j(x)|`.
58 pub fn new(violations: Vec<f64>) -> Self {
59 let total: f64 = violations.iter().sum();
60 let max = violations.iter().cloned().fold(0.0_f64, f64::max);
61 let n_violated = violations.iter().filter(|&&v| v > 0.0).count();
62 Self {
63 total_violation: total,
64 max_violation: max,
65 n_violated,
66 violations,
67 }
68 }
69
70 /// Returns `true` if the solution is strictly feasible (all violations zero).
71 pub fn is_feasible(&self) -> bool {
72 self.total_violation == 0.0
73 }
74
75 /// Returns `true` if the solution is approximately feasible within `tol`.
76 pub fn is_approximately_feasible(&self, tol: f64) -> bool {
77 self.total_violation <= tol
78 }
79
80 /// Compute the L2 (sum-of-squares) violation norm.
81 pub fn l2_norm(&self) -> f64 {
82 self.violations
83 .iter()
84 .map(|v| v.powi(2))
85 .sum::<f64>()
86 .sqrt()
87 }
88}
89
90// ─────────────────────────────────────────────────────────────────────────────
91// Deb's Feasibility Tournament Rule
92// ─────────────────────────────────────────────────────────────────────────────
93
94/// Deb's feasibility tournament rule for comparing candidate solutions.
95///
96/// The rule defines a total order on solutions:
97/// 1. A **feasible** solution always beats an **infeasible** one.
98/// 2. Among two feasible solutions, the one with the better objective wins.
99/// 3. Among two infeasible solutions, the one with the smaller total
100/// constraint violation wins.
101///
102/// # References
103/// Deb, K. (2000). An efficient constraint handling method for genetic
104/// algorithms. *Computer Methods in Applied Mechanics and Engineering*, 186.
105#[derive(Debug, Clone, Copy, Default)]
106pub struct FeasibilityRule {
107 /// Feasibility tolerance: violations smaller than this are treated as zero.
108 pub feasibility_tol: f64,
109}
110
111impl FeasibilityRule {
112 /// Create a new feasibility rule.
113 pub fn new(feasibility_tol: f64) -> Self {
114 Self { feasibility_tol }
115 }
116
117 /// Compare two solutions `a` and `b` according to Deb's tournament rule.
118 ///
119 /// Returns `std::cmp::Ordering::Less` if `a` is preferred,
120 /// `Equal` if they are equivalent, and `Greater` if `b` is preferred.
121 ///
122 /// # Arguments
123 /// * `f_a`, `f_b` — Objective values (minimisation).
124 /// * `viol_a`, `viol_b` — Constraint violations.
125 pub fn compare(
126 &self,
127 f_a: f64,
128 viol_a: &ViolationSummary,
129 f_b: f64,
130 viol_b: &ViolationSummary,
131 ) -> std::cmp::Ordering {
132 let feasible_a = viol_a.total_violation <= self.feasibility_tol;
133 let feasible_b = viol_b.total_violation <= self.feasibility_tol;
134
135 match (feasible_a, feasible_b) {
136 (true, false) => std::cmp::Ordering::Less,
137 (false, true) => std::cmp::Ordering::Greater,
138 (true, true) => f_a.partial_cmp(&f_b).unwrap_or(std::cmp::Ordering::Equal),
139 (false, false) => viol_a
140 .total_violation
141 .partial_cmp(&viol_b.total_violation)
142 .unwrap_or(std::cmp::Ordering::Equal),
143 }
144 }
145
146 /// Sort a list of `(objective, violation)` pairs in-place using the
147 /// feasibility rule (best first).
148 ///
149 /// Returns the sorted indices into the original slice.
150 pub fn sort_population(&self, population: &[(f64, ViolationSummary)]) -> Vec<usize> {
151 let mut indices: Vec<usize> = (0..population.len()).collect();
152 indices.sort_by(|&a, &b| {
153 self.compare(
154 population[a].0,
155 &population[a].1,
156 population[b].0,
157 &population[b].1,
158 )
159 });
160 indices
161 }
162}
163
164// ─────────────────────────────────────────────────────────────────────────────
165// ε-Constrained Feasibility (Takahama & Sakai)
166// ─────────────────────────────────────────────────────────────────────────────
167
168/// ε-constrained feasibility — a relaxed version of Deb's rule that treats
169/// solutions with violation ≤ ε as "feasible" for comparison purposes.
170///
171/// The ε threshold is adapted over generations: it starts at a user-specified
172/// `epsilon_0` and decreases to 0 following a schedule, gradually tightening
173/// the feasibility criterion.
174///
175/// # References
176/// Takahama, T. & Sakai, S. (2006). Constrained optimization by the ε
177/// constrained differential evolution with gradient-based mutation and
178/// feasible elites. *IEEE CEC 2006*.
179#[derive(Debug, Clone)]
180pub struct EpsilonFeasibility {
181 /// Current ε threshold.
182 epsilon: f64,
183 /// Initial ε (at generation 0).
184 epsilon_0: f64,
185 /// Generation at which ε reaches 0.
186 t_c: usize,
187 /// Control parameter (typically 0.1–0.5 for CP schedule, or exponent for
188 /// the exponential schedule).
189 cp: f64,
190 /// Schedule type for ε decay.
191 schedule: EpsilonSchedule,
192 /// Current generation counter.
193 generation: usize,
194}
195
196/// Decay schedule for the ε threshold in [`EpsilonFeasibility`].
197#[derive(Debug, Clone, Copy, PartialEq)]
198pub enum EpsilonSchedule {
199 /// Linear decay: `ε(t) = ε_0 * max(0, 1 - t/t_c)`.
200 Linear,
201 /// Exponential decay: `ε(t) = ε_0 * exp(-cp * t)`.
202 Exponential,
203 /// Power-law decay: `ε(t) = ε_0 * (1 - t/t_c)^cp`.
204 PowerLaw,
205}
206
207impl EpsilonFeasibility {
208 /// Create a new ε-feasibility handler.
209 ///
210 /// # Arguments
211 /// * `epsilon_0` — Initial ε threshold (must be ≥ 0).
212 /// * `t_c` — Generation at which ε → 0 (must be > 0).
213 /// * `cp` — Control parameter for the schedule.
214 /// * `schedule` — Decay schedule.
215 pub fn new(
216 epsilon_0: f64,
217 t_c: usize,
218 cp: f64,
219 schedule: EpsilonSchedule,
220 ) -> OptimizeResult<Self> {
221 if epsilon_0 < 0.0 {
222 return Err(OptimizeError::InvalidInput(
223 "epsilon_0 must be >= 0".to_string(),
224 ));
225 }
226 if t_c == 0 {
227 return Err(OptimizeError::InvalidInput("t_c must be > 0".to_string()));
228 }
229 Ok(Self {
230 epsilon: epsilon_0,
231 epsilon_0,
232 t_c,
233 cp,
234 schedule,
235 generation: 0,
236 })
237 }
238
239 /// Current ε threshold.
240 pub fn epsilon(&self) -> f64 {
241 self.epsilon
242 }
243
244 /// Advance to the next generation and update ε accordingly.
245 pub fn step(&mut self) {
246 self.generation += 1;
247 self.epsilon = self.compute_epsilon(self.generation);
248 }
249
250 /// Compute the ε value for an arbitrary generation `t`.
251 fn compute_epsilon(&self, t: usize) -> f64 {
252 if t >= self.t_c {
253 return 0.0;
254 }
255 let ratio = t as f64 / self.t_c as f64;
256 match self.schedule {
257 EpsilonSchedule::Linear => self.epsilon_0 * (1.0 - ratio),
258 EpsilonSchedule::Exponential => self.epsilon_0 * (-self.cp * t as f64).exp(),
259 EpsilonSchedule::PowerLaw => self.epsilon_0 * (1.0 - ratio).powf(self.cp),
260 }
261 }
262
263 /// Compare two solutions using the ε-constrained rule.
264 ///
265 /// A solution with violation ≤ ε is treated as "feasible" for this
266 /// comparison. The rule is otherwise identical to [`FeasibilityRule`].
267 pub fn compare(
268 &self,
269 f_a: f64,
270 viol_a: &ViolationSummary,
271 f_b: f64,
272 viol_b: &ViolationSummary,
273 ) -> std::cmp::Ordering {
274 let eps_feasible_a = viol_a.total_violation <= self.epsilon;
275 let eps_feasible_b = viol_b.total_violation <= self.epsilon;
276
277 match (eps_feasible_a, eps_feasible_b) {
278 (true, false) => std::cmp::Ordering::Less,
279 (false, true) => std::cmp::Ordering::Greater,
280 (true, true) => f_a.partial_cmp(&f_b).unwrap_or(std::cmp::Ordering::Equal),
281 (false, false) => viol_a
282 .total_violation
283 .partial_cmp(&viol_b.total_violation)
284 .unwrap_or(std::cmp::Ordering::Equal),
285 }
286 }
287}
288
289// ─────────────────────────────────────────────────────────────────────────────
290// Stochastic Ranking (Runarsson & Yao 2000)
291// ─────────────────────────────────────────────────────────────────────────────
292
293/// Stochastic ranking for constrained evolutionary optimization.
294///
295/// A bubble-sort-based ranking that probabilistically compares solutions.
296/// With probability `p_f`, feasible-feasible comparisons are based on
297/// the objective; with probability `1 - p_f`, the violation is used.
298/// Infeasible-infeasible comparisons always use the violation.
299///
300/// This balances exploration of infeasible regions (large `p_f` → more weight
301/// on objectives even for infeasible solutions) against driving toward
302/// feasibility (small `p_f`). `p_f = 0.45` is a commonly recommended value.
303///
304/// # References
305/// Runarsson, T.P. & Yao, X. (2000). Stochastic ranking for constrained
306/// evolutionary optimization. *IEEE Transactions on Evolutionary Computation*,
307/// 4(3), 284–294.
308#[derive(Debug, Clone)]
309pub struct StochasticRanking {
310 /// Probability of using objective comparison between feasible-feasible pairs.
311 pub p_f: f64,
312 /// RNG seed for reproducibility.
313 seed: u64,
314}
315
316impl StochasticRanking {
317 /// Create a new stochastic ranking instance.
318 ///
319 /// # Arguments
320 /// * `p_f` — Probability of objective-based comparison for feasible
321 /// pairs. Typical range: 0.3–0.5.
322 /// * `seed` — RNG seed.
323 pub fn new(p_f: f64, seed: u64) -> OptimizeResult<Self> {
324 if !(0.0..=1.0).contains(&p_f) {
325 return Err(OptimizeError::InvalidInput(
326 "p_f must be in [0, 1]".to_string(),
327 ));
328 }
329 Ok(Self { p_f, seed })
330 }
331
332 /// Rank a population by stochastic ranking (bubble-sort variant).
333 ///
334 /// # Arguments
335 /// * `objectives` — Objective values (one per individual).
336 /// * `violations` — Constraint violation summaries.
337 /// * `n_passes` — Number of bubble-sort passes to perform.
338 ///
339 /// # Returns
340 /// Ranked indices (index 0 = best-ranked individual).
341 pub fn rank(
342 &self,
343 objectives: &[f64],
344 violations: &[ViolationSummary],
345 n_passes: usize,
346 ) -> OptimizeResult<Vec<usize>> {
347 let n = objectives.len();
348 if n != violations.len() {
349 return Err(OptimizeError::InvalidInput(format!(
350 "objectives.len()={n} must equal violations.len()={}",
351 violations.len()
352 )));
353 }
354 if n == 0 {
355 return Ok(vec![]);
356 }
357
358 let mut indices: Vec<usize> = (0..n).collect();
359 let mut rng_state = self.seed;
360
361 let n_bubble = n_passes.max(1);
362
363 for _ in 0..n_bubble {
364 for j in 0..(n - 1) {
365 let a = indices[j];
366 let b = indices[j + 1];
367
368 let fa = objectives[a];
369 let fb = objectives[b];
370 let va = &violations[a];
371 let vb = &violations[b];
372
373 // Pseudo-random value from LCG
374 rng_state = rng_state
375 .wrapping_mul(6364136223846793005)
376 .wrapping_add(1442695040888963407);
377 let u = (rng_state >> 33) as f64 / (u32::MAX as f64);
378
379 let should_swap = if va.is_feasible() && vb.is_feasible() {
380 // Both feasible: compare by objective with prob p_f
381 if u < self.p_f {
382 fa > fb // swap if a is worse
383 } else {
384 false
385 }
386 } else if !va.is_feasible() && !vb.is_feasible() {
387 // Both infeasible: always compare by violation
388 va.total_violation > vb.total_violation
389 } else {
390 // Mixed: feasible beats infeasible
391 !va.is_feasible() && vb.is_feasible()
392 };
393
394 if should_swap {
395 indices.swap(j, j + 1);
396 }
397 }
398 }
399
400 Ok(indices)
401 }
402}
403
404// ─────────────────────────────────────────────────────────────────────────────
405// Adaptive Feasibility Pressure
406// ─────────────────────────────────────────────────────────────────────────────
407
408/// Adaptive feasibility pressure that adjusts constraint-handling behavior
409/// based on the ratio of feasible to infeasible solutions in the population.
410///
411/// When the feasibility ratio is low, the comparison rule leans toward
412/// preferring solutions with smaller violations (exploration mode).
413/// As feasibility improves, it transitions toward objective-based comparison
414/// (exploitation mode).
415///
416/// This prevents premature convergence into infeasible regions while still
417/// allowing useful gradient information from near-feasible solutions.
418#[derive(Debug, Clone)]
419pub struct AdaptiveFeasibility {
420 /// Current feasibility ratio (fraction of feasible solutions in population).
421 feasibility_ratio: f64,
422 /// Target feasibility ratio (typically 0.5).
423 target_ratio: f64,
424 /// Learning rate for updating the feasibility ratio estimate.
425 alpha: f64,
426 /// Feasibility tolerance.
427 feasibility_tol: f64,
428}
429
430impl AdaptiveFeasibility {
431 /// Create a new adaptive feasibility handler.
432 ///
433 /// # Arguments
434 /// * `target_ratio` — Desired fraction of feasible solutions (0 < t ≤ 1).
435 /// * `alpha` — Update learning rate for the ratio estimate.
436 /// * `feasibility_tol` — Tolerance below which violations are treated as zero.
437 pub fn new(target_ratio: f64, alpha: f64, feasibility_tol: f64) -> OptimizeResult<Self> {
438 if !(0.0..=1.0).contains(&target_ratio) {
439 return Err(OptimizeError::InvalidInput(
440 "target_ratio must be in (0, 1]".to_string(),
441 ));
442 }
443 if !(0.0..=1.0).contains(&alpha) {
444 return Err(OptimizeError::InvalidInput(
445 "alpha must be in [0, 1]".to_string(),
446 ));
447 }
448 Ok(Self {
449 feasibility_ratio: target_ratio,
450 target_ratio,
451 alpha,
452 feasibility_tol,
453 })
454 }
455
456 /// Update the feasibility ratio estimate from the current population.
457 ///
458 /// # Arguments
459 /// * `violations` — Violation summaries for all population members.
460 pub fn update(&mut self, violations: &[ViolationSummary]) {
461 if violations.is_empty() {
462 return;
463 }
464 let n_feasible = violations
465 .iter()
466 .filter(|v| v.total_violation <= self.feasibility_tol)
467 .count();
468 let observed_ratio = n_feasible as f64 / violations.len() as f64;
469 // Exponential moving average update
470 self.feasibility_ratio =
471 (1.0 - self.alpha) * self.feasibility_ratio + self.alpha * observed_ratio;
472 }
473
474 /// Current estimated feasibility ratio.
475 pub fn feasibility_ratio(&self) -> f64 {
476 self.feasibility_ratio
477 }
478
479 /// Compute the effective penalty weight for constraint violations.
480 ///
481 /// When feasibility_ratio < target_ratio, penalty is amplified to drive
482 /// solutions toward feasibility. When ratio >= target_ratio, penalty
483 /// is reduced to emphasize objective improvement.
484 ///
485 /// The weight is: `base_penalty * (target / current)^2` if below target,
486 /// or `base_penalty * (current / target)^(-1)` if above.
487 pub fn effective_penalty_weight(&self, base_penalty: f64) -> f64 {
488 if self.feasibility_ratio <= 0.0 {
489 return base_penalty * 10.0;
490 }
491 let ratio = self.target_ratio / self.feasibility_ratio.max(1e-10);
492 if self.feasibility_ratio < self.target_ratio {
493 base_penalty * ratio.powi(2)
494 } else {
495 // Above target: reduce penalty
496 base_penalty / ratio
497 }
498 }
499
500 /// Compare two solutions using the adaptive feasibility-weighted rule.
501 ///
502 /// The effective comparison weight between objective and violation is
503 /// controlled by the current feasibility pressure.
504 pub fn compare(
505 &self,
506 f_a: f64,
507 viol_a: &ViolationSummary,
508 f_b: f64,
509 viol_b: &ViolationSummary,
510 ) -> std::cmp::Ordering {
511 // Use Deb's rule as base, but with adaptive tolerance
512 let feasible_a = viol_a.total_violation <= self.feasibility_tol;
513 let feasible_b = viol_b.total_violation <= self.feasibility_tol;
514
515 match (feasible_a, feasible_b) {
516 (true, false) => std::cmp::Ordering::Less,
517 (false, true) => std::cmp::Ordering::Greater,
518 (true, true) => f_a.partial_cmp(&f_b).unwrap_or(std::cmp::Ordering::Equal),
519 (false, false) => {
520 // Weighted combination: emphasize violation when ratio is low
521 let w = self.effective_penalty_weight(1.0);
522 let score_a = f_a + w * viol_a.total_violation;
523 let score_b = f_b + w * viol_b.total_violation;
524 score_a
525 .partial_cmp(&score_b)
526 .unwrap_or(std::cmp::Ordering::Equal)
527 }
528 }
529 }
530}
531
532// ─────────────────────────────────────────────────────────────────────────────
533// Utility: violation computation helpers
534// ─────────────────────────────────────────────────────────────────────────────
535
536/// Compute inequality constraint violations from a function slice.
537///
538/// For each `g_i(x) <= 0` constraint function in `g_fns`, computes
539/// `max(0, g_i(x))`.
540///
541/// # Arguments
542/// * `x` — Decision variable vector.
543/// * `g_fns` — Slice of inequality constraint functions `g_i: &[f64] -> f64`.
544/// Feasible: `g_i(x) <= 0`.
545///
546/// # Returns
547/// [`ViolationSummary`] with per-constraint violations.
548///
549/// # Examples
550/// ```
551/// use scirs2_optimize::constrained::feasibility_rules::ineq_violations;
552///
553/// // Constraint: x[0] + x[1] <= 2
554/// let g = |x: &[f64]| x[0] + x[1] - 2.0;
555/// let x = &[1.5_f64, 1.5_f64]; // violates: 1.5+1.5-2=1.0 > 0
556/// let v = ineq_violations(x, &[g]);
557/// assert!((v.total_violation - 1.0).abs() < 1e-10);
558/// ```
559pub fn ineq_violations<F>(x: &[f64], g_fns: &[F]) -> ViolationSummary
560where
561 F: Fn(&[f64]) -> f64,
562{
563 let violations: Vec<f64> = g_fns.iter().map(|g| g(x).max(0.0)).collect();
564 ViolationSummary::new(violations)
565}
566
567/// Compute equality constraint violations from a function slice.
568///
569/// For each `h_j(x) = 0` equality constraint, computes `|h_j(x)|`.
570///
571/// # Arguments
572/// * `x` — Decision variable vector.
573/// * `h_fns` — Slice of equality constraint functions `h_j: &[f64] -> f64`.
574/// Feasible: `h_j(x) = 0`.
575///
576/// # Returns
577/// [`ViolationSummary`] with per-constraint violations.
578pub fn eq_violations<F>(x: &[f64], h_fns: &[F]) -> ViolationSummary
579where
580 F: Fn(&[f64]) -> f64,
581{
582 let violations: Vec<f64> = h_fns.iter().map(|h| h(x).abs()).collect();
583 ViolationSummary::new(violations)
584}
585
586/// Combine inequality and equality violations into a single summary.
587///
588/// Concatenates the violation vectors from both and recomputes the summary.
589pub fn combined_violations<G, H>(x: &[f64], g_fns: &[G], h_fns: &[H]) -> ViolationSummary
590where
591 G: Fn(&[f64]) -> f64,
592 H: Fn(&[f64]) -> f64,
593{
594 let mut violations: Vec<f64> = g_fns.iter().map(|g| g(x).max(0.0)).collect();
595 violations.extend(h_fns.iter().map(|h| h(x).abs()));
596 ViolationSummary::new(violations)
597}
598
599// ─────────────────────────────────────────────────────────────────────────────
600// Tests
601// ─────────────────────────────────────────────────────────────────────────────
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 // ── ViolationSummary ──────────────────────────────────────────────────────
608
609 #[test]
610 fn test_violation_summary_feasible() {
611 let vs = ViolationSummary::new(vec![0.0, 0.0]);
612 assert!(vs.is_feasible());
613 assert_eq!(vs.total_violation, 0.0);
614 assert_eq!(vs.n_violated, 0);
615 }
616
617 #[test]
618 fn test_violation_summary_infeasible() {
619 let vs = ViolationSummary::new(vec![0.3, 0.0, 0.5]);
620 assert!(!vs.is_feasible());
621 assert!((vs.total_violation - 0.8).abs() < 1e-10);
622 assert!((vs.max_violation - 0.5).abs() < 1e-10);
623 assert_eq!(vs.n_violated, 2);
624 }
625
626 #[test]
627 fn test_violation_summary_l2_norm() {
628 let vs = ViolationSummary::new(vec![3.0, 4.0]);
629 assert!((vs.l2_norm() - 5.0).abs() < 1e-10);
630 }
631
632 #[test]
633 fn test_violation_summary_approximately_feasible() {
634 let vs = ViolationSummary::new(vec![0.05]);
635 assert!(vs.is_approximately_feasible(0.1));
636 assert!(!vs.is_approximately_feasible(0.01));
637 }
638
639 // ── FeasibilityRule ───────────────────────────────────────────────────────
640
641 #[test]
642 fn test_feasibility_rule_feasible_beats_infeasible() {
643 let rule = FeasibilityRule::new(1e-8);
644 let feas = ViolationSummary::new(vec![0.0]);
645 let infeas = ViolationSummary::new(vec![0.5]);
646 // feasible wins regardless of objective
647 assert_eq!(
648 rule.compare(100.0, &feas, 0.0, &infeas),
649 std::cmp::Ordering::Less
650 );
651 }
652
653 #[test]
654 fn test_feasibility_rule_both_feasible_compare_objective() {
655 let rule = FeasibilityRule::new(1e-8);
656 let feas = ViolationSummary::new(vec![0.0]);
657 assert_eq!(
658 rule.compare(1.0, &feas, 2.0, &feas),
659 std::cmp::Ordering::Less
660 );
661 assert_eq!(
662 rule.compare(2.0, &feas, 1.0, &feas),
663 std::cmp::Ordering::Greater
664 );
665 assert_eq!(
666 rule.compare(1.5, &feas, 1.5, &feas),
667 std::cmp::Ordering::Equal
668 );
669 }
670
671 #[test]
672 fn test_feasibility_rule_both_infeasible_compare_violation() {
673 let rule = FeasibilityRule::new(1e-8);
674 let v1 = ViolationSummary::new(vec![0.3]);
675 let v2 = ViolationSummary::new(vec![0.8]);
676 // v1 has smaller violation → wins (Less)
677 assert_eq!(rule.compare(0.0, &v1, 0.0, &v2), std::cmp::Ordering::Less);
678 }
679
680 #[test]
681 fn test_feasibility_rule_sort_population() {
682 let rule = FeasibilityRule::new(1e-8);
683 let pop = vec![
684 (5.0, ViolationSummary::new(vec![0.0])), // feasible, bad obj
685 (1.0, ViolationSummary::new(vec![1.0])), // infeasible, good obj
686 (2.0, ViolationSummary::new(vec![0.0])), // feasible, medium obj
687 ];
688 let sorted = rule.sort_population(&pop);
689 // Best first: feasible with obj=2.0 (idx 2), then feasible with obj=5.0 (idx 0), then infeasible (idx 1)
690 assert_eq!(sorted[0], 2);
691 assert_eq!(sorted[1], 0);
692 assert_eq!(sorted[2], 1);
693 }
694
695 // ── EpsilonFeasibility ────────────────────────────────────────────────────
696
697 #[test]
698 fn test_epsilon_feasibility_linear_decay() {
699 let mut ef = EpsilonFeasibility::new(1.0, 10, 1.0, EpsilonSchedule::Linear)
700 .expect("failed to create ef");
701 assert!((ef.epsilon() - 1.0).abs() < 1e-10);
702 ef.step(); // gen 1
703 assert!((ef.epsilon() - 0.9).abs() < 1e-10);
704 for _ in 0..9 {
705 ef.step();
706 }
707 assert_eq!(ef.epsilon(), 0.0);
708 }
709
710 #[test]
711 fn test_epsilon_feasibility_power_law_decay() {
712 let mut ef = EpsilonFeasibility::new(1.0, 100, 2.0, EpsilonSchedule::PowerLaw)
713 .expect("failed to create ef");
714 ef.step(); // gen 1: ε = 1 * (1 - 1/100)^2 ≈ 0.9801
715 assert!(ef.epsilon() > 0.97 && ef.epsilon() < 1.0);
716 }
717
718 #[test]
719 fn test_epsilon_feasibility_invalid_epsilon() {
720 let result = EpsilonFeasibility::new(-1.0, 10, 1.0, EpsilonSchedule::Linear);
721 assert!(result.is_err());
722 }
723
724 #[test]
725 fn test_epsilon_feasibility_invalid_tc() {
726 let result = EpsilonFeasibility::new(1.0, 0, 1.0, EpsilonSchedule::Linear);
727 assert!(result.is_err());
728 }
729
730 #[test]
731 fn test_epsilon_feasibility_compare_relaxed_feasibility() {
732 let ef = EpsilonFeasibility::new(0.5, 10, 1.0, EpsilonSchedule::Linear)
733 .expect("failed to create ef");
734 // Both have violation <= 0.5, so both treated as "feasible" → compare by objective
735 let v1 = ViolationSummary::new(vec![0.3]);
736 let v2 = ViolationSummary::new(vec![0.4]);
737 // f_a=1.0, f_b=2.0: a wins (Less)
738 assert_eq!(ef.compare(1.0, &v1, 2.0, &v2), std::cmp::Ordering::Less);
739 }
740
741 // ── StochasticRanking ─────────────────────────────────────────────────────
742
743 #[test]
744 fn test_stochastic_ranking_correct_length() {
745 let sr = StochasticRanking::new(0.45, 42).expect("failed to create sr");
746 let objectives = vec![1.0, 2.0, 3.0, 4.0];
747 let violations = vec![
748 ViolationSummary::new(vec![0.0]),
749 ViolationSummary::new(vec![0.5]),
750 ViolationSummary::new(vec![0.0]),
751 ViolationSummary::new(vec![0.2]),
752 ];
753 let ranked = sr
754 .rank(&objectives, &violations, 5)
755 .expect("failed to create ranked");
756 assert_eq!(ranked.len(), 4);
757 // All indices should be present
758 let mut sorted_ranked = ranked.clone();
759 sorted_ranked.sort_unstable();
760 assert_eq!(sorted_ranked, vec![0, 1, 2, 3]);
761 }
762
763 #[test]
764 fn test_stochastic_ranking_feasible_prefers_better_obj() {
765 // With enough passes, feasible better-objective solution should rank first
766 let sr = StochasticRanking::new(0.45, 42).expect("failed to create sr");
767 let objectives = vec![2.0, 1.0]; // idx 1 has better objective
768 let violations = vec![
769 ViolationSummary::new(vec![0.0]), // feasible
770 ViolationSummary::new(vec![0.0]), // feasible
771 ];
772 let ranked = sr
773 .rank(&objectives, &violations, 50)
774 .expect("failed to create ranked");
775 assert_eq!(ranked[0], 1, "better objective should rank first");
776 }
777
778 #[test]
779 fn test_stochastic_ranking_invalid_p_f() {
780 let result = StochasticRanking::new(1.5, 42);
781 assert!(result.is_err());
782 }
783
784 #[test]
785 fn test_stochastic_ranking_mismatch_lengths() {
786 let sr = StochasticRanking::new(0.45, 42).expect("failed to create sr");
787 let result = sr.rank(&[1.0, 2.0], &[ViolationSummary::new(vec![0.0])], 5);
788 assert!(result.is_err());
789 }
790
791 // ── AdaptiveFeasibility ───────────────────────────────────────────────────
792
793 #[test]
794 fn test_adaptive_feasibility_initial_ratio() {
795 let af = AdaptiveFeasibility::new(0.5, 0.1, 1e-8).expect("failed to create af");
796 assert!((af.feasibility_ratio() - 0.5).abs() < 1e-10);
797 }
798
799 #[test]
800 fn test_adaptive_feasibility_update_all_feasible() {
801 let mut af = AdaptiveFeasibility::new(0.5, 0.5, 1e-8).expect("failed to create af");
802 let violations = vec![
803 ViolationSummary::new(vec![0.0]),
804 ViolationSummary::new(vec![0.0]),
805 ];
806 af.update(&violations);
807 // After update: ratio should move toward 1.0
808 assert!(af.feasibility_ratio() > 0.5);
809 }
810
811 #[test]
812 fn test_adaptive_feasibility_penalty_amplified_when_low() {
813 // When all solutions are infeasible, ratio → 0, penalty should increase
814 let mut af = AdaptiveFeasibility::new(0.5, 0.9, 1e-8).expect("failed to create af");
815 let violations: Vec<ViolationSummary> = vec![
816 ViolationSummary::new(vec![1.0]),
817 ViolationSummary::new(vec![2.0]),
818 ];
819 af.update(&violations);
820 let weight = af.effective_penalty_weight(1.0);
821 assert!(
822 weight > 1.0,
823 "penalty should be amplified when feasibility is low"
824 );
825 }
826
827 #[test]
828 fn test_adaptive_feasibility_compare() {
829 let af = AdaptiveFeasibility::new(0.5, 0.1, 1e-8).expect("failed to create af");
830 let feas = ViolationSummary::new(vec![0.0]);
831 let infeas = ViolationSummary::new(vec![1.0]);
832 assert_eq!(
833 af.compare(100.0, &feas, 0.0, &infeas),
834 std::cmp::Ordering::Less
835 );
836 }
837
838 // ── violation helpers ─────────────────────────────────────────────────────
839
840 #[test]
841 fn test_ineq_violations_satisfied() {
842 let x = &[0.5_f64];
843 let g = |x: &[f64]| x[0] - 1.0; // x[0] <= 1 (g <= 0)
844 let v = ineq_violations(x, &[g]);
845 assert_eq!(v.total_violation, 0.0);
846 }
847
848 #[test]
849 fn test_ineq_violations_violated() {
850 let x = &[1.5_f64];
851 let g = |x: &[f64]| x[0] - 1.0; // x[0] <= 1
852 let v = ineq_violations(x, &[g]);
853 assert!((v.total_violation - 0.5).abs() < 1e-10);
854 }
855
856 #[test]
857 fn test_eq_violations() {
858 let x = &[2.0_f64];
859 let h = |x: &[f64]| x[0] - 3.0; // h(x) = x - 3 should be 0
860 let v = eq_violations(x, &[h]);
861 assert!((v.total_violation - 1.0).abs() < 1e-10);
862 }
863
864 #[test]
865 fn test_combined_violations() {
866 let x = &[1.5_f64, 2.0_f64];
867 let g = |x: &[f64]| x[0] - 1.0; // violated by 0.5
868 let h = |x: &[f64]| x[1] - 2.0; // satisfied (|0| = 0)
869 let v = combined_violations(x, &[g], &[h]);
870 assert!((v.total_violation - 0.5).abs() < 1e-10);
871 assert_eq!(v.n_violated, 1);
872 }
873}