Skip to main content

scirs2_optimize/constrained/
interior_point.rs

1//! Interior point methods for constrained optimization
2//!
3//! This module implements primal-dual interior point methods for solving
4//! constrained optimization problems with equality and inequality constraints.
5
6use super::{Constraint, ConstraintKind};
7use crate::error::OptimizeError;
8use crate::unconstrained::OptimizeResult;
9use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
10use std::cell::RefCell;
11use std::rc::Rc;
12
13/// Type alias for equality constraint function.
14///
15/// Carries an explicit lifetime so that callers may pass closures that borrow
16/// from the (non-`'static`) `Constraint` slice; the boxed constraint callables
17/// (issue #126) cannot be copied out, so they are evaluated by reference.
18type EqualityConstraintFn<'a> = dyn FnMut(&ArrayView1<f64>) -> Array1<f64> + 'a;
19
20/// Type alias for equality constraint jacobian function
21type EqualityJacobianFn<'a> = dyn FnMut(&ArrayView1<f64>) -> Array2<f64> + 'a;
22
23/// Type alias for inequality constraint function
24type InequalityConstraintFn<'a> = dyn FnMut(&ArrayView1<f64>) -> Array1<f64> + 'a;
25
26/// Type alias for inequality constraint jacobian function
27type InequalityJacobianFn<'a> = dyn FnMut(&ArrayView1<f64>) -> Array2<f64> + 'a;
28
29/// Type alias for Newton direction result to reduce type complexity
30type NewtonDirectionResult = (Array1<f64>, Array1<f64>, Array1<f64>, Array1<f64>);
31
32/// Interior point method options
33#[derive(Debug, Clone)]
34pub struct InteriorPointOptions {
35    /// Maximum number of iterations
36    pub max_iter: usize,
37    /// Tolerance for optimality conditions
38    pub tol: f64,
39    /// Initial barrier parameter
40    pub initial_barrier: f64,
41    /// Barrier reduction factor
42    pub barrier_reduction: f64,
43    /// Minimum barrier parameter
44    pub min_barrier: f64,
45    /// Maximum number of line search iterations
46    pub max_ls_iter: usize,
47    /// Line search backtracking factor
48    pub alpha: f64,
49    /// Line search shrinkage factor
50    pub beta: f64,
51    /// Tolerance for feasibility
52    pub feas_tol: f64,
53    /// Use Mehrotra's predictor-corrector method
54    pub use_mehrotra: bool,
55    /// Regularization parameter for KKT system
56    pub regularization: f64,
57}
58
59impl Default for InteriorPointOptions {
60    fn default() -> Self {
61        Self {
62            max_iter: 100,
63            tol: 1e-8,
64            initial_barrier: 1.0,
65            barrier_reduction: 0.1,
66            min_barrier: 1e-10,
67            max_ls_iter: 50,
68            alpha: 0.3,
69            beta: 0.5,
70            feas_tol: 1e-8,
71            use_mehrotra: true,
72            regularization: 1e-8,
73        }
74    }
75}
76
77/// Result from interior point optimization
78#[derive(Debug, Clone)]
79pub struct InteriorPointResult {
80    /// Optimal solution
81    pub x: Array1<f64>,
82    /// Optimal objective value
83    pub fun: f64,
84    /// Lagrange multipliers for equality constraints
85    pub lambda_eq: Option<Array1<f64>>,
86    /// Lagrange multipliers for inequality constraints
87    pub lambda_ineq: Option<Array1<f64>>,
88    /// Number of iterations
89    pub nit: usize,
90    /// Number of function evaluations
91    pub nfev: usize,
92    /// Success flag
93    pub success: bool,
94    /// Status message
95    pub message: String,
96    /// Final barrier parameter
97    pub barrier: f64,
98    /// Final optimality measure
99    pub optimality: f64,
100}
101
102/// Interior point solver for constrained optimization
103pub struct InteriorPointSolver<'a> {
104    /// Number of variables
105    n: usize,
106    /// Number of equality constraints
107    m_eq: usize,
108    /// Number of inequality constraints
109    m_ineq: usize,
110    /// Options
111    options: &'a InteriorPointOptions,
112    /// Function evaluation counter
113    nfev: usize,
114    /// BFGS approximation of the Hessian of the Lagrangian, shared by every
115    /// KKT-system build (Newton, affine-scaling predictor, and corrector).
116    /// Initialized to the identity and refined via [`Self::update_hessian_bfgs`]
117    /// once per outer iteration, replacing what used to be a permanent
118    /// identity ("could use BFGS" TODO).
119    hessian_approx: Array2<f64>,
120    /// Penalty weight `nu` for the l1-exact-penalty line-search merit
121    /// function (see [`Self::line_search`] / [`Self::merit_value`]).
122    /// Monotonically nondecreasing across outer iterations (raised only when
123    /// needed so the current step is a guaranteed descent direction of the
124    /// merit function, per the standard exact-penalty safeguard: Nocedal &
125    /// Wright, *Numerical Optimization*, 2nd ed., eq. 18.36), to avoid
126    /// cycling.
127    merit_penalty: f64,
128}
129
130impl<'a> InteriorPointSolver<'a> {
131    /// Create new interior point solver
132    pub fn new(n: usize, m_eq: usize, m_ineq: usize, options: &'a InteriorPointOptions) -> Self {
133        Self {
134            n,
135            m_eq,
136            m_ineq,
137            options,
138            nfev: 0,
139            hessian_approx: Array2::eye(n),
140            merit_penalty: 1.0,
141        }
142    }
143
144    /// Gradient of the Lagrangian `L(x, λ) = f(x) + λ_eq · c_eq(x) + λ_ineq ·
145    /// c_ineq(x)` with respect to `x`, given the objective gradient and
146    /// whatever constraint Jacobians/multipliers are active.
147    fn lagrangian_gradient(
148        &self,
149        g: &Array1<f64>,
150        j_eq: &Option<Array2<f64>>,
151        lambda_eq: &Array1<f64>,
152        j_ineq: &Option<Array2<f64>>,
153        lambda_ineq: &Array1<f64>,
154    ) -> Array1<f64> {
155        let mut lag_grad = g.clone();
156
157        if let (Some(j_eq), true) = (j_eq, self.m_eq > 0) {
158            lag_grad = &lag_grad + &j_eq.t().dot(lambda_eq);
159        }
160
161        if let (Some(j_ineq), true) = (j_ineq, self.m_ineq > 0) {
162            lag_grad = &lag_grad + &j_ineq.t().dot(lambda_ineq);
163        }
164
165        lag_grad
166    }
167
168    /// Update the shared BFGS Hessian-of-the-Lagrangian approximation given
169    /// the most recent primal step `s = x_{k+1} - x_k` and the corresponding
170    /// change in the Lagrangian gradient `y = ∇_x L(x_{k+1}) - ∇_x L(x_k)`.
171    ///
172    /// Uses Powell's damped BFGS update (Nocedal & Wright, *Numerical
173    /// Optimization*, 2nd ed., Procedure 18.2), which keeps the
174    /// approximation symmetric positive definite even when the raw curvature
175    /// condition `s^T y > 0` fails -- routine for a Lagrangian Hessian (unlike
176    /// an objective Hessian for a convex problem, its sign is not guaranteed).
177    fn update_hessian_bfgs(&mut self, s: &Array1<f64>, y: &Array1<f64>) {
178        let n = self.n;
179        if n == 0 {
180            return;
181        }
182
183        let hs = self.hessian_approx.dot(s);
184        let shs = s.dot(&hs);
185        if !(shs > 0.0) || !shs.is_finite() {
186            // Degenerate curvature in the current approximation: skip this
187            // update rather than risk dividing by (near-)zero.
188            return;
189        }
190
191        let sty = s.dot(y);
192        let theta = if sty >= 0.2 * shs {
193            1.0
194        } else {
195            (0.8 * shs) / (shs - sty)
196        }
197        .clamp(0.0, 1.0);
198
199        let y_damped = if theta >= 1.0 {
200            y.clone()
201        } else {
202            theta * y + (1.0 - theta) * &hs
203        };
204
205        let sty_damped = s.dot(&y_damped);
206        if sty_damped.abs() < 1e-12 || !sty_damped.is_finite() {
207            return;
208        }
209
210        for i in 0..n {
211            for j in 0..n {
212                let updated = self.hessian_approx[[i, j]] - hs[i] * hs[j] / shs
213                    + y_damped[i] * y_damped[j] / sty_damped;
214                self.hessian_approx[[i, j]] = updated;
215            }
216        }
217    }
218
219    /// Writes the shared BFGS Hessian approximation (plus regularization on
220    /// the diagonal) into the leading `n x n` block of a KKT matrix.
221    fn write_hessian_block(&self, kkt_matrix: &mut Array2<f64>, reg: f64) {
222        for i in 0..self.n {
223            for j in 0..self.n {
224                kkt_matrix[[i, j]] = self.hessian_approx[[i, j]];
225            }
226            kkt_matrix[[i, i]] += reg;
227        }
228    }
229
230    /// Solve the constrained optimization problem
231    #[allow(clippy::many_single_char_names)]
232    pub fn solve<F, G>(
233        &mut self,
234        fun: &mut F,
235        grad: &mut G,
236        mut eq_con: Option<&mut EqualityConstraintFn<'_>>,
237        mut eq_jac: Option<&mut EqualityJacobianFn<'_>>,
238        mut ineq_con: Option<&mut InequalityConstraintFn<'_>>,
239        mut ineq_jac: Option<&mut InequalityJacobianFn<'_>>,
240        x0: &Array1<f64>,
241    ) -> Result<InteriorPointResult, OptimizeError>
242    where
243        F: FnMut(&ArrayView1<f64>) -> f64,
244        G: FnMut(&ArrayView1<f64>) -> Array1<f64> + ?Sized,
245    {
246        // Initialize variables
247        let mut x = x0.clone();
248        let mut s = Array1::ones(self.m_ineq); // Slack variables
249        let mut lambda_eq = Array1::zeros(self.m_eq);
250        let mut lambda_ineq = Array1::ones(self.m_ineq);
251        let mut barrier = self.options.initial_barrier;
252
253        // Initialize iteration counter
254        let mut iter = 0;
255
256        // Tracks the point and Lagrangian gradient from the previous
257        // iteration so the shared BFGS Hessian approximation can be updated
258        // from the actual (step, gradient-change) pair once one is available.
259        let mut prev_lag_grad: Option<Array1<f64>> = None;
260        let mut prev_x: Option<Array1<f64>> = None;
261
262        // Main interior point loop
263        while iter < self.options.max_iter {
264            // Evaluate functions and gradients
265            let f = fun(&x.view());
266            let g = grad(&x.view());
267            self.nfev += 2;
268
269            // Evaluate constraints and Jacobians
270            let (c_eq, j_eq) = if self.m_eq > 0 && eq_con.is_some() && eq_jac.is_some() {
271                let c = eq_con.as_mut().expect("Operation failed")(&x.view());
272                let j = eq_jac.as_mut().expect("Operation failed")(&x.view());
273                self.nfev += 2;
274                (Some(c), Some(j))
275            } else {
276                (None, None)
277            };
278
279            let (c_ineq, j_ineq) = if self.m_ineq > 0 && ineq_con.is_some() && ineq_jac.is_some() {
280                let c = ineq_con.as_mut().expect("Operation failed")(&x.view());
281                let j = ineq_jac.as_mut().expect("Operation failed")(&x.view());
282                self.nfev += 2;
283                (Some(c), Some(j))
284            } else {
285                (None, None)
286            };
287
288            // Refine the shared BFGS Hessian-of-the-Lagrangian approximation
289            // from the step and gradient change since the previous iterate
290            // (nothing to update from on the very first iteration).
291            let lag_grad = self.lagrangian_gradient(&g, &j_eq, &lambda_eq, &j_ineq, &lambda_ineq);
292            if let (Some(prev_grad), Some(prev_x)) = (prev_lag_grad.as_ref(), prev_x.as_ref()) {
293                let y = &lag_grad - prev_grad;
294                let step = &x - prev_x;
295                self.update_hessian_bfgs(&step, &y);
296            }
297            prev_lag_grad = Some(lag_grad);
298            prev_x = Some(x.clone());
299
300            // Check convergence
301            let (optimality, feasibility) = self.compute_convergence_measures(
302                &g,
303                &c_eq,
304                &c_ineq,
305                &j_eq,
306                &j_ineq,
307                &lambda_eq,
308                &lambda_ineq,
309                &s,
310            );
311
312            if optimality < self.options.tol && feasibility < self.options.feas_tol {
313                return Ok(InteriorPointResult {
314                    x,
315                    fun: f,
316                    lambda_eq: if self.m_eq > 0 { Some(lambda_eq) } else { None },
317                    lambda_ineq: if self.m_ineq > 0 {
318                        Some(lambda_ineq)
319                    } else {
320                        None
321                    },
322                    nit: iter,
323                    nfev: self.nfev,
324                    success: true,
325                    message: "Optimization terminated successfully.".to_string(),
326                    barrier,
327                    optimality,
328                });
329            }
330
331            // Compute search direction
332            let (dx, ds, dlambda_eq, dlambda_ineq) = if self.options.use_mehrotra {
333                self.compute_mehrotra_direction(
334                    &g,
335                    &c_eq,
336                    &c_ineq,
337                    &j_eq,
338                    &j_ineq,
339                    &s,
340                    &lambda_eq,
341                    &lambda_ineq,
342                    barrier,
343                )?
344            } else {
345                self.compute_newton_direction(
346                    &g,
347                    &c_eq,
348                    &c_ineq,
349                    &j_eq,
350                    &j_ineq,
351                    &s,
352                    &lambda_eq,
353                    &lambda_ineq,
354                    barrier,
355                )?
356            };
357
358            // Line search: fraction-to-boundary rule + Armijo backtracking on
359            // the l1-exact-penalty merit function (see `line_search` /
360            // `merit_value`), which rejects steps that reduce the objective
361            // while catastrophically worsening constraint feasibility --
362            // something a plain-objective Armijo check cannot detect.
363            let step_size = self.line_search(
364                fun,
365                eq_con.as_deref_mut(),
366                ineq_con.as_deref_mut(),
367                &x,
368                &s,
369                &lambda_ineq,
370                &dx,
371                &ds,
372                &dlambda_ineq,
373                &g,
374                &c_eq,
375                &c_ineq,
376                barrier,
377            )?;
378
379            // Update variables
380            x = &x + step_size * &dx;
381            if self.m_ineq > 0 {
382                s = &s + step_size * &ds;
383                lambda_ineq = &lambda_ineq + step_size * &dlambda_ineq;
384            }
385            if self.m_eq > 0 {
386                lambda_eq = &lambda_eq + step_size * &dlambda_eq;
387            }
388
389            // Update barrier parameter: adaptive Fiacco-McCormick /
390            // Mehrotra-style rule (see `update_barrier_parameter`), replacing
391            // the previous unconditional fixed-factor shrink.
392            barrier = self.update_barrier_parameter(barrier, &s, &lambda_ineq, optimality);
393
394            iter += 1;
395        }
396
397        let final_f = fun(&x.view());
398        self.nfev += 1;
399        let (final_optimality, final_feasibility) = self.compute_convergence_measures(
400            &grad(&x.view()),
401            &None,
402            &None,
403            &None,
404            &None,
405            &lambda_eq,
406            &lambda_ineq,
407            &s,
408        );
409        self.nfev += 1;
410
411        Ok(InteriorPointResult {
412            x,
413            fun: final_f,
414            lambda_eq: if self.m_eq > 0 { Some(lambda_eq) } else { None },
415            lambda_ineq: if self.m_ineq > 0 {
416                Some(lambda_ineq)
417            } else {
418                None
419            },
420            nit: iter,
421            nfev: self.nfev,
422            success: false,
423            message: "Maximum iterations reached.".to_string(),
424            barrier,
425            optimality: final_optimality,
426        })
427    }
428
429    /// Compute convergence measures
430    fn compute_convergence_measures(
431        &self,
432        g: &Array1<f64>,
433        c_eq: &Option<Array1<f64>>,
434        c_ineq: &Option<Array1<f64>>,
435        j_eq: &Option<Array2<f64>>,
436        j_ineq: &Option<Array2<f64>>,
437        lambda_eq: &Array1<f64>,
438        lambda_ineq: &Array1<f64>,
439        s: &Array1<f64>,
440    ) -> (f64, f64) {
441        let lag_grad = self.lagrangian_gradient(g, j_eq, lambda_eq, j_ineq, lambda_ineq);
442        let optimality = lag_grad.mapv(|x| x.abs()).sum();
443
444        // Feasibility
445        let mut feasibility = 0.0;
446
447        if let Some(c_eq) = c_eq {
448            feasibility += c_eq.mapv(|x| x.abs()).sum();
449        }
450
451        if let (Some(c_ineq), true) = (c_ineq, self.m_ineq > 0) {
452            feasibility += (c_ineq + s).mapv(|x| x.abs()).sum();
453        }
454
455        // Complementarity: the true (mu -> 0) KKT condition is s_i * lambda_i
456        // = 0, not s_i * lambda_i = barrier. Comparing against the *current*
457        // barrier is only meaningful as an inner-loop "have we converged
458        // this barrier subproblem" check; it is unsound as the overall
459        // termination criterion, and actively wrong whenever the search
460        // direction doesn't track the outer `barrier` value at all (as with
461        // Mehrotra's predictor-corrector, which adapts its own centering
462        // parameter internally every iteration and ignores the `barrier`
463        // argument entirely -- see `compute_mehrotra_direction`). Comparing
464        // against the stale outer `barrier` previously meant the solver
465        // could converge to the exact constrained optimum yet still report
466        // "maximum iterations reached" forever, because the barrier
467        // reduction schedule had frozen (its own trigger condition depends
468        // on `optimality`, which the unbounded growth of `lambda_ineq` at a
469        // tightly-active constraint could keep permanently large).
470        if self.m_ineq > 0 {
471            let complementarity = s
472                .iter()
473                .zip(lambda_ineq.iter())
474                .map(|(&si, &li)| (si * li).abs())
475                .sum::<f64>();
476            feasibility += complementarity;
477        }
478
479        (optimality, feasibility)
480    }
481
482    /// Adaptive barrier-parameter (`mu`) update.
483    ///
484    /// Replaces the previous unconditional fixed-factor shrink (`if
485    /// optimality < 10*barrier { barrier *= barrier_reduction }`, applied
486    /// every time it triggered regardless of how far the iterate actually
487    /// was from complementarity) with the standard Fiacco-McCormick /
488    /// Mehrotra-style rule `mu <- sigma * (s^T lambda_ineq) / m_ineq`: the
489    /// *average complementarity gap* at the current iterate, scaled by a
490    /// centering parameter `sigma in (0,1)`.
491    ///
492    /// `sigma` itself is adapted from how well-centered the current iterate
493    /// is, via the minimum-to-mean complementarity ratio `min_i(s_i*lambda_i)
494    /// / mean_i(s_i*lambda_i)` -- the same idea Mehrotra's predictor-corrector
495    /// uses to set its own centering parameter (`(mu_aff/mu)^3` in
496    /// `compute_mehrotra_direction`): a well-centered iterate (ratio near 1,
497    /// every pair close to the mean) can afford an aggressive (small) sigma,
498    /// while a poorly centered one (ratio near 0, some pair already collapsed
499    /// toward the boundary) is throttled toward `sigma -> 1` (mu left almost
500    /// unchanged) so the *next* iteration re-centers instead of racing mu
501    /// down while some complementarity pair is still badly out of balance.
502    ///
503    /// Safeguards (standard for this class of scheme; Nocedal & Wright,
504    /// *Numerical Optimization*, 2nd ed., §19.3; Wright, *Primal-Dual
505    /// Interior-Point Methods*, §5.4): `mu` never increases and never drops
506    /// below `options.min_barrier`; a degenerate complementarity gap (zero,
507    /// negative from floating-point noise, or non-finite) falls back to the
508    /// previous fixed-factor shrink rather than propagating NaN/garbage into
509    /// `mu`. As before, `mu` is only touched at all once the outer iterate's
510    /// optimality residual is already within an order of magnitude of it --
511    /// shrinking `mu` while the Newton step itself is still far from
512    /// converged just makes the *next* KKT system pathologically
513    /// ill-conditioned for no benefit.
514    fn update_barrier_parameter(
515        &self,
516        barrier: f64,
517        s: &Array1<f64>,
518        lambda_ineq: &Array1<f64>,
519        optimality: f64,
520    ) -> f64 {
521        if !(optimality < 10.0 * barrier) {
522            return barrier;
523        }
524
525        if self.m_ineq == 0 {
526            // No slacks/complementarity to track (equality-only or fully
527            // unconstrained problem): fall back to the previous fixed-factor
528            // shrink, gated the same way, purely so `barrier` still moves
529            // toward `min_barrier` at a bounded rate for reporting purposes.
530            return (barrier * self.options.barrier_reduction).max(self.options.min_barrier);
531        }
532
533        let m = self.m_ineq as f64;
534        let products: Vec<f64> = s
535            .iter()
536            .zip(lambda_ineq.iter())
537            .map(|(&si, &li)| si * li)
538            .collect();
539        let gap_mean = products.iter().sum::<f64>() / m;
540
541        if !(gap_mean > 0.0) || !gap_mean.is_finite() {
542            return (barrier * self.options.barrier_reduction).max(self.options.min_barrier);
543        }
544
545        let min_gap = products.iter().copied().fold(f64::INFINITY, f64::min);
546        let centering_ratio = (min_gap / gap_mean).clamp(0.0, 1.0);
547
548        // Mehrotra-style adaptive centering exponent (cubic damping, as in
549        // the classical `sigma = (mu_aff/mu)^3` heuristic): centered ->
550        // sigma near 0 (aggressive shrink); uncentered -> sigma near 1 (mu
551        // barely moves). Clamped away from the extremes so a single
552        // iteration can neither collapse mu numerically nor stall it
553        // completely.
554        let sigma = (1.0 - centering_ratio).powi(3).clamp(1e-3, 0.9);
555
556        let mu_candidate = sigma * gap_mean;
557
558        mu_candidate.clamp(self.options.min_barrier, barrier)
559    }
560
561    /// Compute Newton direction for the KKT system
562    fn compute_newton_direction(
563        &self,
564        g: &Array1<f64>,
565        c_eq: &Option<Array1<f64>>,
566        c_ineq: &Option<Array1<f64>>,
567        j_eq: &Option<Array2<f64>>,
568        j_ineq: &Option<Array2<f64>>,
569        s: &Array1<f64>,
570        lambda_eq: &Array1<f64>,
571        lambda_ineq: &Array1<f64>,
572        barrier: f64,
573    ) -> Result<NewtonDirectionResult, OptimizeError> {
574        // Build KKT system
575        let n_total = self.n + self.m_eq + 2 * self.m_ineq;
576        let mut kkt_matrix = Array2::zeros((n_total, n_total));
577        let mut rhs = Array1::zeros(n_total);
578
579        // Add regularization to ensure positive definiteness
580        let reg = self.options.regularization.max(1e-8);
581
582        // Hessian of the Lagrangian: the shared BFGS approximation refined
583        // across outer iterations (see `update_hessian_bfgs`), regularized
584        // on the diagonal for conditioning.
585        self.write_hessian_block(&mut kkt_matrix, reg);
586
587        // Stationarity residual: -∇_x L(x, λ) = -(g + J_eq^T λ_eq + J_ineq^T
588        // λ_ineq), NOT just -g. A previous version of this function used
589        // `-g` alone, silently dropping the existing multipliers'
590        // contribution to the Newton residual (correct only in the
591        // degenerate case λ = 0); since λ moves away from its initial value
592        // within the very first iteration, this made every subsequent
593        // Newton step solve a subtly wrong linear system and converge to a
594        // spurious fixed point instead of the true KKT point.
595        let lag_grad = self.lagrangian_gradient(g, j_eq, lambda_eq, j_ineq, lambda_ineq);
596        for i in 0..self.n {
597            rhs[i] = -lag_grad[i];
598        }
599
600        // Column/row layout for the remaining blocks must match how the
601        // solution vector is sliced below: [dx (n) | ds (m_ineq) | dλ_eq
602        // (m_eq) | dλ_ineq (m_ineq)]. `ds` occupies `[n, n+m_ineq)`
603        // (referenced directly as `self.n + i` below), so the dλ_eq/dλ_ineq
604        // row_offset must start *after* that block, not right after `dx` --
605        // starting it at `self.n` (as a previous version of this function
606        // did) made `ds`'s columns silently alias with `dλ_eq`'s (or, when
607        // `m_eq == 0`, with `dλ_ineq`'s), corrupting the KKT system for any
608        // problem with inequality constraints.
609        let mut row_offset = self.n + self.m_ineq;
610
611        // Equality constraints
612        if let (Some(j_eq), Some(c_eq), true) = (j_eq, c_eq, self.m_eq > 0) {
613            // J_eq^T in upper right
614            for i in 0..self.m_eq {
615                for j in 0..self.n {
616                    kkt_matrix[[j, row_offset + i]] = j_eq[[i, j]];
617                    kkt_matrix[[row_offset + i, j]] = j_eq[[i, j]];
618                }
619            }
620
621            // RHS for equality constraints
622            for i in 0..self.m_eq {
623                rhs[row_offset + i] = -c_eq[i];
624            }
625
626            row_offset += self.m_eq;
627        }
628
629        // Inequality constraints
630        if let (Some(j_ineq), Some(c_ineq), true) = (j_ineq, c_ineq, self.m_ineq > 0) {
631            // J_ineq^T in upper right (stationarity <-> dλ_ineq coupling)
632            for i in 0..self.m_ineq {
633                for j in 0..self.n {
634                    kkt_matrix[[j, row_offset + i]] = j_ineq[[i, j]];
635                    kkt_matrix[[row_offset + i, j]] = j_ineq[[i, j]];
636                }
637            }
638
639            // RHS for inequality constraints (unscaled: c_ineq(x) + s = 0)
640            for i in 0..self.m_ineq {
641                rhs[row_offset + i] = -(c_ineq[i] + s[i]);
642            }
643
644            // Complementarity conditions (row-scaled by 1/s_i for numerical
645            // stability: dividing `λ_i·ds_i + s_i·dλ_ineq_i = μ - s_iλ_i`
646            // through by s_i gives diagonal coefficient λ_i/s_i on ds_i and
647            // exactly 1 on dλ_ineq_i). The ds<->dλ_ineq coupling is
648            // symmetric identity in both this (scaled) complementarity row
649            // and the (unscaled) "+ds" term of the inequality-feasibility
650            // row above -- NOT s_i/λ_i, which would conflate the two rows'
651            // independent scalings.
652            for i in 0..self.m_ineq {
653                // Avoid division by very small slack variables
654                let s_i = s[i].max(1e-10);
655                let lambda_i = lambda_ineq[i].max(0.0);
656
657                kkt_matrix[[self.n + i, self.n + i]] = lambda_i / s_i + reg;
658                kkt_matrix[[self.n + i, row_offset + i]] = 1.0;
659                kkt_matrix[[row_offset + i, self.n + i]] = 1.0;
660                rhs[self.n + i] = barrier / s_i - lambda_i;
661            }
662        }
663
664        // Solve KKT system
665        let solution = solve(&kkt_matrix, &rhs)?;
666
667        // Extract components
668        let dx = solution
669            .slice(scirs2_core::ndarray::s![0..self.n])
670            .to_owned();
671        let ds = if self.m_ineq > 0 {
672            solution
673                .slice(scirs2_core::ndarray::s![self.n..self.n + self.m_ineq])
674                .to_owned()
675        } else {
676            Array1::zeros(0)
677        };
678
679        let mut offset = self.n + self.m_ineq;
680        let dlambda_eq = if self.m_eq > 0 {
681            solution
682                .slice(scirs2_core::ndarray::s![offset..offset + self.m_eq])
683                .to_owned()
684        } else {
685            Array1::zeros(0)
686        };
687
688        offset += self.m_eq;
689        let dlambda_ineq = if self.m_ineq > 0 {
690            solution
691                .slice(scirs2_core::ndarray::s![offset..offset + self.m_ineq])
692                .to_owned()
693        } else {
694            Array1::zeros(0)
695        };
696
697        Ok((dx, ds, dlambda_eq, dlambda_ineq))
698    }
699
700    /// Compute Mehrotra's predictor-corrector direction
701    ///
702    /// This implements the full Mehrotra algorithm with predictor and corrector steps:
703    /// 1. Compute predictor step (affine scaling direction)
704    /// 2. Estimate complementarity gap after predictor step
705    /// 3. Compute centering parameter based on gap reduction
706    /// 4. Compute corrector step combining predictor and centering
707    fn compute_mehrotra_direction(
708        &self,
709        g: &Array1<f64>,
710        c_eq: &Option<Array1<f64>>,
711        c_ineq: &Option<Array1<f64>>,
712        j_eq: &Option<Array2<f64>>,
713        j_ineq: &Option<Array2<f64>>,
714        s: &Array1<f64>,
715        lambda_eq: &Array1<f64>,
716        lambda_ineq: &Array1<f64>,
717        _barrier: f64,
718    ) -> Result<NewtonDirectionResult, OptimizeError> {
719        if self.m_ineq == 0 {
720            // No inequality constraints, use standard Newton direction
721            return self.compute_newton_direction(
722                g,
723                c_eq,
724                c_ineq,
725                j_eq,
726                j_ineq,
727                s,
728                lambda_eq,
729                lambda_ineq,
730                0.0,
731            );
732        }
733
734        // Step 1: Compute predictor step (affine scaling direction)
735        // This is the Newton step with zero _barrier parameter (affine scaling)
736        let (dx_aff, ds_aff, dlambda_eq_aff, dlambda_ineq_aff) = self
737            .compute_affine_scaling_direction(
738                g,
739                c_eq,
740                c_ineq,
741                j_eq,
742                j_ineq,
743                s,
744                lambda_eq,
745                lambda_ineq,
746            )?;
747
748        // Step 2: Compute maximum step lengths for predictor step
749        let alpha_primal_max = self.compute_max_step_primal(s, &ds_aff);
750        let alpha_dual_max = self.compute_max_step_dual(lambda_ineq, &dlambda_ineq_aff);
751
752        // Step 3: Estimate complementarity gap after predictor step
753        let current_gap = s
754            .iter()
755            .zip(lambda_ineq.iter())
756            .map(|(&si, &li)| si * li)
757            .sum::<f64>();
758        let mu = current_gap / (self.m_ineq as f64);
759
760        // Predict gap after affine step
761        let mut predicted_gap = 0.0;
762        for i in 0..self.m_ineq {
763            let s_new = s[i] + alpha_primal_max * ds_aff[i];
764            let lambda_new = lambda_ineq[i] + alpha_dual_max * dlambda_ineq_aff[i];
765            predicted_gap += s_new * lambda_new;
766        }
767
768        let mu_aff = predicted_gap / (self.m_ineq as f64);
769
770        // Step 4: Compute centering parameter using Mehrotra's heuristic
771        let sigma = if mu > 0.0 {
772            (mu_aff / mu).powi(3)
773        } else {
774            0.1 // Default centering when current gap is zero
775        };
776
777        // Ensure sigma is in reasonable bounds
778        let sigma = sigma.max(0.0).min(1.0);
779
780        // Step 5: Compute target _barrier parameter for corrector step
781        let sigma_mu = sigma * mu;
782
783        // Step 6: Compute corrector step
784        // This combines the predictor direction with centering and second-order corrections
785        self.compute_corrector_direction(
786            g,
787            c_eq,
788            c_ineq,
789            j_eq,
790            j_ineq,
791            s,
792            lambda_ineq,
793            &dx_aff,
794            &ds_aff,
795            &dlambda_ineq_aff,
796            sigma_mu,
797        )
798    }
799
800    /// Compute affine scaling direction (predictor step)
801    fn compute_affine_scaling_direction(
802        &self,
803        g: &Array1<f64>,
804        c_eq: &Option<Array1<f64>>,
805        c_ineq: &Option<Array1<f64>>,
806        j_eq: &Option<Array2<f64>>,
807        j_ineq: &Option<Array2<f64>>,
808        s: &Array1<f64>,
809        lambda_eq: &Array1<f64>,
810        lambda_ineq: &Array1<f64>,
811    ) -> Result<NewtonDirectionResult, OptimizeError> {
812        // Build KKT system for affine scaling (barrier = 0)
813        let n_total = self.n + self.m_eq + 2 * self.m_ineq;
814        let mut kkt_matrix = Array2::zeros((n_total, n_total));
815        let mut rhs = Array1::zeros(n_total);
816
817        let reg = self.options.regularization.max(1e-8);
818
819        // Hessian of the Lagrangian: shared BFGS approximation + regularization.
820        self.write_hessian_block(&mut kkt_matrix, reg);
821
822        // Stationarity residual: -∇_x L(x, λ), not just -g (see the matching
823        // comment in `compute_newton_direction`).
824        let lag_grad = self.lagrangian_gradient(g, j_eq, lambda_eq, j_ineq, lambda_ineq);
825        for i in 0..self.n {
826            rhs[i] = -lag_grad[i];
827        }
828
829        // See the matching comment in `compute_newton_direction`: this
830        // offset must start *after* the `ds` block at `[n, n+m_ineq)` (used
831        // directly as `self.n + i` below) to match how the solution vector
832        // is sliced in `extract_direction_components`.
833        let mut row_offset = self.n + self.m_ineq;
834
835        // Equality constraints
836        if let (Some(j_eq), Some(c_eq), true) = (j_eq, c_eq, self.m_eq > 0) {
837            for i in 0..self.m_eq {
838                for j in 0..self.n {
839                    kkt_matrix[[j, row_offset + i]] = j_eq[[i, j]];
840                    kkt_matrix[[row_offset + i, j]] = j_eq[[i, j]];
841                }
842            }
843
844            for i in 0..self.m_eq {
845                rhs[row_offset + i] = -c_eq[i];
846            }
847
848            row_offset += self.m_eq;
849        }
850
851        // Inequality constraints
852        if let (Some(j_ineq), Some(c_ineq), true) = (j_ineq, c_ineq, self.m_ineq > 0) {
853            for i in 0..self.m_ineq {
854                for j in 0..self.n {
855                    kkt_matrix[[j, row_offset + i]] = j_ineq[[i, j]];
856                    kkt_matrix[[row_offset + i, j]] = j_ineq[[i, j]];
857                }
858            }
859
860            for i in 0..self.m_ineq {
861                rhs[row_offset + i] = -(c_ineq[i] + s[i]);
862            }
863
864            // Complementarity conditions for affine scaling (no barrier
865            // term); see `compute_newton_direction` for why the ds<->dλ_ineq
866            // coupling must be the identity 1.0, not s_i/λ_i.
867            for i in 0..self.m_ineq {
868                let s_i = s[i].max(1e-10);
869                let lambda_i = lambda_ineq[i].max(0.0);
870
871                kkt_matrix[[self.n + i, self.n + i]] = lambda_i / s_i + reg;
872                kkt_matrix[[self.n + i, row_offset + i]] = 1.0;
873                kkt_matrix[[row_offset + i, self.n + i]] = 1.0;
874
875                // RHS for affine scaling: -s_i * lambda_i (no barrier term),
876                // row-scaled by 1/s_i like the diagonal above.
877                rhs[self.n + i] = -lambda_i;
878            }
879        }
880
881        // Solve KKT system
882        let solution = solve(&kkt_matrix, &rhs)?;
883
884        // Extract components
885        self.extract_direction_components(&solution)
886    }
887
888    /// Compute corrector direction combining predictor and centering
889    fn compute_corrector_direction(
890        &self,
891        self_g: &Array1<f64>,
892        _c_eq: &Option<Array1<f64>>,
893        _c_ineq: &Option<Array1<f64>>,
894        j_eq: &Option<Array2<f64>>,
895        j_ineq: &Option<Array2<f64>>,
896        s: &Array1<f64>,
897        lambda_ineq: &Array1<f64>,
898        dx_aff: &Array1<f64>,
899        ds_aff: &Array1<f64>,
900        dlambda_ineq_aff: &Array1<f64>,
901        sigma_mu: f64,
902    ) -> Result<NewtonDirectionResult, OptimizeError> {
903        // Build KKT system for corrector step
904        let n_total = self.n + self.m_eq + 2 * self.m_ineq;
905        let mut kkt_matrix = Array2::zeros((n_total, n_total));
906        let mut rhs = Array1::zeros(n_total);
907
908        let reg = self.options.regularization.max(1e-8);
909
910        // Hessian of the Lagrangian: shared BFGS approximation + regularization.
911        self.write_hessian_block(&mut kkt_matrix, reg);
912
913        // Gradient of Lagrangian (zero for corrector)
914        for i in 0..self.n {
915            rhs[i] = 0.0;
916        }
917
918        // See the matching comment in `compute_newton_direction`: this
919        // offset must start *after* the `ds` block at `[n, n+m_ineq)` (used
920        // directly as `self.n + i` below) to match how the solution vector
921        // is sliced in `extract_direction_components`.
922        let mut row_offset = self.n + self.m_ineq;
923
924        // Equality constraints (zero RHS for corrector)
925        if let (Some(j_eq), true) = (j_eq, self.m_eq > 0) {
926            for i in 0..self.m_eq {
927                for j in 0..self.n {
928                    kkt_matrix[[j, row_offset + i]] = j_eq[[i, j]];
929                    kkt_matrix[[row_offset + i, j]] = j_eq[[i, j]];
930                }
931            }
932
933            for i in 0..self.m_eq {
934                rhs[row_offset + i] = 0.0;
935            }
936
937            row_offset += self.m_eq;
938        }
939
940        // Inequality constraints (zero RHS for corrector)
941        if let (Some(j_ineq), true) = (j_ineq, self.m_ineq > 0) {
942            for i in 0..self.m_ineq {
943                for j in 0..self.n {
944                    kkt_matrix[[j, row_offset + i]] = j_ineq[[i, j]];
945                    kkt_matrix[[row_offset + i, j]] = j_ineq[[i, j]];
946                }
947            }
948
949            for i in 0..self.m_ineq {
950                rhs[row_offset + i] = 0.0;
951            }
952
953            // Complementarity conditions with centering and second-order
954            // corrections; see `compute_newton_direction` for why the
955            // ds<->dλ_ineq coupling must be the identity 1.0, not s_i/λ_i.
956            for i in 0..self.m_ineq {
957                let s_i = s[i].max(1e-10);
958                let lambda_i = lambda_ineq[i].max(0.0);
959
960                kkt_matrix[[self.n + i, self.n + i]] = lambda_i / s_i + reg;
961                kkt_matrix[[self.n + i, row_offset + i]] = 1.0;
962                kkt_matrix[[row_offset + i, self.n + i]] = 1.0;
963
964                // RHS includes centering term and second-order correction
965                // sigma_mu - ds_aff[i] * dlambda_ineq_aff[i]
966                let correction = sigma_mu - ds_aff[i] * dlambda_ineq_aff[i];
967                rhs[self.n + i] = correction / s_i;
968            }
969        }
970
971        // Solve KKT system
972        let solution = solve(&kkt_matrix, &rhs)?;
973
974        // Extract components and combine with predictor step
975        let (dx_cor, ds_cor, dlambda_eq_cor, dlambda_ineq_cor) =
976            self.extract_direction_components(&solution)?;
977
978        // Combine predictor and corrector steps
979        let dx_final = dx_aff + &dx_cor;
980        let ds_final = ds_aff + &ds_cor;
981        let dlambda_eq_final = &Array1::zeros(self.m_eq) + &dlambda_eq_cor;
982        let dlambda_ineq_final = dlambda_ineq_aff + &dlambda_ineq_cor;
983
984        Ok((dx_final, ds_final, dlambda_eq_final, dlambda_ineq_final))
985    }
986
987    /// Extract direction components from KKT solution
988    fn extract_direction_components(
989        &self,
990        solution: &Array1<f64>,
991    ) -> Result<NewtonDirectionResult, OptimizeError> {
992        let dx = solution
993            .slice(scirs2_core::ndarray::s![0..self.n])
994            .to_owned();
995        let ds = if self.m_ineq > 0 {
996            solution
997                .slice(scirs2_core::ndarray::s![self.n..self.n + self.m_ineq])
998                .to_owned()
999        } else {
1000            Array1::zeros(0)
1001        };
1002
1003        let mut offset = self.n + self.m_ineq;
1004        let dlambda_eq = if self.m_eq > 0 {
1005            solution
1006                .slice(scirs2_core::ndarray::s![offset..offset + self.m_eq])
1007                .to_owned()
1008        } else {
1009            Array1::zeros(0)
1010        };
1011
1012        offset += self.m_eq;
1013        let dlambda_ineq = if self.m_ineq > 0 {
1014            solution
1015                .slice(scirs2_core::ndarray::s![offset..offset + self.m_ineq])
1016                .to_owned()
1017        } else {
1018            Array1::zeros(0)
1019        };
1020
1021        Ok((dx, ds, dlambda_eq, dlambda_ineq))
1022    }
1023
1024    /// Compute maximum step length for primal variables
1025    fn compute_max_step_primal(&self, s: &Array1<f64>, ds: &Array1<f64>) -> f64 {
1026        if self.m_ineq == 0 {
1027            return 1.0;
1028        }
1029
1030        let tau = 0.995; // Fraction to boundary parameter
1031        let mut alpha = 1.0;
1032
1033        for i in 0..self.m_ineq {
1034            if ds[i] < 0.0 {
1035                alpha = f64::min(alpha, -tau * s[i] / ds[i]);
1036            }
1037        }
1038
1039        alpha.max(0.0).min(1.0)
1040    }
1041
1042    /// Compute maximum step length for dual variables
1043    fn compute_max_step_dual(&self, lambda_ineq: &Array1<f64>, dlambda_ineq: &Array1<f64>) -> f64 {
1044        if self.m_ineq == 0 {
1045            return 1.0;
1046        }
1047
1048        let tau = 0.995; // Fraction to boundary parameter
1049        let mut alpha = 1.0;
1050
1051        for i in 0..self.m_ineq {
1052            if dlambda_ineq[i] < 0.0 {
1053                alpha = f64::min(alpha, -tau * lambda_ineq[i] / dlambda_ineq[i]);
1054            }
1055        }
1056
1057        alpha.max(0.0).min(1.0)
1058    }
1059
1060    /// L1 norm of the constraint violation at a point: `||c_eq||_1 +
1061    /// ||c_ineq + s||_1`. Shared by [`Self::merit_value`] and the
1062    /// penalty-parameter safeguard in [`Self::line_search`].
1063    fn l1_infeasibility(c_eq: &Option<Array1<f64>>, c_ineq_plus_s: &Option<Array1<f64>>) -> f64 {
1064        let mut infeas = 0.0;
1065        if let Some(c_eq) = c_eq {
1066            infeas += c_eq.iter().map(|v| v.abs()).sum::<f64>();
1067        }
1068        if let Some(c) = c_ineq_plus_s {
1069            infeas += c.iter().map(|v| v.abs()).sum::<f64>();
1070        }
1071        infeas
1072    }
1073
1074    /// The interior-point line-search merit function `phi_mu,nu(x, s) = f(x)
1075    /// - mu * sum_i ln(s_i) + nu * (||c_eq(x)||_1 + ||c_ineq(x) + s||_1)`:
1076    /// the standard l1-exact-penalty merit function for primal-dual
1077    /// interior-point line searches (Nocedal & Wright, *Numerical
1078    /// Optimization*, 2nd ed., §19.6), blending the log-barrier subproblem
1079    /// objective with an exact l1 penalty on constraint violation.
1080    fn merit_value(
1081        &self,
1082        f: f64,
1083        s: &Array1<f64>,
1084        barrier: f64,
1085        c_eq: &Option<Array1<f64>>,
1086        c_ineq_plus_s: &Option<Array1<f64>>,
1087        nu: f64,
1088    ) -> f64 {
1089        let mut phi = f;
1090
1091        if self.m_ineq > 0 {
1092            phi -= barrier * s.iter().map(|&si| si.max(1e-300).ln()).sum::<f64>();
1093        }
1094
1095        phi + nu * Self::l1_infeasibility(c_eq, c_ineq_plus_s)
1096    }
1097
1098    /// Line search combining the fraction-to-boundary rule with Armijo
1099    /// backtracking on an l1-exact-penalty merit function.
1100    ///
1101    /// `alpha` is first capped, as before, so that `s + alpha*ds` and
1102    /// `lambda_ineq + alpha*dlambda_ineq` stay strictly positive (the
1103    /// fraction-to-boundary rule). Within that cap, `alpha` is chosen by
1104    /// Armijo backtracking on the merit function `phi_mu,nu(x, s)`
1105    /// ([`Self::merit_value`]) rather than on the raw objective alone.
1106    ///
1107    /// Previously this method accepted any step satisfying `f_new <= f0 +
1108    /// alpha_param * alpha * dx.dot(dx)`. Since `dx.dot(dx) >= 0` always, that
1109    /// right-hand side is *greater* than `f0`, so the check accepted any step
1110    /// whose objective merely didn't increase by more than an arbitrary
1111    /// positive slack -- not a real sufficient-decrease test at all -- and
1112    /// entirely regardless of constraint feasibility (the objective was the
1113    /// only thing ever evaluated at the trial point). A step that drove the
1114    /// objective down while catastrophically violating the constraints was
1115    /// therefore always accepted outright, at the largest fraction-to-
1116    /// boundary step available. The merit-function check rejects such steps:
1117    /// growing the l1 constraint-violation term is enough to fail the Armijo
1118    /// condition on `phi`, forcing backtracking instead (see
1119    /// `test_line_search_rejects_catastrophic_constraint_violation`).
1120    ///
1121    /// The penalty weight `nu` (`self.merit_penalty`) is the standard
1122    /// exact-penalty safeguard (Nocedal & Wright eq. 18.36): raised, when
1123    /// necessary, so that the supplied `(dx, ds)` -- which, as a genuine
1124    /// Newton/Mehrotra step from this module's KKT solves, (approximately)
1125    /// satisfies the linearized feasibility equations `J_eq dx = -c_eq` and
1126    /// `J_ineq dx + ds = -(c_ineq + s)` -- is guaranteed to be a descent
1127    /// direction of `phi`. `nu` is kept monotonically nondecreasing across
1128    /// calls (i.e. across outer iterations) to avoid cycling.
1129    #[allow(clippy::too_many_arguments)]
1130    fn line_search<F>(
1131        &mut self,
1132        fun: &mut F,
1133        mut eq_con: Option<&mut EqualityConstraintFn<'_>>,
1134        mut ineq_con: Option<&mut InequalityConstraintFn<'_>>,
1135        x: &Array1<f64>,
1136        s: &Array1<f64>,
1137        lambda_ineq: &Array1<f64>,
1138        dx: &Array1<f64>,
1139        ds: &Array1<f64>,
1140        dlambda_ineq: &Array1<f64>,
1141        g: &Array1<f64>,
1142        c_eq: &Option<Array1<f64>>,
1143        c_ineq: &Option<Array1<f64>>,
1144        barrier: f64,
1145    ) -> Result<f64, OptimizeError>
1146    where
1147        F: FnMut(&ArrayView1<f64>) -> f64,
1148    {
1149        // Fraction to boundary rule
1150        let tau = 0.995;
1151        let mut alpha_primal = 1.0;
1152        let mut alpha_dual = 1.0;
1153
1154        // Maximum step to maintain positivity of slack variables
1155        if self.m_ineq > 0 {
1156            for i in 0..self.m_ineq {
1157                if ds[i] < 0.0 {
1158                    alpha_primal = f64::min(alpha_primal, -tau * s[i] / ds[i]);
1159                }
1160                if dlambda_ineq[i] < 0.0 {
1161                    alpha_dual = f64::min(alpha_dual, -tau * lambda_ineq[i] / dlambda_ineq[i]);
1162                }
1163            }
1164        }
1165
1166        let alpha_max = f64::min(alpha_primal, alpha_dual).clamp(0.0, 1.0);
1167
1168        // Merit function value and its directional derivative at the current
1169        // point, along (dx, ds).
1170        let f0 = fun(&x.view());
1171        self.nfev += 1;
1172
1173        let c_ineq_plus_s0 = c_ineq.as_ref().map(|c| c + s);
1174        let infeas0 = Self::l1_infeasibility(c_eq, &c_ineq_plus_s0);
1175
1176        // D(f - mu*sum(ln s); (dx,ds)) = g.dot(dx) - mu*sum(ds_i/s_i).
1177        let barrier_dir_deriv = g.dot(dx)
1178            - if self.m_ineq > 0 {
1179                barrier
1180                    * s.iter()
1181                        .zip(ds.iter())
1182                        .map(|(&si, &dsi)| dsi / si.max(1e-300))
1183                        .sum::<f64>()
1184            } else {
1185                0.0
1186            };
1187
1188        // Exact-penalty safeguard (Nocedal & Wright eq. 18.36): raise `nu`
1189        // just enough that `(dx, ds)` is a descent direction of `phi`, given
1190        // that the l1 term's directional derivative is `-infeas0` whenever
1191        // the linearized feasibility equations are (approximately) satisfied
1192        // -- true for the Newton/Mehrotra steps this method is called with.
1193        let margin = 0.1;
1194        if infeas0 > 1e-12 {
1195            let nu_required = (barrier_dir_deriv / ((1.0 - margin) * infeas0)).max(0.0);
1196            if self.merit_penalty < nu_required {
1197                self.merit_penalty = nu_required + 1.0;
1198            }
1199        }
1200        let nu = self.merit_penalty;
1201
1202        // Defensive fallback: a legitimate descent direction always makes
1203        // this negative (see above), but guard against surprises (e.g.
1204        // severe regularization-induced KKT-solve error) forcing a spurious
1205        // non-negative value back into an overly permissive Armijo
1206        // threshold -- exactly the failure mode this rewrite fixes.
1207        let dir_deriv = {
1208            let d = barrier_dir_deriv - nu * infeas0;
1209            if d.is_finite() && d < 0.0 {
1210                d
1211            } else {
1212                -1e-10 * (1.0 + f0.abs())
1213            }
1214        };
1215
1216        let phi0 = self.merit_value(f0, s, barrier, c_eq, &c_ineq_plus_s0, nu);
1217
1218        // Only re-evaluate a constraint at trial points if it was actually
1219        // supplied *and* treated as active this iteration (i.e. `c_eq`/
1220        // `c_ineq` at the current point is `Some`, matching how the KKT
1221        // system that produced `dx`/`ds` was built).
1222        let eval_eq = c_eq.is_some();
1223        let eval_ineq = c_ineq.is_some();
1224
1225        let mut alpha = alpha_max;
1226
1227        for _ in 0..self.options.max_ls_iter {
1228            let x_new = x + alpha * dx;
1229            let f_new = fun(&x_new.view());
1230            self.nfev += 1;
1231
1232            let s_new = if self.m_ineq > 0 {
1233                s + alpha * ds
1234            } else {
1235                s.clone()
1236            };
1237
1238            let c_eq_new: Option<Array1<f64>> = if eval_eq {
1239                if let Some(f) = eq_con.as_mut() {
1240                    self.nfev += 1;
1241                    Some(f(&x_new.view()))
1242                } else {
1243                    None
1244                }
1245            } else {
1246                None
1247            };
1248
1249            let c_ineq_new: Option<Array1<f64>> = if eval_ineq {
1250                if let Some(f) = ineq_con.as_mut() {
1251                    self.nfev += 1;
1252                    Some(f(&x_new.view()))
1253                } else {
1254                    None
1255                }
1256            } else {
1257                None
1258            };
1259            let c_ineq_plus_s_new = c_ineq_new.map(|c| c + &s_new);
1260
1261            let phi_new =
1262                self.merit_value(f_new, &s_new, barrier, &c_eq_new, &c_ineq_plus_s_new, nu);
1263
1264            if phi_new <= phi0 + self.options.alpha * alpha * dir_deriv {
1265                return Ok(alpha);
1266            }
1267
1268            alpha *= self.options.beta;
1269        }
1270
1271        Ok(alpha)
1272    }
1273}
1274
1275/// Solve linear system using LU decomposition from scirs2-linalg
1276#[allow(dead_code)]
1277fn solve(a: &Array2<f64>, b: &Array1<f64>) -> Result<Array1<f64>, OptimizeError> {
1278    use scirs2_linalg::solve;
1279
1280    solve(&a.view(), &b.view(), None)
1281        .map_err(|e| OptimizeError::ComputationError(format!("Linear system solve failed: {}", e)))
1282}
1283
1284/// Minimize a function subject to constraints using interior point method
1285///
1286/// Both the equality and inequality constraint callbacks (and their
1287/// Jacobians) are wired all the way through to the solver: previously this
1288/// convenience wrapper used `eq_con`/`ineq_con` only to *count* constraints
1289/// (to size the KKT system) but then unconditionally passed `None` for every
1290/// constraint callback into [`InteriorPointSolver::solve`], so any supplied
1291/// constraint was silently never enforced. When a constraint is supplied
1292/// without its own Jacobian, a forward finite-difference approximation of
1293/// that specific constraint is used instead of dropping it.
1294///
1295/// If `grad_fn` is `None`, the gradient is estimated via forward finite
1296/// differences (as before); when supplied, the analytical gradient is used
1297/// directly instead of unconditionally re-deriving it via finite
1298/// differences.
1299#[allow(dead_code, clippy::too_many_arguments)]
1300pub fn minimize_interior_point<F, G, H, J>(
1301    fun: F,
1302    grad_fn: Option<G>,
1303    x0: Array1<f64>,
1304    eq_con: Option<H>,
1305    eq_jac: Option<J>,
1306    ineq_con: Option<H>,
1307    ineq_jac: Option<J>,
1308    options: Option<InteriorPointOptions>,
1309) -> Result<OptimizeResult<f64>, OptimizeError>
1310where
1311    F: FnMut(&ArrayView1<f64>) -> f64 + Clone,
1312    G: FnMut(&ArrayView1<f64>) -> Array1<f64>,
1313    H: FnMut(&ArrayView1<f64>) -> Array1<f64>,
1314    J: FnMut(&ArrayView1<f64>) -> Array2<f64>,
1315{
1316    let options = options.unwrap_or_default();
1317    let n = x0.len();
1318
1319    // Each constraint callback may return any number of constraint rows (it
1320    // is `Array1`-valued, not scalar); determine the real dimensionality by
1321    // evaluating it once at `x0` instead of assuming exactly one row
1322    // whenever a callback is present. Getting this wrong silently
1323    // undersizes `s`/`lambda_ineq`/the KKT system versus what `j_ineq`
1324    // actually is, which previously went undetected only because the
1325    // constraint callbacks were never even passed to the solver (see below).
1326    let mut eq_con = eq_con;
1327    let mut ineq_con = ineq_con;
1328    let m_eq = match eq_con.as_mut() {
1329        Some(f) => f(&x0.view()).len(),
1330        None => 0,
1331    };
1332    let m_ineq = match ineq_con.as_mut() {
1333        Some(f) => f(&x0.view()).len(),
1334        None => 0,
1335    };
1336
1337    // Create solver
1338    let mut solver = InteriorPointSolver::new(n, m_eq, m_ineq, &options);
1339
1340    // Prepare function and gradient: use the caller's analytical gradient
1341    // when supplied, falling back to forward finite differences otherwise.
1342    let mut fun_mut = fun.clone();
1343    let eps = 1e-8;
1344    let mut fd_fun = fun.clone();
1345    let mut grad_owned: Box<dyn FnMut(&ArrayView1<f64>) -> Array1<f64>> = match grad_fn {
1346        Some(g) => Box::new(g),
1347        None => Box::new(move |x: &ArrayView1<f64>| finite_diff_gradient(&mut fd_fun, x, eps)),
1348    };
1349
1350    // Each constraint callback is shared behind `Rc<RefCell<_>>` so it can be
1351    // evaluated both for its own value and, when no analytical Jacobian is
1352    // supplied, again at perturbed points for a finite-difference Jacobian --
1353    // without requiring `H: Clone`.
1354    let eq_con_shared = eq_con.map(|f| Rc::new(RefCell::new(f)));
1355    let ineq_con_shared = ineq_con.map(|f| Rc::new(RefCell::new(f)));
1356
1357    let mut eq_con_owned: Option<Box<dyn FnMut(&ArrayView1<f64>) -> Array1<f64>>> =
1358        eq_con_shared.as_ref().map(|shared| {
1359            let shared = Rc::clone(shared);
1360            Box::new(move |x: &ArrayView1<f64>| shared.borrow_mut()(x))
1361                as Box<dyn FnMut(&ArrayView1<f64>) -> Array1<f64>>
1362        });
1363    let mut eq_jac_owned: Option<Box<dyn FnMut(&ArrayView1<f64>) -> Array2<f64>>> =
1364        match (eq_jac, eq_con_shared.as_ref()) {
1365            (Some(j), _) => Some(Box::new(j)),
1366            (None, Some(shared)) => {
1367                let shared = Rc::clone(shared);
1368                Some(Box::new(move |x: &ArrayView1<f64>| {
1369                    let mut con = |xv: &ArrayView1<f64>| shared.borrow_mut()(xv);
1370                    finite_diff_jacobian_fn(&mut con, x, eps)
1371                }))
1372            }
1373            (None, None) => None,
1374        };
1375
1376    let mut ineq_con_owned: Option<Box<dyn FnMut(&ArrayView1<f64>) -> Array1<f64>>> =
1377        ineq_con_shared.as_ref().map(|shared| {
1378            let shared = Rc::clone(shared);
1379            Box::new(move |x: &ArrayView1<f64>| shared.borrow_mut()(x))
1380                as Box<dyn FnMut(&ArrayView1<f64>) -> Array1<f64>>
1381        });
1382    let mut ineq_jac_owned: Option<Box<dyn FnMut(&ArrayView1<f64>) -> Array2<f64>>> =
1383        match (ineq_jac, ineq_con_shared.as_ref()) {
1384            (Some(j), _) => Some(Box::new(j)),
1385            (None, Some(shared)) => {
1386                let shared = Rc::clone(shared);
1387                Some(Box::new(move |x: &ArrayView1<f64>| {
1388                    let mut con = |xv: &ArrayView1<f64>| shared.borrow_mut()(xv);
1389                    finite_diff_jacobian_fn(&mut con, x, eps)
1390                }))
1391            }
1392            (None, None) => None,
1393        };
1394
1395    let result: InteriorPointResult = solver.solve(
1396        &mut fun_mut,
1397        &mut *grad_owned,
1398        eq_con_owned.as_deref_mut(),
1399        eq_jac_owned.as_deref_mut(),
1400        ineq_con_owned.as_deref_mut(),
1401        ineq_jac_owned.as_deref_mut(),
1402        &x0,
1403    )?;
1404
1405    Ok(OptimizeResult {
1406        x: result.x,
1407        fun: result.fun,
1408        nit: result.nit,
1409        func_evals: result.nfev,
1410        nfev: result.nfev,
1411        success: result.success,
1412        message: result.message,
1413        jacobian: None,
1414        hessian: None,
1415    })
1416}
1417
1418/// Compute gradient using finite differences
1419#[allow(dead_code)]
1420fn finite_diff_gradient<F>(fun: &mut F, x: &ArrayView1<f64>, eps: f64) -> Array1<f64>
1421where
1422    F: FnMut(&ArrayView1<f64>) -> f64,
1423{
1424    let n = x.len();
1425    let mut grad = Array1::zeros(n);
1426    let f0 = fun(x);
1427    let mut x_pert = x.to_owned();
1428
1429    for i in 0..n {
1430        let h = eps * (1.0 + x[i].abs());
1431        x_pert[i] = x[i] + h;
1432        let f_plus = fun(&x_pert.view());
1433        grad[i] = (f_plus - f0) / h;
1434        x_pert[i] = x[i];
1435    }
1436
1437    grad
1438}
1439
1440/// Compute the Jacobian of a single vector-valued constraint function via
1441/// forward finite differences (one column per input dimension).
1442#[allow(dead_code)]
1443fn finite_diff_jacobian_fn<C>(con: &mut C, x: &ArrayView1<f64>, eps: f64) -> Array2<f64>
1444where
1445    C: FnMut(&ArrayView1<f64>) -> Array1<f64>,
1446{
1447    let n = x.len();
1448    let f0 = con(x);
1449    let m = f0.len();
1450    let mut jac = Array2::zeros((m, n));
1451    let mut x_pert = x.to_owned();
1452
1453    for j in 0..n {
1454        let h = eps * (1.0 + x[j].abs());
1455        x_pert[j] = x[j] + h;
1456        let f_plus = con(&x_pert.view());
1457        for i in 0..m {
1458            jac[[i, j]] = (f_plus[i] - f0[i]) / h;
1459        }
1460        x_pert[j] = x[j];
1461    }
1462
1463    jac
1464}
1465
1466/// Compute the Jacobian of multiple constraints.
1467///
1468/// For each constraint, the analytical Jacobian attached via
1469/// [`Constraint::with_jacobian`] (issue #127) is used when present; otherwise a
1470/// forward finite-difference approximation is computed. An analytical Jacobian
1471/// of the wrong length falls back to finite differences (no panic, no unwrap).
1472#[allow(dead_code)]
1473fn finite_diff_jacobian_constraints(
1474    constraints: &[&Constraint],
1475    x: &ArrayView1<f64>,
1476    eps: f64,
1477) -> Array2<f64> {
1478    let n = x.len();
1479    let m = constraints.len();
1480    let mut jac = Array2::zeros((m, n));
1481    let x_slice = x.as_slice().expect("Operation failed");
1482
1483    // Evaluate constraints at current point (reused by the finite-difference path)
1484    let f0: Vec<f64> = constraints.iter().map(|c| (c.fun)(x_slice)).collect();
1485
1486    // Track which rows still need finite differences (analytical not available)
1487    let mut needs_fd = vec![true; m];
1488    for (i, c) in constraints.iter().enumerate() {
1489        if let Some(ref jac_fn) = c.jac {
1490            let grad = jac_fn(x_slice);
1491            if grad.len() == n {
1492                for j in 0..n {
1493                    jac[[i, j]] = grad[j];
1494                }
1495                needs_fd[i] = false;
1496            }
1497        }
1498    }
1499
1500    if needs_fd.iter().any(|&b| b) {
1501        let mut x_pert = x.to_owned();
1502
1503        for j in 0..n {
1504            let h = eps * (1.0 + x[j].abs());
1505            x_pert[j] = x[j] + h;
1506            let x_pert_slice = x_pert.as_slice().expect("Operation failed");
1507
1508            // Evaluate constraints at perturbed point (FD rows only)
1509            for i in 0..m {
1510                if needs_fd[i] {
1511                    let f_plus = (constraints[i].fun)(x_pert_slice);
1512                    jac[[i, j]] = (f_plus - f0[i]) / h;
1513                }
1514            }
1515
1516            x_pert[j] = x[j]; // Reset
1517        }
1518    }
1519
1520    jac
1521}
1522
1523/// Minimize a function subject to constraints using interior point method
1524/// with constraint conversion from general format
1525#[allow(dead_code)]
1526pub fn minimize_interior_point_constrained<F>(
1527    func: F,
1528    x0: Array1<f64>,
1529    constraints: &[Constraint],
1530    options: Option<InteriorPointOptions>,
1531) -> Result<OptimizeResult<f64>, OptimizeError>
1532where
1533    F: Fn(&[f64]) -> f64 + Clone,
1534{
1535    let options = options.unwrap_or_default();
1536    let n = x0.len();
1537
1538    // Separate constraints by type
1539    let eq_constraints: Vec<_> = constraints
1540        .iter()
1541        .filter(|c| c.kind == ConstraintKind::Equality && !c.is_bounds())
1542        .collect();
1543    let ineq_constraints: Vec<_> = constraints
1544        .iter()
1545        .filter(|c| c.kind == ConstraintKind::Inequality && !c.is_bounds())
1546        .collect();
1547
1548    let m_eq = eq_constraints.len();
1549    let m_ineq = ineq_constraints.len();
1550
1551    // Create solver with proper constraint counts
1552    let mut solver = InteriorPointSolver::new(n, m_eq, m_ineq, &options);
1553
1554    // Prepare function and gradient
1555    let func_clone = func.clone();
1556    let mut fun_mut =
1557        move |x: &ArrayView1<f64>| -> f64 { func(x.as_slice().expect("Operation failed")) };
1558    let mut grad_mut = move |x: &ArrayView1<f64>| -> Array1<f64> {
1559        let mut fun_fd =
1560            |x: &ArrayView1<f64>| -> f64 { func_clone(x.as_slice().expect("Operation failed")) };
1561        finite_diff_gradient(&mut fun_fd, x, 1e-8)
1562    };
1563
1564    // Prepare constraint functions and Jacobians if needed.
1565    //
1566    // The constraint callables are boxed trait objects (issue #126) and cannot
1567    // be copied out of the `Constraint` slice. Instead, each closure captures a
1568    // shared reference to the partitioned constraint list and evaluates the
1569    // constraints in place, preserving the original numerical behaviour. The
1570    // closures borrow `eq_constraints` / `ineq_constraints` (and through them
1571    // `constraints`), so they are scoped to this function via `+ '_`.
1572    #[allow(clippy::type_complexity)]
1573    let mut eq_con_mut: Option<Box<dyn FnMut(&ArrayView1<f64>) -> Array1<f64> + '_>> = if m_eq > 0 {
1574        Some(Box::new(|x: &ArrayView1<f64>| -> Array1<f64> {
1575            let x_slice = x.as_slice().expect("Operation failed");
1576            Array1::from_vec(eq_constraints.iter().map(|c| (c.fun)(x_slice)).collect())
1577        }))
1578    } else {
1579        None
1580    };
1581
1582    #[allow(clippy::type_complexity)]
1583    let mut eq_jac_mut: Option<Box<dyn FnMut(&ArrayView1<f64>) -> Array2<f64> + '_>> = if m_eq > 0 {
1584        Some(Box::new(|x: &ArrayView1<f64>| -> Array2<f64> {
1585            let eps = 1e-8;
1586            finite_diff_jacobian_constraints(&eq_constraints, x, eps)
1587        }))
1588    } else {
1589        None
1590    };
1591
1592    #[allow(clippy::type_complexity)]
1593    let mut ineq_con_mut: Option<Box<dyn FnMut(&ArrayView1<f64>) -> Array1<f64> + '_>> =
1594        if m_ineq > 0 {
1595            Some(Box::new(|x: &ArrayView1<f64>| -> Array1<f64> {
1596                let x_slice = x.as_slice().expect("Operation failed");
1597                Array1::from_vec(ineq_constraints.iter().map(|c| (c.fun)(x_slice)).collect())
1598            }))
1599        } else {
1600            None
1601        };
1602
1603    #[allow(clippy::type_complexity)]
1604    let mut ineq_jac_mut: Option<Box<dyn FnMut(&ArrayView1<f64>) -> Array2<f64> + '_>> =
1605        if m_ineq > 0 {
1606            Some(Box::new(|x: &ArrayView1<f64>| -> Array2<f64> {
1607                let eps = 1e-8;
1608                finite_diff_jacobian_constraints(&ineq_constraints, x, eps)
1609            }))
1610        } else {
1611            None
1612        };
1613
1614    // Solve with constraints
1615    let result = solver.solve(
1616        &mut fun_mut,
1617        &mut grad_mut,
1618        eq_con_mut.as_mut().map(|f| f.as_mut()),
1619        eq_jac_mut.as_mut().map(|f| f.as_mut()),
1620        ineq_con_mut.as_mut().map(|f| f.as_mut()),
1621        ineq_jac_mut.as_mut().map(|f| f.as_mut()),
1622        &x0,
1623    )?;
1624
1625    // Handle bounds constraints separately if present
1626    let bounds_constraints: Vec<_> = constraints.iter().filter(|c| c.is_bounds()).collect();
1627
1628    if !bounds_constraints.is_empty() {
1629        eprintln!("Warning: Box constraints (bounds) are not yet fully integrated with interior point method");
1630    }
1631
1632    Ok(OptimizeResult {
1633        x: result.x,
1634        fun: result.fun,
1635        nit: result.nit,
1636        func_evals: result.nfev,
1637        nfev: result.nfev,
1638        success: result.success,
1639        message: result.message,
1640        jacobian: None,
1641        hessian: None,
1642    })
1643}
1644
1645// Tests live in `interior_point_tests.rs` (split out to keep this
1646// implementation file under the workspace's 2000-line guideline).
1647#[cfg(test)]
1648#[path = "interior_point_tests.rs"]
1649mod tests;