1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
//! gh #231 — `PdSearchDirCalc` must not panic when the Mehrotra corrector's
//! affine step is missing.
//!
//! Under `mehrotra_algorithm=yes` the predictor (affine) step is produced by the
//! adaptive-μ oracle and stashed in `IpoptData::delta_aff`. Several legitimate
//! paths leave it unset when the main search-direction solve runs: the affine
//! solve can fail and fall back to the LOQO oracle (which computes no predictor),
//! the probing iterate-quality guard can return early without one, or an early
//! iteration may precede the first affine step. Previously the main-step RHS
//! assembly did `unwrap_or_else(|| panic!(...))` on `delta_aff`, so those states
//! took the whole process down — uncatchable from the C API, the Python
//! bindings, or the GAMS link.
//!
//! The minimal reproducer from the issue: a separable quartic with a single
//! upper-bounded inequality, started far from the optimum with a large target
//! coefficient, which is exactly the regime where the zero/ill-conditioned
//! affine solve leaves `delta_aff` empty. The only property under test is that
//! the solve returns *a status* instead of panicking.
use pounce_algorithm::application::IpoptApplication;
use pounce_common::types::Number;
use pounce_nlp::return_codes::ApplicationReturnStatus;
use pounce_nlp::tnlp::{
BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest, StartingPoint,
TNLP,
};
use std::cell::RefCell;
use std::rc::Rc;
/// `min (x0 − a0)⁴ + (x1 − a1)⁴ s.t. x0 ≤ a0`, started at the origin.
///
/// With `a = [100, 137]` this is the issue's `a0 ≥ 1e2` panicking family.
struct QuarticWithCap {
a: [Number; 2],
}
impl TNLP for QuarticWithCap {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 2,
m: 1,
nnz_jac_g: 1,
nnz_h_lag: 2,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l.copy_from_slice(&[-2.0e19; 2]);
b.x_u.copy_from_slice(&[2.0e19; 2]);
// g(x) = x0 ≤ a0.
b.g_l[0] = -2.0e19;
b.g_u[0] = self.a[0];
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
sp.x.copy_from_slice(&[0.0, 0.0]);
true
}
fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
Some((x[0] - self.a[0]).powi(4) + (x[1] - self.a[1]).powi(4))
}
fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 4.0 * (x[0] - self.a[0]).powi(3);
g[1] = 4.0 * (x[1] - self.a[1]).powi(3);
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = x[0];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow[0] = 0;
jcol[0] = 0;
}
SparsityRequest::Values { values } => {
values[0] = 1.0;
}
}
true
}
fn eval_h(
&mut self,
x: Option<&[Number]>,
_new_x: bool,
obj_factor: Number,
_lambda: Option<&[Number]>,
_new_lambda: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
for i in 0..2 {
irow[i] = i as i32;
jcol[i] = i as i32;
}
}
SparsityRequest::Values { values } => {
// g is linear, so the Lagrangian Hessian is the objective's.
let x = x.expect("eval_h(Values) without x");
values[0] = obj_factor * 12.0 * (x[0] - self.a[0]).powi(2);
values[1] = obj_factor * 12.0 * (x[1] - self.a[1]).powi(2);
}
}
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
fn solve_mehrotra(a: [Number; 2]) -> ApplicationReturnStatus {
let mut app = IpoptApplication::new();
app.options_mut()
.set_string_value("mehrotra_algorithm", "yes", true, false)
.unwrap();
// Keep the run short — we only care that assembling the search direction
// does not panic, not that this pathological problem converges.
app.options_mut()
.set_integer_value("max_iter", 50, true, false)
.unwrap();
app.initialize().unwrap();
let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(QuarticWithCap { a }));
app.optimize_tnlp(tnlp)
}
/// The regression: before the fix this panicked with
/// "PdSearchDirCalc: delta_aff missing for Mehrotra". Now it returns a status.
#[test]
fn missing_affine_step_returns_a_status_instead_of_panicking() {
let status = solve_mehrotra([100.0, 137.0]);
eprintln!("gh#231 reproducer finished with {status:?}");
// Any terminal status is acceptable; the point is that the library returned
// control to the caller rather than aborting the process. `Internal_Error`
// (which is what an unwinding panic would map to if it were caught) must not
// appear, since the fix is a graceful fallback, not a caught panic.
assert!(
!matches!(status, ApplicationReturnStatus::InternalError),
"solve should not report an internal error, got {status:?}"
);
}
/// The issue notes the panic is fine at small `a0` and fires for `a0 ≥ 1e2`.
/// The small-coefficient case must keep working (and should actually solve).
#[test]
fn small_coefficient_case_still_solves() {
let status = solve_mehrotra([1.0, 1.0]);
eprintln!("small-coefficient control finished with {status:?}");
assert!(
matches!(
status,
ApplicationReturnStatus::SolveSucceeded
| ApplicationReturnStatus::SolvedToAcceptableLevel
),
"the well-behaved case should still converge, got {status:?}"
);
}