Skip to main content

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}