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;