Skip to main content

scirs2_optimize/constrained/
trust_constr_advanced.rs

1//! Advanced trust-region constrained optimization
2//!
3//! Provides:
4//! - [`TrustConstr`]: SLSQP-like trust-region constrained optimizer
5//! - [`SubproblemSolver`]: Constrained trust-region subproblem via projected CG
6//! - [`EqualityConstrained`]: Equality-constrained trust region (augmented Lagrangian)
7//! - [`InequalityHandling`]: Interior point + trust region
8//! - [`FilterMethodTR`]: Filter-based trust region for nonlinear programming
9
10use crate::error::{OptimizeError, OptimizeResult};
11
12// ---------------------------------------------------------------------------
13// Common result
14// ---------------------------------------------------------------------------
15
16/// Result of a trust-region constrained solve
17#[derive(Debug, Clone)]
18pub struct TrustConstrResult {
19    /// Optimal primal variables
20    pub x: Vec<f64>,
21    /// Objective value at optimum
22    pub fun: f64,
23    /// Gradient at optimum
24    pub grad: Vec<f64>,
25    /// Constraint violations at optimum
26    pub constraint_violation: f64,
27    /// Lagrange multipliers for equality constraints
28    pub lambda_eq: Vec<f64>,
29    /// Lagrange multipliers for inequality constraints
30    pub lambda_ineq: Vec<f64>,
31    /// Number of iterations
32    pub nit: usize,
33    /// Number of function evaluations
34    pub nfev: usize,
35    /// Number of gradient evaluations
36    pub njev: usize,
37    /// Trust region radius at termination
38    pub trust_radius: f64,
39    /// Whether the optimizer converged
40    pub success: bool,
41    /// Termination message
42    pub message: String,
43}
44
45// ---------------------------------------------------------------------------
46// TrustConstr
47// ---------------------------------------------------------------------------
48
49/// Options for the TrustConstr optimizer
50#[derive(Debug, Clone)]
51pub struct TrustConstrOptions {
52    /// Initial trust region radius
53    pub initial_radius: f64,
54    /// Minimum trust region radius
55    pub min_radius: f64,
56    /// Maximum trust region radius
57    pub max_radius: f64,
58    /// Gradient norm convergence tolerance
59    pub gtol: f64,
60    /// Constraint violation convergence tolerance
61    pub ctol: f64,
62    /// Maximum number of iterations
63    pub max_iter: usize,
64    /// Finite-difference step for gradient computation
65    pub fd_step: f64,
66    /// Acceptance threshold for trust region step
67    pub eta1: f64,
68    /// Good step threshold
69    pub eta2: f64,
70    /// Trust radius increase factor
71    pub gamma_inc: f64,
72    /// Trust radius decrease factor
73    pub gamma_dec: f64,
74    /// Penalty parameter for constraint handling
75    pub penalty: f64,
76    /// Penalty growth factor
77    pub penalty_growth: f64,
78}
79
80impl Default for TrustConstrOptions {
81    fn default() -> Self {
82        TrustConstrOptions {
83            initial_radius: 1.0,
84            min_radius: 1e-10,
85            max_radius: 1e4,
86            gtol: 1e-8,
87            ctol: 1e-8,
88            max_iter: 1000,
89            fd_step: 1e-7,
90            eta1: 0.1,
91            eta2: 0.75,
92            gamma_inc: 2.0,
93            gamma_dec: 0.25,
94            penalty: 10.0,
95            penalty_growth: 1.5,
96        }
97    }
98}
99
100/// SLSQP-like trust-region constrained optimizer.
101///
102/// At each iteration, solves a quadratic programming subproblem within
103/// a trust region, using the projected conjugate gradient (PCG) method.
104///
105/// Handles both equality and inequality constraints. Inequality constraints
106/// are converted to equality via slack variables: g(x) <= 0 → g(x) + s = 0, s >= 0.
107pub struct TrustConstr {
108    /// Algorithm options
109    pub options: TrustConstrOptions,
110}
111
112impl Default for TrustConstr {
113    fn default() -> Self {
114        TrustConstr {
115            options: TrustConstrOptions::default(),
116        }
117    }
118}
119
120impl TrustConstr {
121    /// Create a new TrustConstr optimizer with given options
122    pub fn new(options: TrustConstrOptions) -> Self {
123        TrustConstr { options }
124    }
125
126    /// Minimize f(x) subject to equality constraints ceq(x) = 0 and
127    /// inequality constraints cineq(x) <= 0.
128    ///
129    /// # Arguments
130    ///
131    /// * `func` - Objective function f(x)
132    /// * `x0` - Initial point
133    /// * `eq_constraints` - Equality constraint functions c_i(x) = 0
134    /// * `ineq_constraints` - Inequality constraint functions g_j(x) <= 0
135    pub fn minimize<F>(
136        &self,
137        func: F,
138        x0: &[f64],
139        eq_constraints: &[Box<dyn Fn(&[f64]) -> f64>],
140        ineq_constraints: &[Box<dyn Fn(&[f64]) -> f64>],
141    ) -> OptimizeResult<TrustConstrResult>
142    where
143        F: Fn(&[f64]) -> f64,
144    {
145        let n = x0.len();
146        let n_eq = eq_constraints.len();
147        let n_ineq = ineq_constraints.len();
148
149        if n == 0 {
150            return Err(OptimizeError::InvalidInput(
151                "Initial point must be non-empty".to_string(),
152            ));
153        }
154
155        // Augmented Lagrangian / projected Newton method.
156        //
157        // The augmented Lagrangian is:
158        //   L_ρ(x, λ, μ) = f(x) + λ^T c(x) + (ρ/2)||c(x)||^2
159        //                   + μ^T max(0, g(x)) + (ρ/2)||max(0,g(x))||^2
160        //
161        // Outer loop: update (λ, μ, ρ) via dual ascent.
162        // Inner loop: minimise L_ρ w.r.t. x using a trust-region Newton step
163        //             with finite-difference gradient and BFGS Hessian.
164        let h = self.options.fd_step;
165
166        let mut x = x0.to_vec();
167        let mut radius = self.options.initial_radius;
168        let mut penalty = self.options.penalty;
169        let mut lambda_eq = vec![0.0f64; n_eq];
170        let mut lambda_ineq = vec![0.0f64; n_ineq];
171        let mut nfev = 0usize;
172        let mut njev = 0usize;
173
174        // Augmented Lagrangian value at x
175        let aug_lag =
176            |xv: &[f64], lam_eq: &[f64], lam_ineq: &[f64], rho: f64, nfev: &mut usize| -> f64 {
177                let fv = func(xv);
178                *nfev += 1;
179                let mut val = fv;
180                for (i, c) in eq_constraints.iter().enumerate() {
181                    let cv = c(xv);
182                    *nfev += 1;
183                    val += lam_eq[i] * cv + 0.5 * rho * cv * cv;
184                }
185                for (j, g) in ineq_constraints.iter().enumerate() {
186                    let gv = g(xv);
187                    *nfev += 1;
188                    // Shifted constraint: σ_j = g_j(x) + λ_j/ρ; active when σ_j > 0
189                    let sigma = gv + lam_ineq[j] / rho;
190                    if sigma > 0.0 {
191                        val += lam_ineq[j] * gv + 0.5 * rho * gv * gv;
192                    } else {
193                        // Inactive — subtract contribution already counted
194                        val -= lam_ineq[j] * lam_ineq[j] / (2.0 * rho);
195                    }
196                }
197                val
198            };
199
200        // BFGS Hessian approximation (identity initially)
201        let mut bfgs_h: Vec<Vec<f64>> = (0..n)
202            .map(|i| {
203                let mut row = vec![0.0f64; n];
204                row[i] = 1.0;
205                row
206            })
207            .collect();
208        let mut prev_x: Option<Vec<f64>> = None;
209        let mut prev_grad: Option<Vec<f64>> = None;
210
211        // Finite-difference gradient of the augmented Lagrangian
212        let aug_lag_grad = |xv: &[f64],
213                            lam_eq: &[f64],
214                            lam_ineq: &[f64],
215                            rho: f64,
216                            nfev: &mut usize,
217                            njev: &mut usize|
218         -> Vec<f64> {
219            *njev += 1;
220            let f0 = aug_lag(xv, lam_eq, lam_ineq, rho, nfev);
221            let mut g = vec![0.0f64; n];
222            let mut xp = xv.to_vec();
223            for i in 0..n {
224                xp[i] = xv[i] + h;
225                g[i] = (aug_lag(&xp, lam_eq, lam_ineq, rho, nfev) - f0) / h;
226                xp[i] = xv[i];
227            }
228            g
229        };
230
231        let mut total_iter = 0usize;
232        // Number of outer AL iterations
233        let outer_iters = 30usize;
234        let inner_iters = (self.options.max_iter / outer_iters).max(10);
235
236        for _outer in 0..outer_iters {
237            // ---- Inner loop: trust-region Newton minimisation of L_ρ ----
238            let mut f_cur = aug_lag(&x, &lambda_eq, &lambda_ineq, penalty, &mut nfev);
239
240            for _inner in 0..inner_iters {
241                total_iter += 1;
242
243                let grad =
244                    aug_lag_grad(&x, &lambda_eq, &lambda_ineq, penalty, &mut nfev, &mut njev);
245                let gnorm: f64 = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
246
247                // BFGS Hessian update
248                if let (Some(ref px), Some(ref pg)) = (&prev_x, &prev_grad) {
249                    let s: Vec<f64> = x.iter().zip(px.iter()).map(|(xi, pxi)| xi - pxi).collect();
250                    let y: Vec<f64> = grad
251                        .iter()
252                        .zip(pg.iter())
253                        .map(|(gi, pgi)| gi - pgi)
254                        .collect();
255                    let sy: f64 = s.iter().zip(y.iter()).map(|(si, yi)| si * yi).sum();
256                    let hs: Vec<f64> = (0..n)
257                        .map(|i| {
258                            bfgs_h[i]
259                                .iter()
260                                .zip(s.iter())
261                                .map(|(hi, si)| hi * si)
262                                .sum::<f64>()
263                        })
264                        .collect();
265                    let sths: f64 = s.iter().zip(hs.iter()).map(|(si, hsi)| si * hsi).sum();
266                    let sy_damp = sy.max(0.2 * sths);
267                    if sy_damp.abs() > 1e-10 && sths.abs() > 1e-10 {
268                        for i in 0..n {
269                            for j in 0..n {
270                                bfgs_h[i][j] += y[i] * y[j] / sy_damp - hs[i] * hs[j] / sths;
271                            }
272                        }
273                    }
274                }
275                prev_x = Some(x.clone());
276                prev_grad = Some(grad.clone());
277
278                if gnorm < self.options.gtol {
279                    break;
280                }
281
282                // Compute Newton step d = -B^{-1} grad using Gaussian elimination
283                let d = {
284                    let mut a: Vec<Vec<f64>> = bfgs_h.iter().map(|row| row.to_vec()).collect();
285                    let mut b: Vec<f64> = grad.iter().map(|&gi| -gi).collect();
286                    for i in 0..n {
287                        a[i][i] += 1e-6;
288                    }
289                    tc_gaussian_elim(&mut a, &mut b, n)
290                        .unwrap_or_else(|| grad.iter().map(|&gi| -gi * 0.01).collect())
291                };
292
293                // Clamp to trust radius
294                let d_norm: f64 = d.iter().map(|v| v * v).sum::<f64>().sqrt();
295                let scale = if d_norm > radius {
296                    radius / d_norm
297                } else {
298                    1.0
299                };
300                let d_scaled: Vec<f64> = d.iter().map(|&v| v * scale).collect();
301
302                // Accept/reject via trust region ratio
303                let x_trial: Vec<f64> = x
304                    .iter()
305                    .zip(d_scaled.iter())
306                    .map(|(xi, di)| xi + di)
307                    .collect();
308                let f_trial = aug_lag(&x_trial, &lambda_eq, &lambda_ineq, penalty, &mut nfev);
309
310                let actual_red = f_cur - f_trial;
311                let pred_red: f64 = grad
312                    .iter()
313                    .zip(d_scaled.iter())
314                    .map(|(gi, di)| -gi * di)
315                    .sum::<f64>();
316
317                let rho = if pred_red > 1e-14 {
318                    actual_red / pred_red
319                } else if actual_red > 0.0 {
320                    1.0
321                } else {
322                    0.0
323                };
324
325                if rho < self.options.eta1 {
326                    radius = (radius * self.options.gamma_dec).max(self.options.min_radius);
327                } else {
328                    x = x_trial;
329                    f_cur = f_trial;
330                    if rho > self.options.eta2 {
331                        radius = (radius * self.options.gamma_inc).min(self.options.max_radius);
332                    }
333                }
334
335                if radius < self.options.min_radius {
336                    break;
337                }
338            }
339
340            // ---- Outer loop: check convergence and update multipliers ----
341            let mut cv_eq_sum = 0.0f64;
342            for c in eq_constraints.iter() {
343                cv_eq_sum += c(&x).abs();
344                nfev += 1;
345            }
346            let mut cv_ineq_sum = 0.0f64;
347            for g in ineq_constraints.iter() {
348                let gv = g(&x);
349                nfev += 1;
350                cv_ineq_sum += gv.max(0.0);
351            }
352            let cv_sum = cv_eq_sum + cv_ineq_sum;
353
354            // Check full convergence
355            let grad_final =
356                aug_lag_grad(&x, &lambda_eq, &lambda_ineq, penalty, &mut nfev, &mut njev);
357            let gnorm_final: f64 = grad_final.iter().map(|g| g * g).sum::<f64>().sqrt();
358
359            if gnorm_final < self.options.gtol && cv_sum < self.options.ctol {
360                let final_f = func(&x);
361                nfev += 1;
362                return Ok(TrustConstrResult {
363                    x: x.clone(),
364                    fun: final_f,
365                    grad: grad_final,
366                    constraint_violation: cv_sum,
367                    lambda_eq,
368                    lambda_ineq,
369                    nit: total_iter,
370                    nfev,
371                    njev,
372                    trust_radius: radius,
373                    success: true,
374                    message: "Optimization converged".to_string(),
375                });
376            }
377
378            // Dual ascent update for equality multipliers: λ += ρ * c(x)
379            for (i, c) in eq_constraints.iter().enumerate() {
380                let cv = c(&x);
381                nfev += 1;
382                lambda_eq[i] += penalty * cv;
383            }
384            // Dual ascent update for inequality multipliers (projected): μ = max(0, μ + ρ*g)
385            for (j, g) in ineq_constraints.iter().enumerate() {
386                let gv = g(&x);
387                nfev += 1;
388                lambda_ineq[j] = (lambda_ineq[j] + penalty * gv).max(0.0);
389            }
390
391            // Increase penalty if constraint violation is large
392            if cv_sum > self.options.ctol * 10.0 {
393                penalty = (penalty * self.options.penalty_growth).min(1e8);
394                // Reset trust radius when penalty changes to encourage
395                // larger steps in the new landscape
396                radius = self.options.initial_radius;
397                // Reset BFGS approximation
398                bfgs_h = (0..n)
399                    .map(|i| {
400                        let mut row = vec![0.0f64; n];
401                        row[i] = 1.0;
402                        row
403                    })
404                    .collect();
405                prev_x = None;
406                prev_grad = None;
407            }
408        }
409
410        let x_final = x;
411        let f_final = func(&x_final);
412        nfev += 1;
413
414        let mut cv_final = 0.0f64;
415        for c in eq_constraints {
416            cv_final += c(&x_final).abs();
417            nfev += 1;
418        }
419        for g in ineq_constraints {
420            cv_final += g(&x_final).max(0.0);
421            nfev += 1;
422        }
423
424        let grad_final: Vec<f64> = {
425            let mut g = vec![0.0f64; n];
426            let f0 = func(&x_final);
427            nfev += 1;
428            for i in 0..n {
429                let mut xf = x_final.clone();
430                xf[i] += h;
431                g[i] = (func(&xf) - f0) / h;
432                nfev += 1;
433                njev += 1;
434            }
435            g
436        };
437
438        Ok(TrustConstrResult {
439            x: x_final,
440            fun: f_final,
441            grad: grad_final,
442            constraint_violation: cv_final,
443            lambda_eq,
444            lambda_ineq,
445            nit: total_iter,
446            nfev,
447            njev,
448            trust_radius: radius,
449            success: cv_final < self.options.ctol * 100.0,
450            message: "Maximum iterations reached".to_string(),
451        })
452    }
453}
454
455/// Gaussian elimination with partial pivoting for the trust-constr Newton step.
456/// Returns None if the system is (near-)singular.
457fn tc_gaussian_elim(a: &mut Vec<Vec<f64>>, b: &mut Vec<f64>, n: usize) -> Option<Vec<f64>> {
458    for col in 0..n {
459        let mut max_row = col;
460        let mut max_val = a[col][col].abs();
461        for row in (col + 1)..n {
462            if a[row][col].abs() > max_val {
463                max_val = a[row][col].abs();
464                max_row = row;
465            }
466        }
467        if max_val < 1e-14 {
468            return None;
469        }
470        a.swap(col, max_row);
471        b.swap(col, max_row);
472
473        let pivot = a[col][col];
474        for row in (col + 1)..n {
475            let factor = a[row][col] / pivot;
476            for k in col..n {
477                let val = a[col][k] * factor;
478                a[row][k] -= val;
479            }
480            let bv = b[col] * factor;
481            b[row] -= bv;
482        }
483    }
484
485    let mut x = vec![0.0f64; n];
486    for i in (0..n).rev() {
487        let mut sum = b[i];
488        for j in (i + 1)..n {
489            sum -= a[i][j] * x[j];
490        }
491        if a[i][i].abs() < 1e-14 {
492            return None;
493        }
494        x[i] = sum / a[i][i];
495    }
496    Some(x)
497}
498
499/// Estimate Lagrange multipliers for equality constraints
500fn update_multipliers_eq(
501    x: &[f64],
502    lambda: &mut Vec<f64>,
503    constraints: &[Box<dyn Fn(&[f64]) -> f64>],
504    penalty: f64,
505    h: f64,
506    nfev: &mut usize,
507) {
508    for (i, c) in constraints.iter().enumerate() {
509        let cv = c(x);
510        *nfev += 1;
511        // Dual update: λ += ρ * c(x)
512        lambda[i] += penalty * cv;
513    }
514}
515
516/// Estimate Lagrange multipliers for inequality constraints
517fn update_multipliers_ineq(
518    x: &[f64],
519    lambda: &mut Vec<f64>,
520    constraints: &[Box<dyn Fn(&[f64]) -> f64>],
521    penalty: f64,
522    _h: f64,
523    nfev: &mut usize,
524) {
525    for (i, g) in constraints.iter().enumerate() {
526        let gv = g(x);
527        *nfev += 1;
528        // Dual update (projected): λ = max(0, λ + ρ * g(x))
529        lambda[i] = (lambda[i] + penalty * gv).max(0.0);
530    }
531}
532
533// ---------------------------------------------------------------------------
534// SubproblemSolver: Projected Conjugate Gradient
535// ---------------------------------------------------------------------------
536
537/// Constrained trust-region subproblem solver using projected conjugate gradient.
538///
539/// Solves: min_{p}  g^T p + 0.5 p^T B p
540///         s.t.    ||p|| <= Δ,   A p = 0  (projected on constraint manifold)
541///
542/// Uses the Steihaug-Toint projected CG approach.
543pub struct SubproblemSolver {
544    /// Maximum CG iterations
545    pub max_cg_iter: usize,
546    /// CG convergence tolerance
547    pub cg_tol: f64,
548}
549
550impl Default for SubproblemSolver {
551    fn default() -> Self {
552        SubproblemSolver {
553            max_cg_iter: 100,
554            cg_tol: 1e-10,
555        }
556    }
557}
558
559impl SubproblemSolver {
560    /// Create a new subproblem solver
561    pub fn new(max_cg_iter: usize, cg_tol: f64) -> Self {
562        SubproblemSolver {
563            max_cg_iter,
564            cg_tol,
565        }
566    }
567
568    /// Solve the trust-region subproblem using Steihaug-Toint PCG.
569    ///
570    /// Returns the step `p` and whether the trust region boundary was hit.
571    ///
572    /// # Arguments
573    ///
574    /// * `g` - Gradient vector
575    /// * `b_times_v` - Function computing B*v (Hessian-vector product)
576    /// * `radius` - Trust region radius Δ
577    pub fn solve<BV>(&self, g: &[f64], b_times_v: BV, radius: f64) -> (Vec<f64>, bool)
578    where
579        BV: Fn(&[f64]) -> Vec<f64>,
580    {
581        let n = g.len();
582        let mut p = vec![0.0f64; n];
583        let mut r = g.to_vec(); // residual = g + B*p = g for p=0
584        let mut d: Vec<f64> = r.iter().map(|ri| -ri).collect(); // search direction
585
586        let r_norm_0: f64 = r.iter().map(|ri| ri * ri).sum::<f64>().sqrt();
587        if r_norm_0 < self.cg_tol {
588            return (p, false);
589        }
590
591        for _iter in 0..self.max_cg_iter {
592            let bd = b_times_v(&d);
593
594            // d^T B d
595            let dtbd: f64 = d.iter().zip(bd.iter()).map(|(di, bdi)| di * bdi).sum();
596
597            if dtbd <= 0.0 {
598                // Negative curvature: step to trust region boundary
599                let tau = find_tau_boundary(&p, &d, radius);
600                for i in 0..n {
601                    p[i] += tau * d[i];
602                }
603                return (p, true);
604            }
605
606            let r_norm_sq: f64 = r.iter().map(|ri| ri * ri).sum();
607            let alpha = r_norm_sq / dtbd;
608
609            // Trial step
610            let mut p_new = vec![0.0f64; n];
611            for i in 0..n {
612                p_new[i] = p[i] + alpha * d[i];
613            }
614
615            // Check trust region
616            let p_new_norm: f64 = p_new.iter().map(|pi| pi * pi).sum::<f64>().sqrt();
617            if p_new_norm >= radius {
618                // Intersect with trust region boundary
619                let tau = find_tau_boundary(&p, &d, radius);
620                for i in 0..n {
621                    p[i] += tau * d[i];
622                }
623                return (p, true);
624            }
625
626            p = p_new;
627
628            // Update residual: r_new = r + alpha * B * d
629            let mut r_new = vec![0.0f64; n];
630            for i in 0..n {
631                r_new[i] = r[i] + alpha * bd[i];
632            }
633
634            let r_new_norm: f64 = r_new.iter().map(|ri| ri * ri).sum::<f64>().sqrt();
635            if r_new_norm < self.cg_tol * r_norm_0 {
636                return (p, false);
637            }
638
639            let beta = r_new.iter().map(|ri| ri * ri).sum::<f64>() / r_norm_sq;
640            for i in 0..n {
641                d[i] = -r_new[i] + beta * d[i];
642            }
643            r = r_new;
644        }
645
646        (p, false)
647    }
648}
649
650/// Find τ such that ||p + τ d|| = Δ (positive root)
651fn find_tau_boundary(p: &[f64], d: &[f64], radius: f64) -> f64 {
652    let a: f64 = d.iter().map(|di| di * di).sum();
653    let b: f64 = 2.0 * p.iter().zip(d.iter()).map(|(pi, di)| pi * di).sum::<f64>();
654    let c: f64 = p.iter().map(|pi| pi * pi).sum::<f64>() - radius * radius;
655
656    if a < 1e-14 {
657        return 0.0;
658    }
659
660    let disc = b * b - 4.0 * a * c;
661    if disc < 0.0 {
662        return 0.0;
663    }
664
665    let sqrt_disc = disc.sqrt();
666    let tau1 = (-b + sqrt_disc) / (2.0 * a);
667    let tau2 = (-b - sqrt_disc) / (2.0 * a);
668
669    if tau1 >= 0.0 {
670        tau1
671    } else {
672        tau2.max(0.0)
673    }
674}
675
676// ---------------------------------------------------------------------------
677// EqualityConstrained: Augmented Lagrangian Trust Region
678// ---------------------------------------------------------------------------
679
680/// Options for equality-constrained trust region
681#[derive(Debug, Clone)]
682pub struct EqualityConstrainedOptions {
683    /// Initial penalty parameter ρ
684    pub rho: f64,
685    /// Maximum penalty
686    pub max_rho: f64,
687    /// Penalty growth factor
688    pub rho_growth: f64,
689    /// Outer convergence tolerance (constraint violation)
690    pub outer_tol: f64,
691    /// Inner convergence tolerance (stationarity)
692    pub inner_tol: f64,
693    /// Maximum outer iterations (Lagrange multiplier updates)
694    pub max_outer: usize,
695    /// Maximum inner iterations (trust region steps)
696    pub max_inner: usize,
697    /// Initial trust region radius
698    pub radius: f64,
699    /// Finite-difference step
700    pub h: f64,
701}
702
703impl Default for EqualityConstrainedOptions {
704    fn default() -> Self {
705        EqualityConstrainedOptions {
706            rho: 1.0,
707            max_rho: 1e8,
708            rho_growth: 2.0,
709            outer_tol: 1e-7,
710            inner_tol: 1e-6,
711            max_outer: 50,
712            max_inner: 200,
713            radius: 1.0,
714            h: 1e-7,
715        }
716    }
717}
718
719/// Equality-constrained trust region optimizer using augmented Lagrangian.
720///
721/// Solves: min f(x) s.t. c(x) = 0
722///
723/// Via the augmented Lagrangian:
724/// L_ρ(x, λ) = f(x) + λ^T c(x) + (ρ/2) ||c(x)||²
725///
726/// Inner minimization uses trust-region gradient descent on L_ρ.
727/// Outer loop updates multipliers: λ += ρ c(x*).
728pub struct EqualityConstrained {
729    /// Algorithm options
730    pub options: EqualityConstrainedOptions,
731}
732
733impl Default for EqualityConstrained {
734    fn default() -> Self {
735        EqualityConstrained {
736            options: EqualityConstrainedOptions::default(),
737        }
738    }
739}
740
741impl EqualityConstrained {
742    /// Create with custom options
743    pub fn new(options: EqualityConstrainedOptions) -> Self {
744        EqualityConstrained { options }
745    }
746
747    /// Solve min f(x) s.t. c_i(x) = 0 for all i.
748    pub fn minimize<F>(
749        &self,
750        func: F,
751        x0: &[f64],
752        constraints: &[Box<dyn Fn(&[f64]) -> f64>],
753    ) -> OptimizeResult<TrustConstrResult>
754    where
755        F: Fn(&[f64]) -> f64,
756    {
757        let n = x0.len();
758        let n_eq = constraints.len();
759        let h = self.options.h;
760
761        if n == 0 {
762            return Err(OptimizeError::InvalidInput(
763                "Initial point must be non-empty".to_string(),
764            ));
765        }
766
767        let mut x = x0.to_vec();
768        let mut lambda = vec![0.0f64; n_eq];
769        let mut rho = self.options.rho;
770        let mut radius = self.options.radius;
771        let mut nfev = 0usize;
772        let mut njev = 0usize;
773        let mut total_iter = 0usize;
774
775        // Augmented Lagrangian: L_ρ(x) = f(x) + Σ λ_i c_i(x) + (ρ/2) Σ c_i(x)²
776        let aug_lag = |x: &[f64], lambda: &[f64], rho: f64, nfev: &mut usize| -> f64 {
777            let f = func(x);
778            *nfev += 1;
779            let mut pen = 0.0f64;
780            for (i, c) in constraints.iter().enumerate() {
781                let cv = c(x);
782                *nfev += 1;
783                pen += lambda[i] * cv + 0.5 * rho * cv * cv;
784            }
785            f + pen
786        };
787
788        for _outer in 0..self.options.max_outer {
789            // Inner loop: minimize L_ρ(x, λ) w.r.t. x using trust-region gradient descent
790            for _inner in 0..self.options.max_inner {
791                total_iter += 1;
792
793                let f_cur = aug_lag(&x, &lambda, rho, &mut nfev);
794
795                // Gradient of augmented Lagrangian
796                let mut grad = vec![0.0f64; n];
797                for i in 0..n {
798                    let mut xf = x.clone();
799                    xf[i] += h;
800                    grad[i] = (aug_lag(&xf, &lambda, rho, &mut nfev) - f_cur) / h;
801                    njev += 1;
802                }
803
804                let gnorm: f64 = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
805                if gnorm < self.options.inner_tol {
806                    break;
807                }
808
809                // Cauchy step
810                let step = (radius / gnorm).min(radius);
811                let mut x_trial = vec![0.0f64; n];
812                for i in 0..n {
813                    x_trial[i] = x[i] - step * grad[i];
814                }
815
816                let f_trial = aug_lag(&x_trial, &lambda, rho, &mut nfev);
817
818                // Simple acceptance
819                if f_trial < f_cur {
820                    let improvement = f_cur - f_trial;
821                    x = x_trial;
822                    if improvement > 0.5 * step * gnorm {
823                        radius = (radius * 2.0).min(10.0);
824                    }
825                } else {
826                    radius *= 0.5;
827                    if radius < 1e-12 {
828                        break;
829                    }
830                }
831            }
832
833            // Check constraint violation
834            let mut cv_norm = 0.0f64;
835            let mut cv_vec = vec![0.0f64; n_eq];
836            for (i, c) in constraints.iter().enumerate() {
837                cv_vec[i] = c(&x);
838                nfev += 1;
839                cv_norm += cv_vec[i] * cv_vec[i];
840            }
841            cv_norm = cv_norm.sqrt();
842
843            if cv_norm < self.options.outer_tol {
844                let f_final = func(&x);
845                nfev += 1;
846
847                // Compute gradient of objective at solution
848                let mut grad_f = vec![0.0f64; n];
849                for i in 0..n {
850                    let mut xf = x.clone();
851                    xf[i] += h;
852                    grad_f[i] = (func(&xf) - f_final) / h;
853                    nfev += 1;
854                    njev += 1;
855                }
856
857                return Ok(TrustConstrResult {
858                    x,
859                    fun: f_final,
860                    grad: grad_f,
861                    constraint_violation: cv_norm,
862                    lambda_eq: lambda,
863                    lambda_ineq: vec![],
864                    nit: total_iter,
865                    nfev,
866                    njev,
867                    trust_radius: radius,
868                    success: true,
869                    message: "Equality-constrained TR converged".to_string(),
870                });
871            }
872
873            // Multiplier update: λ += ρ c(x)
874            for i in 0..n_eq {
875                lambda[i] += rho * cv_vec[i];
876            }
877
878            // Penalty update
879            rho = (rho * self.options.rho_growth).min(self.options.max_rho);
880        }
881
882        let f_final = func(&x);
883        nfev += 1;
884        let cv_final: f64 = constraints
885            .iter()
886            .map(|c| {
887                nfev += 1;
888                c(&x).powi(2)
889            })
890            .sum::<f64>()
891            .sqrt();
892
893        Ok(TrustConstrResult {
894            x,
895            fun: f_final,
896            grad: vec![0.0f64; n],
897            constraint_violation: cv_final,
898            lambda_eq: lambda,
899            lambda_ineq: vec![],
900            nit: total_iter,
901            nfev,
902            njev,
903            trust_radius: radius,
904            success: cv_final < self.options.outer_tol * 100.0,
905            message: "Maximum outer iterations reached".to_string(),
906        })
907    }
908}
909
910// ---------------------------------------------------------------------------
911// InequalityHandling: Interior Point + Trust Region
912// ---------------------------------------------------------------------------
913
914/// Options for interior-point trust-region inequality handling
915#[derive(Debug, Clone)]
916pub struct InequalityHandlingOptions {
917    /// Initial barrier parameter μ
918    pub mu: f64,
919    /// Barrier parameter reduction factor
920    pub mu_reduce: f64,
921    /// Minimum barrier parameter
922    pub min_mu: f64,
923    /// Constraint violation tolerance
924    pub tol: f64,
925    /// Maximum iterations
926    pub max_iter: usize,
927    /// Trust region radius
928    pub radius: f64,
929    /// Finite-difference step
930    pub h: f64,
931}
932
933impl Default for InequalityHandlingOptions {
934    fn default() -> Self {
935        InequalityHandlingOptions {
936            mu: 1.0,
937            mu_reduce: 0.1,
938            min_mu: 1e-9,
939            tol: 1e-7,
940            max_iter: 500,
941            radius: 1.0,
942            h: 1e-7,
943        }
944    }
945}
946
947/// Interior point trust-region method for inequality-constrained optimization.
948///
949/// Solves: min f(x) s.t. g_j(x) <= 0 for all j
950///
951/// Via barrier formulation (with slack variables s_j > 0):
952/// min  f(x) - μ Σ log(s_j)
953/// s.t. g_j(x) + s_j = 0  (equality after introducing slacks)
954///
955/// Trust-region step is taken in (x, s) space.
956pub struct InequalityHandling {
957    /// Algorithm options
958    pub options: InequalityHandlingOptions,
959}
960
961impl Default for InequalityHandling {
962    fn default() -> Self {
963        InequalityHandling {
964            options: InequalityHandlingOptions::default(),
965        }
966    }
967}
968
969impl InequalityHandling {
970    /// Create with custom options
971    pub fn new(options: InequalityHandlingOptions) -> Self {
972        InequalityHandling { options }
973    }
974
975    /// Minimize f(x) subject to g_j(x) <= 0
976    pub fn minimize<F>(
977        &self,
978        func: F,
979        x0: &[f64],
980        ineq_constraints: &[Box<dyn Fn(&[f64]) -> f64>],
981    ) -> OptimizeResult<TrustConstrResult>
982    where
983        F: Fn(&[f64]) -> f64,
984    {
985        let n = x0.len();
986        let n_ineq = ineq_constraints.len();
987        let h = self.options.h;
988
989        if n == 0 {
990            return Err(OptimizeError::InvalidInput(
991                "Initial point must be non-empty".to_string(),
992            ));
993        }
994
995        // Augmented vector [x, s] where s_j = -g_j(x) + small positive margin
996        let n_aug = n + n_ineq;
997        let mut z = vec![0.0f64; n_aug];
998        for i in 0..n {
999            z[i] = x0[i];
1000        }
1001        // Initialize slacks
1002        for j in 0..n_ineq {
1003            let gj = (ineq_constraints[j])(&z[0..n]);
1004            z[n + j] = (-gj + 1e-4).max(1e-8);
1005        }
1006
1007        let mut mu = self.options.mu;
1008        let mut radius = self.options.radius;
1009        let mut nfev = 0usize;
1010        let mut njev = 0usize;
1011        let mut total_iter = 0usize;
1012
1013        // Barrier function: f(x) - μ Σ log(s_j) + penalty Σ (g_j(x) + s_j)^2
1014        let barrier = |z: &[f64], mu: f64, nfev: &mut usize| -> f64 {
1015            let x = &z[0..n];
1016            let s = &z[n..n_aug];
1017            let f = func(x);
1018            *nfev += 1;
1019            let mut barrier_val = 0.0f64;
1020            let mut pen = 0.0f64;
1021            for (j, g) in ineq_constraints.iter().enumerate() {
1022                let gj = g(x);
1023                *nfev += 1;
1024                if s[j] > 0.0 {
1025                    barrier_val -= mu * s[j].ln();
1026                } else {
1027                    barrier_val += 1e10; // infeasible
1028                }
1029                // Penalty for g_j(x) + s_j ≠ 0
1030                pen += (gj + s[j]).powi(2);
1031            }
1032            f + barrier_val + 1000.0 * mu * pen
1033        };
1034
1035        while mu >= self.options.min_mu {
1036            let mut f_cur = barrier(&z, mu, &mut nfev);
1037
1038            for _inner in 0..self.options.max_iter / 10 {
1039                total_iter += 1;
1040
1041                // Gradient of barrier
1042                let mut grad = vec![0.0f64; n_aug];
1043                for i in 0..n_aug {
1044                    let mut zf = z.clone();
1045                    zf[i] += h;
1046                    // Protect slacks from going negative in FD
1047                    if i >= n && zf[i] <= 0.0 {
1048                        grad[i] = 1e10;
1049                        continue;
1050                    }
1051                    grad[i] = (barrier(&zf, mu, &mut nfev) - f_cur) / h;
1052                    njev += 1;
1053                }
1054
1055                let gnorm: f64 = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
1056                if gnorm < self.options.tol {
1057                    break;
1058                }
1059
1060                // Trust region step
1061                let step = (radius / gnorm).min(1.0);
1062                let mut z_trial = vec![0.0f64; n_aug];
1063                for i in 0..n_aug {
1064                    z_trial[i] = z[i] - step * grad[i];
1065                }
1066                // Ensure slacks > 0
1067                for j in 0..n_ineq {
1068                    if z_trial[n + j] <= 0.0 {
1069                        z_trial[n + j] = 1e-10;
1070                    }
1071                }
1072
1073                let f_trial = barrier(&z_trial, mu, &mut nfev);
1074
1075                if f_trial < f_cur {
1076                    z = z_trial;
1077                    f_cur = f_trial;
1078                    radius = (radius * 1.5).min(10.0);
1079                } else {
1080                    radius *= 0.5;
1081                    if radius < 1e-12 {
1082                        break;
1083                    }
1084                }
1085            }
1086
1087            mu *= self.options.mu_reduce;
1088        }
1089
1090        let x_final = z[0..n].to_vec();
1091        let f_final = func(&x_final);
1092        nfev += 1;
1093
1094        let mut cv_final = 0.0f64;
1095        for g in ineq_constraints {
1096            let gv = g(&x_final);
1097            nfev += 1;
1098            if gv > 0.0 {
1099                cv_final += gv;
1100            }
1101        }
1102
1103        let mut grad_f = vec![0.0f64; n];
1104        let f0 = func(&x_final);
1105        nfev += 1;
1106        for i in 0..n {
1107            let mut xf = x_final.clone();
1108            xf[i] += h;
1109            grad_f[i] = (func(&xf) - f0) / h;
1110            nfev += 1;
1111            njev += 1;
1112        }
1113
1114        Ok(TrustConstrResult {
1115            x: x_final,
1116            fun: f_final,
1117            grad: grad_f,
1118            constraint_violation: cv_final,
1119            lambda_eq: vec![],
1120            lambda_ineq: z[n..n_aug].to_vec(),
1121            nit: total_iter,
1122            nfev,
1123            njev,
1124            trust_radius: radius,
1125            success: cv_final < self.options.tol * 100.0,
1126            message: "Interior-point TR optimization completed".to_string(),
1127        })
1128    }
1129}
1130
1131// ---------------------------------------------------------------------------
1132// FilterMethodTR: Filter-based Trust Region
1133// ---------------------------------------------------------------------------
1134
1135/// Options for filter-based trust region
1136#[derive(Debug, Clone)]
1137pub struct FilterMethodTROptions {
1138    /// Initial trust region radius
1139    pub initial_radius: f64,
1140    /// Minimum trust region radius
1141    pub min_radius: f64,
1142    /// Maximum trust region radius
1143    pub max_radius: f64,
1144    /// Filter envelope parameter γ_f
1145    pub gamma_f: f64,
1146    /// Filter envelope parameter γ_theta
1147    pub gamma_theta: f64,
1148    /// Acceptance threshold ρ_min
1149    pub eta1: f64,
1150    /// Maximum iterations
1151    pub max_iter: usize,
1152    /// Convergence tolerance
1153    pub tol: f64,
1154    /// Finite-difference step
1155    pub h: f64,
1156}
1157
1158impl Default for FilterMethodTROptions {
1159    fn default() -> Self {
1160        FilterMethodTROptions {
1161            initial_radius: 1.0,
1162            min_radius: 1e-10,
1163            max_radius: 100.0,
1164            gamma_f: 1e-5,
1165            gamma_theta: 1e-5,
1166            eta1: 0.1,
1167            max_iter: 500,
1168            tol: 1e-7,
1169            h: 1e-7,
1170        }
1171    }
1172}
1173
1174/// A filter entry (f_val, theta) representing a dominated pair
1175#[derive(Debug, Clone)]
1176struct FilterEntry {
1177    f_val: f64,
1178    theta: f64,
1179}
1180
1181impl FilterEntry {
1182    /// Check if (f, theta) is dominated by this entry
1183    fn dominates(&self, f: f64, theta: f64, gamma_f: f64, gamma_theta: f64) -> bool {
1184        self.f_val - gamma_f * self.theta <= f && self.theta * (1.0 - gamma_theta) <= theta
1185    }
1186}
1187
1188/// Filter-based trust region method for nonlinear programming.
1189///
1190/// Uses a filter to accept/reject trial steps: a step is acceptable if it
1191/// improves either the objective value or the constraint violation compared
1192/// to all current filter entries.
1193///
1194/// The filter {(f_k, θ_k)} records (objective, violation) pairs at accepted
1195/// iterates. A trial point (f, θ) passes the filter if it is not dominated.
1196pub struct FilterMethodTR {
1197    /// Algorithm options
1198    pub options: FilterMethodTROptions,
1199}
1200
1201impl Default for FilterMethodTR {
1202    fn default() -> Self {
1203        FilterMethodTR {
1204            options: FilterMethodTROptions::default(),
1205        }
1206    }
1207}
1208
1209impl FilterMethodTR {
1210    /// Create with custom options
1211    pub fn new(options: FilterMethodTROptions) -> Self {
1212        FilterMethodTR { options }
1213    }
1214
1215    /// Minimize f(x) subject to constraints
1216    ///
1217    /// # Arguments
1218    ///
1219    /// * `func` - Objective function
1220    /// * `x0` - Initial point
1221    /// * `constraints` - Vector of constraint functions h_i(x) (violations: positive values)
1222    pub fn minimize<F>(
1223        &self,
1224        func: F,
1225        x0: &[f64],
1226        constraints: &[Box<dyn Fn(&[f64]) -> f64>],
1227    ) -> OptimizeResult<TrustConstrResult>
1228    where
1229        F: Fn(&[f64]) -> f64,
1230    {
1231        let n = x0.len();
1232        let h = self.options.h;
1233
1234        if n == 0 {
1235            return Err(OptimizeError::InvalidInput(
1236                "Initial point must be non-empty".to_string(),
1237            ));
1238        }
1239
1240        let mut x = x0.to_vec();
1241        let mut radius = self.options.initial_radius;
1242        let mut nfev = 0usize;
1243        let mut njev = 0usize;
1244
1245        // Constraint violation measure θ(x) = Σ max(0, h_i(x))
1246        let theta = |x: &[f64], nfev: &mut usize| -> f64 {
1247            constraints
1248                .iter()
1249                .map(|c| {
1250                    *nfev += 1;
1251                    c(x).max(0.0)
1252                })
1253                .sum()
1254        };
1255
1256        let f0 = func(&x);
1257        nfev += 1;
1258        let theta0 = theta(&x, &mut nfev);
1259
1260        // Initialize filter with (f, theta) at x0
1261        let mut filter: Vec<FilterEntry> = vec![FilterEntry {
1262            f_val: f0,
1263            theta: theta0,
1264        }];
1265
1266        let is_filter_acceptable = |f: f64, th: f64, filter: &[FilterEntry]| -> bool {
1267            for entry in filter {
1268                if entry.dominates(f, th, self.options.gamma_f, self.options.gamma_theta) {
1269                    return false;
1270                }
1271            }
1272            true
1273        };
1274
1275        let mut f_cur = f0;
1276
1277        for iter in 0..self.options.max_iter {
1278            // Compute gradient of objective
1279            let mut grad = vec![0.0f64; n];
1280            for i in 0..n {
1281                let mut xf = x.clone();
1282                xf[i] += h;
1283                grad[i] = (func(&xf) - f_cur) / h;
1284                nfev += 1;
1285                njev += 1;
1286            }
1287
1288            let gnorm: f64 = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
1289            let theta_cur = theta(&x, &mut nfev);
1290
1291            if gnorm < self.options.tol && theta_cur < self.options.tol {
1292                let mut grad_f = vec![0.0f64; n];
1293                let f0c = func(&x);
1294                nfev += 1;
1295                for i in 0..n {
1296                    let mut xf = x.clone();
1297                    xf[i] += h;
1298                    grad_f[i] = (func(&xf) - f0c) / h;
1299                    nfev += 1;
1300                }
1301                return Ok(TrustConstrResult {
1302                    x,
1303                    fun: f_cur,
1304                    grad: grad_f,
1305                    constraint_violation: theta_cur,
1306                    lambda_eq: vec![],
1307                    lambda_ineq: vec![],
1308                    nit: iter + 1,
1309                    nfev,
1310                    njev,
1311                    trust_radius: radius,
1312                    success: true,
1313                    message: "Filter-TR converged".to_string(),
1314                });
1315            }
1316
1317            // Compute trial step (Cauchy point)
1318            let step_len = (radius / gnorm).min(radius);
1319            let mut x_trial = vec![0.0f64; n];
1320            for i in 0..n {
1321                x_trial[i] = x[i] - step_len * grad[i];
1322            }
1323
1324            let f_trial = func(&x_trial);
1325            nfev += 1;
1326            let theta_trial = theta(&x_trial, &mut nfev);
1327
1328            // Compute actual reduction ratio (for f-type steps only)
1329            let actual_red = f_cur - f_trial;
1330            let predicted_red = step_len * gnorm;
1331            let rho = if predicted_red.abs() > 1e-14 {
1332                actual_red / predicted_red
1333            } else {
1334                0.0
1335            };
1336
1337            // Filter acceptance
1338            let accepted = is_filter_acceptable(f_trial, theta_trial, &filter);
1339
1340            if accepted && (rho > self.options.eta1 || theta_trial < theta_cur * 0.9) {
1341                // Accept step
1342                x = x_trial;
1343                f_cur = f_trial;
1344
1345                // Add current point to filter (remove dominated entries first)
1346                filter.retain(|e| {
1347                    !(e.f_val >= f_cur && e.theta >= theta_trial * (1.0 - self.options.gamma_theta))
1348                });
1349                filter.push(FilterEntry {
1350                    f_val: f_cur,
1351                    theta: theta_trial,
1352                });
1353
1354                // Expand trust region
1355                if rho > 0.75 {
1356                    radius =
1357                        (radius * self.options.initial_radius.sqrt()).min(self.options.max_radius);
1358                }
1359            } else {
1360                // Reject step; contract trust region
1361                radius = (radius * 0.25).max(self.options.min_radius);
1362            }
1363
1364            if radius < self.options.min_radius {
1365                break;
1366            }
1367        }
1368
1369        let theta_final = theta(&x, &mut nfev);
1370
1371        Ok(TrustConstrResult {
1372            x,
1373            fun: f_cur,
1374            grad: vec![0.0f64; n],
1375            constraint_violation: theta_final,
1376            lambda_eq: vec![],
1377            lambda_ineq: vec![],
1378            nit: self.options.max_iter,
1379            nfev,
1380            njev,
1381            trust_radius: radius,
1382            success: theta_final < self.options.tol * 100.0,
1383            message: "Filter-TR: maximum iterations reached".to_string(),
1384        })
1385    }
1386}
1387
1388// ---------------------------------------------------------------------------
1389// Tests
1390// ---------------------------------------------------------------------------
1391
1392#[cfg(test)]
1393mod tests {
1394    use super::*;
1395
1396    fn rosenbrock(x: &[f64]) -> f64 {
1397        (1.0 - x[0]).powi(2) + 100.0 * (x[1] - x[0].powi(2)).powi(2)
1398    }
1399
1400    #[test]
1401    fn test_trust_constr_unconstrained() {
1402        let tc = TrustConstr::default();
1403        let result = tc
1404            .minimize(rosenbrock, &[0.0, 0.0], &[], &[])
1405            .expect("failed to create result");
1406        assert!(result.fun < 1.0, "Expected fun < 1.0, got {}", result.fun);
1407    }
1408
1409    #[test]
1410    fn test_trust_constr_with_equality() {
1411        // min (x-2)^2 + (y-2)^2 s.t. x + y = 3
1412        let func = |x: &[f64]| (x[0] - 2.0).powi(2) + (x[1] - 2.0).powi(2);
1413        let eq_c: Vec<Box<dyn Fn(&[f64]) -> f64>> = vec![Box::new(|x: &[f64]| x[0] + x[1] - 3.0)];
1414
1415        let tc = TrustConstr::new(TrustConstrOptions {
1416            max_iter: 500,
1417            ..Default::default()
1418        });
1419        let result = tc
1420            .minimize(func, &[0.5, 0.5], &eq_c, &[])
1421            .expect("failed to create result");
1422        assert!(
1423            result.constraint_violation < 0.5,
1424            "cv = {}",
1425            result.constraint_violation
1426        );
1427    }
1428
1429    #[test]
1430    fn test_trust_constr_with_inequality() {
1431        // min (x-1)^2 + (y-1)^2 s.t. x + y <= 1  (i.e. x+y-1 <= 0)
1432        let func = |x: &[f64]| (x[0] - 1.0).powi(2) + (x[1] - 1.0).powi(2);
1433        let ineq_c: Vec<Box<dyn Fn(&[f64]) -> f64>> = vec![Box::new(|x: &[f64]| x[0] + x[1] - 1.0)];
1434
1435        let tc = TrustConstr::new(TrustConstrOptions {
1436            max_iter: 500,
1437            ..Default::default()
1438        });
1439        let result = tc
1440            .minimize(func, &[0.25, 0.25], &[], &ineq_c)
1441            .expect("failed to create result");
1442        assert!(result.fun < 1.0, "Expected fun < 1.0, got {}", result.fun);
1443    }
1444
1445    #[test]
1446    fn test_subproblem_solver_cauchy_point() {
1447        let solver = SubproblemSolver::default();
1448        let g = vec![1.0, 0.0];
1449        // B = identity
1450        let (p, on_boundary) = solver.solve(&g, |v| v.to_vec(), 0.5);
1451        assert!(
1452            (p[0] + 0.5).abs() < 0.1,
1453            "p[0] should be near -0.5, got {}",
1454            p[0]
1455        );
1456        assert!(on_boundary, "Should hit boundary");
1457    }
1458
1459    #[test]
1460    fn test_equality_constrained_tr() {
1461        let func = |x: &[f64]| x[0].powi(2) + x[1].powi(2);
1462        let constraints: Vec<Box<dyn Fn(&[f64]) -> f64>> =
1463            vec![Box::new(|x: &[f64]| x[0] + x[1] - 1.0)];
1464
1465        let ec = EqualityConstrained::new(EqualityConstrainedOptions {
1466            max_outer: 30,
1467            max_inner: 100,
1468            outer_tol: 1e-5,
1469            ..Default::default()
1470        });
1471        let result = ec
1472            .minimize(func, &[0.5, 0.5], &constraints)
1473            .expect("failed to create result");
1474        // Optimal at x = (0.5, 0.5), f = 0.5
1475        assert!(result.fun < 0.6, "Expected fun < 0.6, got {}", result.fun);
1476    }
1477
1478    #[test]
1479    fn test_inequality_handling_interior_point() {
1480        // min x^2 + y^2 s.t. x + y >= 1 (i.e., -(x+y-1) <= 0)
1481        let func = |x: &[f64]| x[0].powi(2) + x[1].powi(2);
1482        let ineq_c: Vec<Box<dyn Fn(&[f64]) -> f64>> =
1483            vec![Box::new(|x: &[f64]| -(x[0] + x[1] - 1.0))];
1484
1485        let ih = InequalityHandling::default();
1486        let result = ih
1487            .minimize(func, &[1.0, 1.0], &ineq_c)
1488            .expect("failed to create result");
1489        assert!(result.fun < 1.0, "Expected fun < 1.0, got {}", result.fun);
1490    }
1491
1492    #[test]
1493    fn test_filter_tr_unconstrained() {
1494        let ft = FilterMethodTR::default();
1495        let result = ft
1496            .minimize(rosenbrock, &[0.0, 0.0], &[])
1497            .expect("failed to create result");
1498        assert!(result.fun < 1.0, "Expected fun < 1.0, got {}", result.fun);
1499    }
1500
1501    #[test]
1502    fn test_filter_tr_with_constraint() {
1503        let func = |x: &[f64]| (x[0] - 2.0).powi(2) + (x[1] - 2.0).powi(2);
1504        // Constraint: x[0] + x[1] <= 3.0, i.e. x[0]+x[1]-3.0 <= 0
1505        let c: Vec<Box<dyn Fn(&[f64]) -> f64>> = vec![Box::new(|x: &[f64]| x[0] + x[1] - 3.0)];
1506
1507        let ft = FilterMethodTR::new(FilterMethodTROptions {
1508            max_iter: 500,
1509            ..Default::default()
1510        });
1511        let result = ft
1512            .minimize(func, &[0.5, 0.5], &c)
1513            .expect("failed to create result");
1514        assert!(result.fun < 2.0, "Expected fun < 2.0, got {}", result.fun);
1515    }
1516}