pounce_cli/dispatch.rs
1//! Solver routing for the LP/QP/QCQP dispatch.
2//!
3//! See `dev-notes/lp-qp-routing.md`. This module sits between problem
4//! loading and the call to `optimize_tnlp`. It does three things:
5//!
6//! 1. **Classify** the parsed problem into a [`ProblemClass`] by walking
7//! the nonlinear expression trees the `.nl` reader already produced.
8//! 2. **Resolve** that class against the user's `solver_selection`
9//! option into a [`SolverChoice`].
10//! 3. **Dispatch** to the chosen solver (in `main.rs`).
11//!
12//! All solvers are wired: `auto` routes an LP/convex-QP to `pounce-convex`'s
13//! interior-point solver, a convex QCQP to the same crate's conic (SOCP)
14//! driver, and everything else to the existing filter-IPM (`Nlp`).
15//!
16//! ## Classification
17//!
18//! The `.nl` format has no dedicated quadratic section: each row's
19//! linear part lives in the `G`/`J` coefficient segments (already split
20//! out into [`NlProblem::obj_linear`] / [`NlProblem::con_linear`]),
21//! while any higher-order term — including a QP's quadratic terms — is
22//! written into the nonlinear expression tree as `Mul`/`Pow` nodes. So:
23//!
24//! - no nonlinear parts at all → **LP**;
25//! - all nonlinear parts are degree-2 polynomials → **QP** family
26//! (convex / nonconvex / QCQP split by curvature);
27//! - anything else (transcendental, higher degree) → **NLP**.
28//!
29//! ### Conservative fallback (correctness guard)
30//!
31//! Misclassifying an indefinite or non-quadratic problem *into* a convex
32//! solver would return a spurious KKT point as if globally optimal.
33//! Whenever the walk cannot *prove* the stronger class, the classifier
34//! falls back to the more general one, ultimately `Nlp`. The convexity
35//! (PSD) test uses a tolerance and routes "inconclusive within
36//! tolerance" to the safe side, never to the convex path.
37
38use crate::nl_reader::{BinOp, Expr, NlProblem, UnaryOp};
39use pounce_common::types::{lower_bound_present, upper_bound_present};
40use std::collections::BTreeMap;
41
42/// Tolerance for the smallest-eigenvalue sign test in the convexity
43/// check. A Hessian eigenvalue below `-PSD_TOL` is treated as a genuine
44/// negative direction (nonconvex); within `±PSD_TOL` it is treated as
45/// zero. Scaled tolerances would be better once we have problem scaling
46/// in this path; a fixed absolute tolerance is adequate here and errs
47/// toward the safe (more general) class.
48const PSD_TOL: f64 = 1e-9;
49
50/// Size budget (`n · m`) above which a convex QCQP is routed to the general
51/// NLP solver instead of the conic (SOCP) interior-point path.
52///
53/// The QCQP→SOCP reformulation ([`crate::qp_extract::extract_socp_with_map`])
54/// and the conic solve both scale with the problem's variable × constraint
55/// product; for the very large convex QCQPs in the mittelmann set
56/// (`nql180` ≈ 1.3e5 vars × 1.3e5 cons, `qssp180` ≈ 2.0e5 × 1.3e5) the
57/// reformulation alone burns the entire CPU budget before the solver starts.
58/// The pre-classifier baseline routed these to the NLP filter-IPM, which
59/// solves them in well under the time limit (`qssp180` 27 iters, `nql180`
60/// 44 iters). Above this budget we do the same: a convex QCQP is still a
61/// valid NLP, so the fallback is sound — it only forgoes the conic
62/// specialization on a scale the conic path is not yet tuned for.
63///
64/// `1e8` keeps the conic path for small-to-moderate QCQPs (e.g. 1e4 × 1e4)
65/// while bounding the reformulation cost to roughly a second.
66const SOCP_SIZE_BUDGET: u64 = 100_000_000;
67
68/// Per-constraint coupling budget for the QCQP→SOCP conic path.
69///
70/// The `n · m` [`SOCP_SIZE_BUDGET`] catches QCQPs that are large in the
71/// *problem* dimensions, but a problem can have a small `n · m` and still be
72/// ruinously expensive to put in conic form: each convex quadratic *row*
73/// `½xᵀQx ≤ b` is reformulated to a second-order cone via a factorization of
74/// its Hessian `Q` ([`crate::qp_extract::extract_socp_with_map`]), which costs
75/// `O(k³)` in the number of variables `k` that couple inside that one
76/// constraint. The mittelmann `qcqp1000-*` rows have only a handful of
77/// constraints (tiny `n · m`) but each couples ~1000 variables, so the
78/// per-row factorization alone exhausts the CPU budget before the conic solve
79/// starts.
80///
81/// When any single quadratic constraint couples more than this many active
82/// variables we route the whole QCQP to the general NLP filter-IPM, which
83/// solves it soundly without the conic reformulation — exactly what the
84/// classifier did for these rows before the convexity certificate was made
85/// cheap. A *diagonal* (separable) constraint Hessian is exempt: it is
86/// SOC-representable in `O(nnz)` with no factorization, so its size is
87/// harmless. This guard governs only the conic *reformulation* cost; the
88/// convexity test itself is the cheap sparse factorization in
89/// [`coupled_hessian_is_psd`].
90const QCQP_SOCP_COUPLED_VARS: usize = 256;
91
92/// The mathematical class of a loaded problem, from most to least
93/// specialized. See the module docs and `dev-notes/lp-qp-routing.md`.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ProblemClass {
96 /// Linear objective, linear constraints.
97 Lp,
98 /// Convex quadratic objective, linear constraints (Hessian PSD).
99 ConvexQp,
100 /// Convex quadratic objective and/or convex quadratic constraints.
101 /// SOCP-representable; routes to the conic (SOCP) interior-point solver.
102 ConvexQcqp,
103 /// Quadratic but with an indefinite Hessian somewhere. Falls through
104 /// to the NLP solver for a local minimum.
105 NonconvexQp,
106 /// General nonlinear (transcendental terms, higher-degree
107 /// polynomials, or anything the classifier cannot prove quadratic).
108 Nlp,
109}
110
111impl ProblemClass {
112 /// Human-readable name for diagnostics and the
113 /// forced-solver-mismatch error message.
114 pub fn name(self) -> &'static str {
115 match self {
116 ProblemClass::Lp => "LP",
117 ProblemClass::ConvexQp => "convex QP",
118 ProblemClass::ConvexQcqp => "convex QCQP",
119 ProblemClass::NonconvexQp => "nonconvex QP",
120 ProblemClass::Nlp => "NLP",
121 }
122 }
123}
124
125/// The resolved solver to dispatch to, after combining a
126/// [`ProblemClass`] with the `solver_selection` option.
127///
128/// `auto` resolves an LP/convex-QP to [`SolverChoice::LpIpm`]/[`SolverChoice::QpIpm`],
129/// a convex QCQP to [`SolverChoice::SocpIpm`], and everything else to
130/// [`SolverChoice::Nlp`]; a forced `solver_selection` can pin any of them.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum SolverChoice {
133 /// The existing Wächter-Biegler filter-IPM.
134 Nlp,
135 /// LP interior-point in `pounce-convex`.
136 LpIpm,
137 /// Convex-QP interior-point in `pounce-convex`.
138 QpIpm,
139 /// Conic (SOCP) IPM in `pounce-convex`: convex QCQP, reformulated to
140 /// second-order cones.
141 SocpIpm,
142 /// Active-set QP in `pounce-qp` (parallel track).
143 QpActiveSet,
144}
145
146impl SolverChoice {
147 /// Human-readable description of the dispatched solver, for the
148 /// banner-level "Solving as …" log line. Names the algorithm and the
149 /// crate that implements it so a reader can tell which of pounce's
150 /// solvers actually ran.
151 pub fn describe(self) -> &'static str {
152 match self {
153 SolverChoice::Nlp => "NLP filter line-search interior-point (pounce-nlp)",
154 SolverChoice::LpIpm => "LP interior-point (pounce-convex)",
155 SolverChoice::QpIpm => "convex QP interior-point (pounce-convex)",
156 SolverChoice::SocpIpm => "convex QCQP conic interior-point (pounce-convex)",
157 SolverChoice::QpActiveSet => "active-set QP (pounce-qp)",
158 }
159 }
160}
161
162/// Parsed `solver_selection` option value.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum SolverSelection {
165 /// Pick the most specialized solver matching the class. Default.
166 Auto,
167 /// Force the NLP solver regardless of class (current behavior).
168 Nlp,
169 /// Force IPM-LP; error if the problem is not an LP.
170 LpIpm,
171 /// Force IPM-QP; error if the problem is not LP/convex-QP.
172 QpIpm,
173 /// Force the conic (SOCP) IPM; error if the problem is not a convex
174 /// LP / QP / QCQP (all of which the conic solver handles).
175 Socp,
176 /// Force active-set QP; error if the problem is not LP/convex-QP.
177 QpActiveSet,
178}
179
180impl SolverSelection {
181 /// Parse the `solver_selection` option string. Returns `None` for an
182 /// unrecognized value so the caller can surface a tidy error.
183 pub fn parse(s: &str) -> Option<Self> {
184 match s {
185 "auto" => Some(SolverSelection::Auto),
186 "nlp" => Some(SolverSelection::Nlp),
187 "lp-ipm" => Some(SolverSelection::LpIpm),
188 "qp-ipm" => Some(SolverSelection::QpIpm),
189 "socp" => Some(SolverSelection::Socp),
190 "qp-active-set" => Some(SolverSelection::QpActiveSet),
191 _ => None,
192 }
193 }
194
195 /// The accepted values, for error messages and option registration.
196 pub const VALUES: &'static [&'static str] =
197 &["auto", "nlp", "lp-ipm", "qp-ipm", "socp", "qp-active-set"];
198}
199
200/// Classify a parsed `.nl` problem.
201///
202/// Works off the already-split linear / nonlinear representation in
203/// [`NlProblem`]: a row contributes to the class only through its
204/// nonlinear `Expr` (the linear part is, by construction, linear). The
205/// classifier is deliberately conservative — see the module docs.
206pub fn classify_problem(prob: &NlProblem) -> ProblemClass {
207 // Fast path: no nonlinear parts anywhere ⇒ LP. (Header-equivalent:
208 // n_nl_objs == 0 && n_nl_cons == 0.)
209 let obj_nl = !is_trivially_zero(&prob.obj_nonlinear);
210 let cons_nl = prob.con_nonlinear.iter().any(|e| !is_trivially_zero(e));
211 if !obj_nl && !cons_nl {
212 return ProblemClass::Lp;
213 }
214
215 // Objective curvature.
216 let obj_quad = match analyze_quadratic(&prob.obj_nonlinear, prob.n) {
217 Some(q) => q,
218 // Objective has a non-quadratic nonlinear term ⇒ NLP.
219 None => return ProblemClass::Nlp,
220 };
221
222 // Constraint curvature. A quadratic constraint makes this a QCQP;
223 // any non-quadratic constraint term makes the whole problem NLP.
224 let mut any_quadratic_constraint = false;
225 for c in &prob.con_nonlinear {
226 if is_trivially_zero(c) {
227 continue;
228 }
229 match analyze_quadratic(c, prob.n) {
230 Some(q) if q.is_empty() => {} // purely linear after all
231 Some(_) => any_quadratic_constraint = true,
232 None => return ProblemClass::Nlp,
233 }
234 }
235
236 // Objective Hessian definiteness, as the *minimizer* sees it. A
237 // `maximize` problem is internally negated to a minimization, so a
238 // concave-up (PSD-Hessian) maximize is a nonconvex minimize. Test the
239 // sense-adjusted Hessian, not the raw one, or maximize-of-convex slips
240 // through to the convex IPM and produces a wrong (max/saddle) answer.
241 if !obj_quad.is_empty() {
242 let effective: QuadHessian = if prob.minimize {
243 obj_quad.clone()
244 } else {
245 obj_quad.iter().map(|(k, v)| (*k, -v)).collect()
246 };
247 if !hessian_is_psd(&effective, prob.n) {
248 return ProblemClass::NonconvexQp;
249 }
250 }
251
252 if any_quadratic_constraint {
253 // Convex QCQP requires every quadratic constraint to be convex *as a
254 // feasible set*, not merely to have a PSD Hessian. A quadratic
255 // `g(x) = ½xᵀQx + … ` carves a convex region only when it is a
256 // one-sided **upper** bound `g(x) ≤ g_u` *and* `Q ⪰ 0`. The other
257 // senses are nonconvex even with a PSD Hessian:
258 // - `g(x) ≥ g_l` (finite lower bound): the super-level set of a
259 // convex function is nonconvex;
260 // - a quadratic equality `g(x) = c`;
261 // - a two-sided range `g_l ≤ g(x) ≤ g_u` (includes the `≥` side).
262 // This sense test matters now that ConvexQcqp is dispatched to the
263 // conic solver (it is SOC-representable only in the convex case); a
264 // misclassified nonconvex row would return a spurious "optimum".
265 // Anything not provably convex falls back to NLP (sound: the
266 // filter-IPM finds a local minimum either way).
267 for (row, c) in prob.con_nonlinear.iter().enumerate() {
268 if is_trivially_zero(c) {
269 continue;
270 }
271 match analyze_quadratic(c, prob.n) {
272 Some(q) if q.is_empty() => {} // purely linear after all
273 Some(q) => {
274 let lo = prob.g_l[row];
275 let hi = prob.g_u[row];
276 // Presence is directional (gh #401). The symmetric
277 // `|v| < 1e19` test this used to run called a row with a
278 // real bound past the *opposite* sentinel — `g(x) >= 5e20`
279 // arrives as `g_l = 5e20`, `g_u = 1e19` — free on both
280 // sides, and `continue` below then dropped a real
281 // constraint from the convexity decision.
282 let lo_present = lower_bound_present(lo);
283 let hi_present = upper_bound_present(hi);
284 let vacuous = !lo_present && !hi_present;
285 let upper_only = hi_present && !lo_present;
286 if vacuous {
287 // Free row: imposes nothing, so it cannot make the
288 // problem nonconvex. Ignore it.
289 continue;
290 }
291 // Convexity (cheap sparse certificate) gates the QCQP
292 // class; the per-row coupling guard then gates the *conic*
293 // path: a convex but heavily-coupled constraint Hessian is
294 // ruinous to put in SOC form, so route the whole QCQP to
295 // NLP (which solves it soundly) rather than burn the budget
296 // in the reformulation — the mittelmann `qcqp1000-*` rows.
297 if !upper_only
298 || !hessian_is_psd(&q, prob.n)
299 || qcqp_constraint_too_costly_for_socp(&q)
300 {
301 return ProblemClass::Nlp;
302 }
303 }
304 None => return ProblemClass::Nlp,
305 }
306 }
307 // A convex QCQP whose scale exceeds the conic path's budget falls
308 // back to NLP: the QCQP→SOCP reformulation and conic solve scale with
309 // `n · m`, and beyond this the setup alone exhausts the CPU budget
310 // (the mittelmann `nql180`/`qssp180` regression). NLP solves the same
311 // problem soundly — see `SOCP_SIZE_BUDGET`.
312 if (prob.n as u64).saturating_mul(prob.m as u64) > SOCP_SIZE_BUDGET {
313 return ProblemClass::Nlp;
314 }
315 return ProblemClass::ConvexQcqp;
316 }
317
318 // Quadratic (or linear) convex objective with linear constraints.
319 if obj_quad.is_empty() {
320 // Objective nonlinear part collapsed to nothing quadratic and no
321 // constraints are quadratic — it was effectively linear.
322 ProblemClass::Lp
323 } else {
324 ProblemClass::ConvexQp
325 }
326}
327
328/// Resolve a [`ProblemClass`] and a [`SolverSelection`] into the solver
329/// to dispatch to, or an error string when a forced selection does not
330/// match the detected class.
331///
332/// `auto` routes LP / convex QP to the convex IPM (`QpIpm`) and convex
333/// QCQP to the conic IPM (`SocpIpm`); nonconvex QP and general NLP resolve
334/// to `Nlp`. A forced selection that does not match the detected class is
335/// rejected with a clear message. (`QpActiveSet` is accepted for LP / convex
336/// QP and dispatched to the active-set SQP engine — see `main.rs`.)
337pub fn resolve_solver(
338 class: ProblemClass,
339 selection: SolverSelection,
340) -> Result<SolverChoice, String> {
341 use ProblemClass as P;
342 use SolverSelection as S;
343
344 // Is this class within the convex-QP family (LP or convex QP)?
345 let is_lp = class == P::Lp;
346 let is_convex_qp = matches!(class, P::Lp | P::ConvexQp);
347 // The conic solver handles the whole convex cone family: LP, convex QP,
348 // and (reformulated to second-order cones) convex QCQP.
349 let is_conic = matches!(class, P::Lp | P::ConvexQp | P::ConvexQcqp);
350
351 match selection {
352 // `auto`: route LP and convex QP to the specialized convex IPM
353 // (`pounce-convex`) and convex QCQP to the same crate's conic
354 // (SOCP) IPM; nonconvex QP and general NLP fall through to the NLP
355 // filter-IPM. LP is solved by the same QP IPM (P = 0), so it
356 // resolves to `QpIpm` rather than a distinct LP entry point.
357 S::Auto => match class {
358 P::Lp | P::ConvexQp => Ok(SolverChoice::QpIpm),
359 P::ConvexQcqp => Ok(SolverChoice::SocpIpm),
360 _ => Ok(SolverChoice::Nlp),
361 },
362 S::Nlp => Ok(SolverChoice::Nlp),
363 S::LpIpm => {
364 if is_lp {
365 Ok(SolverChoice::LpIpm)
366 } else {
367 Err(mismatch_msg(class, "lp-ipm", "an LP"))
368 }
369 }
370 S::QpIpm => {
371 if is_convex_qp {
372 Ok(SolverChoice::QpIpm)
373 } else {
374 Err(mismatch_msg(class, "qp-ipm", "an LP or convex QP"))
375 }
376 }
377 S::Socp => {
378 if is_conic {
379 Ok(SolverChoice::SocpIpm)
380 } else {
381 Err(mismatch_msg(class, "socp", "a convex LP, QP, or QCQP"))
382 }
383 }
384 S::QpActiveSet => {
385 if is_convex_qp {
386 Ok(SolverChoice::QpActiveSet)
387 } else {
388 Err(mismatch_msg(class, "qp-active-set", "an LP or convex QP"))
389 }
390 }
391 }
392}
393
394fn mismatch_msg(class: ProblemClass, forced: &str, expected: &str) -> String {
395 format!(
396 "problem class {} does not match forced solver {} (expected {})",
397 class.name(),
398 forced,
399 expected
400 )
401}
402
403// ---------------------------------------------------------------------
404// Quadratic-form analysis
405// ---------------------------------------------------------------------
406
407/// The symmetric Hessian of a quadratic form, stored as a sparse upper-
408/// triangular (i ≤ j) map of `(i, j) -> ∂²/∂xᵢ∂xⱼ`. Empty means the
409/// expression is (at most) linear.
410pub(crate) type QuadHessian = BTreeMap<(usize, usize), f64>;
411
412/// Full quadratic read-out: `(Hessian, [(var, linear coef), …], constant)`.
413/// The linear and constant parts are the pieces AMPL/Pyomo fold into the
414/// nonlinear objective tree (see [`analyze_quadratic_full`]).
415pub(crate) type QuadForm = (QuadHessian, Vec<(usize, f64)>, f64);
416
417/// Attempt to read an expression as a polynomial of total degree ≤ 2 and
418/// return its Hessian (constant, since the form is quadratic). Returns
419/// `None` if the expression contains any term the classifier cannot
420/// prove is degree-≤2 polynomial (transcendental ops, division by a
421/// non-constant, `Pow` with exponent ∉ {0,1,2}, products of degree > 2,
422/// external calls, …). `None` ⇒ treat as general nonlinear.
423pub(crate) fn analyze_quadratic(e: &Expr, n: usize) -> Option<QuadHessian> {
424 analyze_quadratic_full(e, n).map(|(h, _, _)| h)
425}
426
427/// Like [`analyze_quadratic`] but also returns the degree-1 (linear)
428/// coefficients *and* the degree-0 (constant) term of the form:
429/// `(Hessian, [(var, coef), …], constant)`.
430///
431/// AMPL folds the linear part of a nonlinear term into the objective's
432/// nonlinear expression tree (the `−6·x₀` of `(x₀−3)²`, say) rather than
433/// the linear section. Callers building the QP objective vector `c` must
434/// add these in, exactly as the NLP path's `eval_f` sums the linear
435/// section *and* the nonlinear tree — otherwise the linear shift is
436/// silently dropped and the convex solve minimizes the wrong objective.
437///
438/// The **constant** is returned for the same reason: AMPL/Pyomo also fold
439/// the objective's degree-0 term into the nonlinear tree (the `+9` of
440/// `(x₀−3)²`), where it does *not* land in `NlProblem::obj_constant`. It
441/// is irrelevant to the minimizer but is part of the *reported objective
442/// value*; dropping it makes the convex solve report an objective off by
443/// that constant versus the NLP path (see `qp_extract`).
444pub(crate) fn analyze_quadratic_full(e: &Expr, _n: usize) -> Option<QuadForm> {
445 let poly = to_poly(e)?;
446 if poly.max_degree() > 2 {
447 return None;
448 }
449 let mut h: QuadHessian = BTreeMap::new();
450 let mut lin: Vec<(usize, f64)> = Vec::new();
451 let mut constant = 0.0;
452 for (vars, coef) in &poly.terms {
453 match vars.as_slice() {
454 // Constant term: no gradient/Hessian contribution, but it is
455 // part of the objective *value* — accumulate, don't drop.
456 [] => constant += *coef,
457 // Linear term c·xᵢ.
458 [i] => lin.push((*i, *coef)),
459 // Quadratic term c·xᵢ·xⱼ.
460 [i, j] => {
461 let (i, j) = (*i.min(j), *i.max(j));
462 // ∂²(c·xᵢxⱼ)/∂xᵢ∂xⱼ = c for i≠j; ∂²(c·xᵢ²)/∂xᵢ² = 2c.
463 let contrib = if i == j { 2.0 * coef } else { *coef };
464 *h.entry((i, j)).or_insert(0.0) += contrib;
465 }
466 _ => return None,
467 }
468 }
469 // Drop explicit zeros so `is_empty()` means "linear".
470 h.retain(|_, v| v.abs() > 0.0);
471 Some((h, lin, constant))
472}
473
474/// A multivariate polynomial as a map from a sorted variable-index
475/// multiset (the monomial) to its coefficient. `[]` is the constant
476/// term, `[i]` is `xᵢ`, `[i, i]` is `xᵢ²`, `[i, j]` is `xᵢxⱼ`.
477#[derive(Debug, Clone, Default)]
478struct Poly {
479 terms: BTreeMap<Vec<usize>, f64>,
480}
481
482impl Poly {
483 fn constant(c: f64) -> Self {
484 let mut terms = BTreeMap::new();
485 if c != 0.0 {
486 terms.insert(Vec::new(), c);
487 }
488 Poly { terms }
489 }
490
491 fn var(i: usize) -> Self {
492 let mut terms = BTreeMap::new();
493 terms.insert(vec![i], 1.0);
494 Poly { terms }
495 }
496
497 fn max_degree(&self) -> usize {
498 self.terms.keys().map(|m| m.len()).max().unwrap_or(0)
499 }
500
501 fn as_constant(&self) -> Option<f64> {
502 match self.terms.len() {
503 0 => Some(0.0),
504 1 => self.terms.get(&Vec::new()).copied(),
505 _ => None,
506 }
507 }
508
509 fn add(mut self, other: &Poly) -> Poly {
510 for (m, c) in &other.terms {
511 *self.terms.entry(m.clone()).or_insert(0.0) += c;
512 }
513 self.prune();
514 self
515 }
516
517 fn neg(mut self) -> Poly {
518 for c in self.terms.values_mut() {
519 *c = -*c;
520 }
521 self
522 }
523
524 fn scale(mut self, s: f64) -> Poly {
525 if s == 0.0 {
526 return Poly::default();
527 }
528 for c in self.terms.values_mut() {
529 *c *= s;
530 }
531 self
532 }
533
534 /// Multiply two polynomials, bailing (`None`) if any product
535 /// monomial would exceed total degree 2 — past that the classifier
536 /// gives up and the caller routes to NLP.
537 fn mul(&self, other: &Poly) -> Option<Poly> {
538 let mut out = Poly::default();
539 for (ma, ca) in &self.terms {
540 for (mb, cb) in &other.terms {
541 if ma.len() + mb.len() > 2 {
542 return None;
543 }
544 let mut m = ma.clone();
545 m.extend_from_slice(mb);
546 m.sort_unstable();
547 *out.terms.entry(m).or_insert(0.0) += ca * cb;
548 }
549 }
550 out.prune();
551 Some(out)
552 }
553
554 fn prune(&mut self) {
555 self.terms.retain(|_, c| c.abs() > 0.0);
556 }
557}
558
559/// Lower an `Expr` to a [`Poly`] of total degree ≤ 2, or `None` if it
560/// contains anything outside that class. `Cse` nodes are inlined (they
561/// are mathematically equivalent to their body).
562fn to_poly(e: &Expr) -> Option<Poly> {
563 match e {
564 Expr::Const(c) => Some(Poly::constant(*c)),
565 Expr::Var(i) => Some(Poly::var(*i)),
566 Expr::Cse(body) => to_poly(body),
567 Expr::Sum(items) => {
568 // Accumulate every monomial into one map, pruning ONCE at the
569 // end. The previous `acc = acc.add(&to_poly(it)?)` called the
570 // self-pruning `add` per item, and `prune` rescans the entire
571 // accumulated map, making an N-term sum O(N²). On QCQP forms
572 // (a quadratic over n vars expands to up to ~n² monomials) this
573 // hung the `solver_selection=auto` classifier for >300 s before
574 // the solver ever started. Merge-then-prune is O(N log N).
575 let mut acc = Poly::default();
576 for it in items {
577 let p = to_poly(it)?;
578 for (m, c) in &p.terms {
579 *acc.terms.entry(m.clone()).or_insert(0.0) += c;
580 }
581 }
582 acc.prune();
583 Some(acc)
584 }
585 Expr::Unary(op, a) => match op {
586 UnaryOp::Neg => Some(to_poly(a)?.neg()),
587 // Everything else is transcendental / non-polynomial.
588 _ => None,
589 },
590 Expr::Binary(op, a, b) => {
591 let pa = to_poly(a)?;
592 let pb = to_poly(b)?;
593 match op {
594 BinOp::Add => Some(pa.add(&pb)),
595 BinOp::Sub => Some(pa.add(&pb.neg())),
596 BinOp::Mul => pa.mul(&pb),
597 BinOp::Div => {
598 // Division is polynomial only by a nonzero constant.
599 let d = pb.as_constant()?;
600 if d == 0.0 {
601 None
602 } else {
603 Some(pa.scale(1.0 / d))
604 }
605 }
606 BinOp::Pow => {
607 // Polynomial only for constant integer exponents in
608 // {0, 1, 2}.
609 let exp = pb.as_constant()?;
610 if exp == 0.0 {
611 Some(Poly::constant(1.0))
612 } else if exp == 1.0 {
613 Some(pa)
614 } else if exp == 2.0 {
615 pa.mul(&pa)
616 } else {
617 None
618 }
619 }
620 // atan2 and any other binary opcodes are non-polynomial.
621 _ => None,
622 }
623 }
624 // External function calls are opaque ⇒ not provably polynomial.
625 Expr::Funcall { .. } => None,
626 // Comparisons, logicals, conditionals, and n-ary min/max (the
627 // smooth-/control-flow `.nl` opcodes) are non-polynomial ⇒ not a
628 // convex QP, so the classifier routes them to the NLP solver.
629 _ => None,
630 }
631}
632
633/// True if the expression is the literal constant zero the `.nl` reader
634/// uses for "no nonlinear part".
635fn is_trivially_zero(e: &Expr) -> bool {
636 matches!(e, Expr::Const(c) if *c == 0.0)
637}
638
639// ---------------------------------------------------------------------
640// PSD test
641// ---------------------------------------------------------------------
642
643/// Number of distinct variables that couple inside a quadratic form — the
644/// dimension `k` of the matrix that would be factored.
645fn hessian_active_vars(h: &QuadHessian) -> usize {
646 let mut active: Vec<usize> = Vec::with_capacity(2 * h.len());
647 for (i, j) in h.keys() {
648 active.push(*i);
649 active.push(*j);
650 }
651 active.sort_unstable();
652 active.dedup();
653 active.len()
654}
655
656/// True when reformulating this *convex* quadratic constraint to a
657/// second-order cone would be too costly — a *coupled* (off-diagonal) Hessian
658/// over more than [`QCQP_SOCP_COUPLED_VARS`] active variables, whose per-row
659/// `O(k³)` factorization dominates the budget. A purely diagonal constraint
660/// Hessian is exempt (SOC-representable in `O(nnz)`). Callers route such a
661/// QCQP to the general NLP solver instead of the conic path. This is about
662/// the *reformulation* cost, not convexity: the constraint is already known
663/// convex (PSD) when this is consulted.
664fn qcqp_constraint_too_costly_for_socp(h: &QuadHessian) -> bool {
665 let has_offdiag = h.keys().any(|(i, j)| i != j);
666 has_offdiag && hessian_active_vars(h) > QCQP_SOCP_COUPLED_VARS
667}
668
669/// Is the (symmetric, sparse) Hessian positive semidefinite?
670///
671/// A purely diagonal Hessian is settled in `O(nnz)` by sign — its
672/// eigenvalues *are* its diagonal entries — with no factorization at all;
673/// this keeps large separable / least-squares QPs cheap. A *coupled*
674/// Hessian is certified by a sparse symmetric factorization (see
675/// [`coupled_hessian_is_psd`]): feral's LDLᵀ reports the matrix inertia in
676/// roughly `O(nnz · fill)`, so even the large but sparse coupled Hessians of
677/// the CVXQP family (n ≈ 1000) are classified in well under the solve cost —
678/// no dense `k×k` allocation and no `O(k³)` eigensolve. Returns `true` only
679/// when the smallest eigenvalue is `≥ -PSD_TOL`; an indefinite or
680/// inconclusive result returns `false`, routing to the safe (more general)
681/// class.
682fn hessian_is_psd(h: &QuadHessian, _n: usize) -> bool {
683 if h.is_empty() {
684 return true; // zero matrix is PSD (the linear case)
685 }
686 // Fast path: a diagonal Hessian is PSD iff every diagonal entry is
687 // `≥ -PSD_TOL`. No factorization — essential for large but separable
688 // objectives, where the answer is trivial.
689 if h.keys().all(|(i, j)| i == j) {
690 return h.values().all(|v| *v >= -PSD_TOL);
691 }
692 coupled_hessian_is_psd(h)
693}
694
695/// PSD certificate for a *coupled* Hessian via a sparse symmetric
696/// factorization.
697///
698/// The test is positive-definiteness of the `ε`-shifted matrix `H + ε·I`
699/// with `ε = PSD_TOL`. A genuinely-PSD `H` (smallest eigenvalue `λ_min ≥ 0`,
700/// even a singular one) becomes strictly positive definite after the shift,
701/// so feral factors it with no negative pivots (`inertia.negative == 0`); a
702/// truly indefinite `H` with `λ_min < -PSD_TOL` keeps a strictly-negative
703/// shifted eigenvalue and yields `negative > 0`. The `negative == 0` test on
704/// the shifted matrix is therefore exactly `λ_min ≥ -PSD_TOL` — the same
705/// tolerance the dense path used — and it scales to large sparse Hessians
706/// because the factorization cost tracks the nonzero/fill count, not a dense
707/// `k³`.
708///
709/// The Hessian is compressed to its active variable set so the factored
710/// dimension is `k` (the number of distinct variables in the form). The
711/// [`QuadHessian`] is upper-triangular (`i ≤ j`); feral wants the lower
712/// triangle (`row ≥ col`), so each entry `(i, j)` is emitted at
713/// `(row = j, col = i)`. Every active diagonal is seeded with `ε` (the shift;
714/// `from_triplets` sums it with any diagonal entry already in `H`), which
715/// also guarantees no structurally empty column. A non-`Success`
716/// factorization (singular/fatal — should not occur given the strictly-PD
717/// shift, but possible on a pathological form) is treated conservatively as
718/// not-provably-PSD.
719fn coupled_hessian_is_psd(h: &QuadHessian) -> bool {
720 use feral::{CscMatrix, FactorStatus, Solver};
721
722 // Compress to the active variable set so the factored dimension is `k`.
723 let mut active: Vec<usize> = Vec::with_capacity(2 * h.len());
724 for (i, j) in h.keys() {
725 active.push(*i);
726 active.push(*j);
727 }
728 active.sort_unstable();
729 active.dedup();
730 let k = active.len();
731 let idx = |v: usize| active.binary_search(&v).unwrap();
732
733 // Lower-triangle triplets: H's entry (i ≤ j) maps to (row = j, col = i).
734 // Capacity covers H's nonzeros plus one ε-shift per active diagonal.
735 let mut rows: Vec<usize> = Vec::with_capacity(h.len() + k);
736 let mut cols: Vec<usize> = Vec::with_capacity(h.len() + k);
737 let mut vals: Vec<f64> = Vec::with_capacity(h.len() + k);
738 for ((i, j), v) in h {
739 let (ri, rj) = (idx(*i), idx(*j));
740 // i ≤ j by the upper-tri convention, so rj ≥ ri ⇒ lower triangle.
741 rows.push(rj);
742 cols.push(ri);
743 vals.push(*v);
744 }
745 // εI shift: seed every active diagonal (summed with H's own diagonal).
746 for d in 0..k {
747 rows.push(d);
748 cols.push(d);
749 vals.push(PSD_TOL);
750 }
751
752 let mat = match CscMatrix::from_triplets(k, &rows, &cols, &vals) {
753 Ok(m) => m,
754 Err(_) => return false, // malformed ⇒ be conservative
755 };
756 let mut solver = Solver::new();
757 match solver.factor(&mat, None) {
758 FactorStatus::Success => {
759 // PD ⟺ no negative pivots in the LDLᵀ of the ε-shifted matrix.
760 solver.inertia().map(|i| i.negative == 0).unwrap_or(false)
761 }
762 // Singular / wrong-inertia / fatal: cannot certify ⇒ safe fallback.
763 _ => false,
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use crate::nl_reader::parse_nl_text;
771
772 // --- SolverSelection parsing ---
773
774 #[test]
775 fn parse_selection_values() {
776 assert_eq!(SolverSelection::parse("auto"), Some(SolverSelection::Auto));
777 assert_eq!(SolverSelection::parse("nlp"), Some(SolverSelection::Nlp));
778 assert_eq!(
779 SolverSelection::parse("lp-ipm"),
780 Some(SolverSelection::LpIpm)
781 );
782 assert_eq!(
783 SolverSelection::parse("qp-ipm"),
784 Some(SolverSelection::QpIpm)
785 );
786 assert_eq!(
787 SolverSelection::parse("qp-active-set"),
788 Some(SolverSelection::QpActiveSet)
789 );
790 assert_eq!(SolverSelection::parse("lp-simplex"), None);
791 assert_eq!(SolverSelection::parse("bogus"), None);
792 }
793
794 // --- resolve_solver: auto routes LP/convex-QP to the convex IPM,
795 // everything else to NLP ---
796
797 #[test]
798 fn auto_routes_convex_qp_family_to_qp_ipm() {
799 assert_eq!(
800 resolve_solver(ProblemClass::Lp, SolverSelection::Auto),
801 Ok(SolverChoice::QpIpm),
802 "auto should route LP to the convex IPM (P=0)"
803 );
804 assert_eq!(
805 resolve_solver(ProblemClass::ConvexQp, SolverSelection::Auto),
806 Ok(SolverChoice::QpIpm),
807 "auto should route convex QP to the convex IPM"
808 );
809 }
810
811 #[test]
812 fn auto_routes_convex_qcqp_to_socp() {
813 assert_eq!(
814 resolve_solver(ProblemClass::ConvexQcqp, SolverSelection::Auto),
815 Ok(SolverChoice::SocpIpm),
816 "auto should route convex QCQP to the conic IPM"
817 );
818 }
819
820 #[test]
821 fn auto_routes_nonconvex_to_nlp() {
822 for class in [ProblemClass::NonconvexQp, ProblemClass::Nlp] {
823 assert_eq!(
824 resolve_solver(class, SolverSelection::Auto),
825 Ok(SolverChoice::Nlp),
826 "auto must resolve to Nlp for {:?}",
827 class
828 );
829 }
830 }
831
832 #[test]
833 fn forced_socp_accepts_convex_cone_family_only() {
834 for class in [
835 ProblemClass::Lp,
836 ProblemClass::ConvexQp,
837 ProblemClass::ConvexQcqp,
838 ] {
839 assert_eq!(
840 resolve_solver(class, SolverSelection::Socp),
841 Ok(SolverChoice::SocpIpm),
842 "socp should accept {:?}",
843 class
844 );
845 }
846 assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::Socp).is_err());
847 assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::Socp).is_err());
848 }
849
850 #[test]
851 fn forced_nlp_always_ok() {
852 assert_eq!(
853 resolve_solver(ProblemClass::ConvexQp, SolverSelection::Nlp),
854 Ok(SolverChoice::Nlp)
855 );
856 }
857
858 #[test]
859 fn forced_lp_on_nlp_errors() {
860 let err = resolve_solver(ProblemClass::Nlp, SolverSelection::LpIpm).unwrap_err();
861 assert!(err.contains("NLP"), "msg should name detected class: {err}");
862 assert!(
863 err.contains("lp-ipm"),
864 "msg should name forced solver: {err}"
865 );
866 }
867
868 #[test]
869 fn forced_lp_on_lp_ok() {
870 assert_eq!(
871 resolve_solver(ProblemClass::Lp, SolverSelection::LpIpm),
872 Ok(SolverChoice::LpIpm)
873 );
874 }
875
876 #[test]
877 fn forced_qp_accepts_lp_and_convex_qp_only() {
878 assert_eq!(
879 resolve_solver(ProblemClass::Lp, SolverSelection::QpIpm),
880 Ok(SolverChoice::QpIpm)
881 );
882 assert_eq!(
883 resolve_solver(ProblemClass::ConvexQp, SolverSelection::QpIpm),
884 Ok(SolverChoice::QpIpm)
885 );
886 assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::QpIpm).is_err());
887 assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::QpIpm).is_err());
888 }
889
890 // --- Poly / quadratic analysis unit tests ---
891
892 #[test]
893 fn poly_of_quadratic_diagonal() {
894 // (x0 - 1)^2 => x0^2 - 2 x0 + 1
895 let e = Expr::Binary(
896 BinOp::Pow,
897 Box::new(Expr::Binary(
898 BinOp::Sub,
899 Box::new(Expr::Var(0)),
900 Box::new(Expr::Const(1.0)),
901 )),
902 Box::new(Expr::Const(2.0)),
903 );
904 let h = analyze_quadratic(&e, 1).expect("degree-2 polynomial");
905 // d²/dx0² (x0²) = 2
906 assert_eq!(h.get(&(0, 0)), Some(&2.0));
907 }
908
909 #[test]
910 fn poly_rejects_transcendental() {
911 // sin(x0) is not polynomial.
912 let e = Expr::Unary(UnaryOp::Sin, Box::new(Expr::Var(0)));
913 assert!(analyze_quadratic(&e, 1).is_none());
914 }
915
916 #[test]
917 fn poly_rejects_cubic() {
918 // x0^3
919 let e = Expr::Binary(
920 BinOp::Pow,
921 Box::new(Expr::Var(0)),
922 Box::new(Expr::Const(3.0)),
923 );
924 assert!(analyze_quadratic(&e, 1).is_none());
925 }
926
927 #[test]
928 fn cross_term_hessian() {
929 // x0 * x1 => H[0,1] = 1
930 let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
931 let h = analyze_quadratic(&e, 2).expect("degree-2");
932 assert_eq!(h.get(&(0, 1)), Some(&1.0));
933 }
934
935 #[test]
936 fn large_quadratic_sum_lowers_without_quadratic_blowup() {
937 // Regression guard for the `solver_selection=auto` classifier hang
938 // (mittelmann QCQP/bearing_400/qssp180 emitted zero iterations and
939 // burned the full CPU budget). A quadratic expressed as a large
940 // `Sum` of monomials must lower to a `Poly` in O(N log N): the old
941 // `acc = acc.add(&to_poly(it)?)` ran the self-pruning `add` per
942 // item, and `prune` rescans the whole accumulated map, so an
943 // N-monomial sum was O(N²) and spun for >300 s before the solver
944 // started (Ipopt solved the same problems in seconds). Build a
945 // 5000-term sum of distinct squares and confirm the full diagonal
946 // Hessian is recovered — this path completes effectively instantly
947 // once the per-`add` prune is gone.
948 const N: usize = 5000;
949 let terms: Vec<Expr> = (0..N)
950 .map(|i| Expr::Binary(BinOp::Mul, Box::new(Expr::Var(i)), Box::new(Expr::Var(i))))
951 .collect();
952 let e = Expr::Sum(terms);
953 let h = analyze_quadratic(&e, N).expect("degree-2 sum of squares is a QP");
954 assert_eq!(h.len(), N, "every xᵢ² contributes one diagonal entry");
955 assert_eq!(h.get(&(0, 0)), Some(&2.0));
956 assert_eq!(h.get(&(N - 1, N - 1)), Some(&2.0));
957 }
958
959 // --- PSD test ---
960
961 #[test]
962 fn psd_accepts_convex_separable() {
963 // diag(2, 4): both eigenvalues positive.
964 let mut h = QuadHessian::new();
965 h.insert((0, 0), 2.0);
966 h.insert((1, 1), 4.0);
967 assert!(hessian_is_psd(&h, 2));
968 }
969
970 #[test]
971 fn psd_rejects_indefinite() {
972 // [[0,1],[1,0]] has eigenvalues ±1.
973 let mut h = QuadHessian::new();
974 h.insert((0, 1), 1.0);
975 assert!(!hessian_is_psd(&h, 2));
976 }
977
978 #[test]
979 fn psd_accepts_psd_with_zero_eigenvalue() {
980 // [[1,1],[1,1]] is PSD (eigenvalues 0 and 2).
981 let mut h = QuadHessian::new();
982 h.insert((0, 0), 1.0);
983 h.insert((0, 1), 1.0);
984 h.insert((1, 1), 1.0);
985 assert!(hessian_is_psd(&h, 2));
986 }
987
988 // --- A1: ±PSD_TOL boundary of the convexity test (silent-misroute guard) ---
989
990 /// The safety-critical case: a *real* negative direction — even a small
991 /// one, well beyond `PSD_TOL` — must read non-PSD so an indefinite QP
992 /// routes to NLP, never to the convex IPM (which would return a spurious
993 /// "optimal" at a saddle/maximum).
994 #[test]
995 fn psd_rejects_small_but_real_negative_curvature() {
996 // diag(2, −1e-3): min eigenvalue −1e-3 ≪ −PSD_TOL.
997 let mut h = QuadHessian::new();
998 h.insert((0, 0), 2.0);
999 h.insert((1, 1), -1e-3);
1000 assert!(
1001 !hessian_is_psd(&h, 2),
1002 "a −1e-3 eigenvalue must read indefinite, not be rounded to PSD"
1003 );
1004 }
1005
1006 /// Pin the threshold at exactly `±PSD_TOL` (1e-9). Within the band the
1007 /// test rounds a tiny negative eigenvalue to PSD **by design**: a
1008 /// genuinely semidefinite Hessian whose smallest eigenvalue computes as a
1009 /// tiny negative (Jacobi roundoff) must not be misread as nonconvex. The
1010 /// band is far below the error of solving a convex QP with that much
1011 /// curvature, so it is the sound tradeoff — see the A1 Finding in
1012 /// `dev-notes/pr70-hardening.md`. (1×1 Hessians are returned exactly, so
1013 /// this is deterministic.)
1014 #[test]
1015 fn psd_threshold_is_psd_tol() {
1016 let mut just_inside = QuadHessian::new();
1017 just_inside.insert((0, 0), -1e-10); // |λ| < PSD_TOL ⇒ treated as zero
1018 assert!(
1019 hessian_is_psd(&just_inside, 1),
1020 "−1e-10 is within tolerance and must round to PSD"
1021 );
1022
1023 let mut just_outside = QuadHessian::new();
1024 just_outside.insert((0, 0), -1e-7); // |λ| > PSD_TOL ⇒ genuine negative
1025 assert!(
1026 !hessian_is_psd(&just_outside, 1),
1027 "−1e-7 is beyond tolerance and must read indefinite"
1028 );
1029 }
1030
1031 // --- Sparse-factorization PSD certificate (CVXQP family) ---
1032
1033 /// A large *diagonal* Hessian must take the O(nnz) sign fast path — no
1034 /// factorization at all — and read PSD. This is the large separable /
1035 /// least-squares QP shape (AUG2D, LISWET, …) that stays on the convex
1036 /// fast path.
1037 #[test]
1038 fn large_diagonal_hessian_is_cheap_and_psd() {
1039 let n = 50_000;
1040 let mut h = QuadHessian::new();
1041 for i in 0..n {
1042 h.insert((i, i), 2.0);
1043 }
1044 assert!(
1045 hessian_is_psd(&h, n),
1046 "diag(2,…,2) is PSD and must be settled by the O(nnz) sign path"
1047 );
1048 }
1049
1050 /// A large *coupled* convex Hessian (off-diagonal terms over many
1051 /// variables) is the CVXQP-family shape that the old dense-Jacobi cap
1052 /// refused to certify (routing it to NLP). The sparse-factorization
1053 /// certificate now proves it PSD cheaply, so it reaches the convex
1054 /// solver. This is the regression fix.
1055 #[test]
1056 fn large_coupled_convex_hessian_is_certified_psd() {
1057 let k = 1_000;
1058 let mut h = QuadHessian::new();
1059 // Diagonally dominant tridiagonal: SPD. 2 on the diagonal, 0.1 on
1060 // the off-diagonal coupling chain ⇒ strictly diagonally dominant.
1061 for i in 0..k {
1062 h.insert((i, i), 2.0);
1063 }
1064 for i in 0..(k - 1) {
1065 h.insert((i, i + 1), 0.1);
1066 }
1067 assert!(
1068 hessian_is_psd(&h, k),
1069 "a diagonally-dominant coupled Hessian over {k} vars must be \
1070 certified PSD by the sparse factorization (CVXQP regression)"
1071 );
1072 }
1073
1074 /// The sparse certificate must still *reject* a large coupled Hessian
1075 /// that is genuinely indefinite — size does not buy a free pass.
1076 #[test]
1077 fn large_coupled_indefinite_hessian_is_rejected() {
1078 let k = 1_000;
1079 let mut h = QuadHessian::new();
1080 for i in 0..k {
1081 h.insert((i, i), 2.0);
1082 }
1083 for i in 0..(k - 1) {
1084 h.insert((i, i + 1), 0.1);
1085 }
1086 // Flip one diagonal strongly negative ⇒ an indefinite direction.
1087 h.insert((0, 0), -5.0);
1088 assert!(
1089 !hessian_is_psd(&h, k),
1090 "a coupled Hessian with a strong negative-curvature direction \
1091 must be rejected regardless of size"
1092 );
1093 }
1094
1095 /// A *small* coupled Hessian is certified by the same sparse path.
1096 #[test]
1097 fn small_coupled_hessian_is_certified_psd() {
1098 // [[2, 1], [1, 2]] — eigenvalues 1 and 3, PSD.
1099 let mut h = QuadHessian::new();
1100 h.insert((0, 0), 2.0);
1101 h.insert((0, 1), 1.0);
1102 h.insert((1, 1), 2.0);
1103 assert!(hessian_is_psd(&h, 2));
1104 }
1105
1106 // --- End-to-end classify_problem on parsed .nl text ---
1107
1108 /// Minimal `g`-format `.nl` text builder is overkill; instead use the
1109 /// reader's own fixtures via parse_nl_text on hand-written stubs.
1110 /// These cover the header LP fast-path and the AST walk.
1111
1112 #[test]
1113 fn classify_pure_lp() {
1114 // minimize x0 + x1 s.t. x0 + x1 <= 1, no nonlinear parts.
1115 // Build an NlProblem directly for a hermetic test.
1116 let prob = NlProblem {
1117 n: 2,
1118 m: 1,
1119 num_obj: 1,
1120 minimize: true,
1121 obj_nonlinear: Expr::Const(0.0),
1122 obj_linear: vec![(0, 1.0), (1, 1.0)],
1123 obj_constant: 0.0,
1124 con_nonlinear: vec![Expr::Const(0.0)],
1125 con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
1126 x_l: vec![0.0, 0.0],
1127 x_u: vec![f64::INFINITY, f64::INFINITY],
1128 g_l: vec![f64::NEG_INFINITY],
1129 g_u: vec![1.0],
1130 x0: vec![0.0, 0.0],
1131 lambda0: vec![0.0],
1132 suffixes: Default::default(),
1133 imported_funcs: Vec::new(),
1134 ampl_options: Vec::new(),
1135 var_names: Vec::new(),
1136 con_names: Vec::new(),
1137 };
1138 assert_eq!(classify_problem(&prob), ProblemClass::Lp);
1139 }
1140
1141 #[test]
1142 fn classify_convex_qp() {
1143 // minimize x0^2 + x1^2 s.t. linear; convex (H = diag(2,2)).
1144 let obj = Expr::Binary(
1145 BinOp::Add,
1146 Box::new(Expr::Binary(
1147 BinOp::Pow,
1148 Box::new(Expr::Var(0)),
1149 Box::new(Expr::Const(2.0)),
1150 )),
1151 Box::new(Expr::Binary(
1152 BinOp::Pow,
1153 Box::new(Expr::Var(1)),
1154 Box::new(Expr::Const(2.0)),
1155 )),
1156 );
1157 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1158 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
1159 }
1160
1161 /// **gh #401.** A quadratic row whose real bound lies past the *opposite*
1162 /// sentinel must not be waved through as a free row.
1163 ///
1164 /// `x0² + x1² >= 5e20` arrives as `g_l = 5e20` (real), `g_u = 1e19`
1165 /// (the absent-upper sentinel). The symmetric `|v| < 1e19` test called
1166 /// *both* sides infinite, so `vacuous` was true and the row was skipped
1167 /// with "Free row: imposes nothing" — and the model then classified as a
1168 /// convex QCQP and went to the conic solver as if the constraint were not
1169 /// there. It is a reverse-convex row: the honest answer is NLP.
1170 #[test]
1171 fn a_quadratic_row_bounded_past_the_sentinel_is_not_vacuous() {
1172 let con = Expr::Binary(
1173 BinOp::Add,
1174 Box::new(Expr::Binary(
1175 BinOp::Pow,
1176 Box::new(Expr::Var(0)),
1177 Box::new(Expr::Const(2.0)),
1178 )),
1179 Box::new(Expr::Binary(
1180 BinOp::Pow,
1181 Box::new(Expr::Var(1)),
1182 Box::new(Expr::Const(2.0)),
1183 )),
1184 );
1185 let mut prob = qp_stub(Expr::Const(0.0), vec![con]);
1186 prob.obj_linear = vec![(0, 1.0)];
1187 prob.g_l = vec![5e20]; // real lower bound
1188 prob.g_u = vec![1e19]; // absent-upper sentinel
1189 assert_eq!(
1190 classify_problem(&prob),
1191 ProblemClass::Nlp,
1192 "a `>=` quadratic row is reverse-convex and must route to NLP; \
1193 treating it as a free row sent the model to the conic solver \
1194 with the constraint silently dropped"
1195 );
1196 }
1197
1198 #[test]
1199 fn classify_nonconvex_qp() {
1200 // minimize x0 * x1 (indefinite Hessian) s.t. linear.
1201 let obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1202 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1203 assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1204 }
1205
1206 #[test]
1207 fn classify_nlp_from_transcendental_objective() {
1208 let obj = Expr::Unary(UnaryOp::Exp, Box::new(Expr::Var(0)));
1209 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1210 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1211 }
1212
1213 /// Regression: a `maximize` of a PSD-Hessian objective is a *concave*
1214 /// maximization ⇒ nonconvex minimization. The convexity test must run
1215 /// on the sense-adjusted Hessian, or this slips through to the convex
1216 /// IPM and returns a wrong (maximum/saddle) answer.
1217 #[test]
1218 fn classify_maximize_psd_objective_is_nonconvex() {
1219 // maximize x0^2 + x1^2 (H = diag(2,2), PSD) — concave max.
1220 let obj = Expr::Binary(
1221 BinOp::Add,
1222 Box::new(Expr::Binary(
1223 BinOp::Pow,
1224 Box::new(Expr::Var(0)),
1225 Box::new(Expr::Const(2.0)),
1226 )),
1227 Box::new(Expr::Binary(
1228 BinOp::Pow,
1229 Box::new(Expr::Var(1)),
1230 Box::new(Expr::Const(2.0)),
1231 )),
1232 );
1233 let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1234 prob.minimize = false;
1235 assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1236 }
1237
1238 /// Mirror: `maximize` of a concave (NSD-Hessian) objective is a convex
1239 /// minimization once negated, so it is a legitimate `ConvexQp`.
1240 #[test]
1241 fn classify_maximize_concave_objective_is_convex() {
1242 // maximize −(x0^2 + x1^2) (H = diag(−2,−2)); negated ⇒ PSD.
1243 let neg_sq = |v: usize| {
1244 Expr::Unary(
1245 UnaryOp::Neg,
1246 Box::new(Expr::Binary(
1247 BinOp::Pow,
1248 Box::new(Expr::Var(v)),
1249 Box::new(Expr::Const(2.0)),
1250 )),
1251 )
1252 };
1253 let obj = Expr::Binary(BinOp::Add, Box::new(neg_sq(0)), Box::new(neg_sq(1)));
1254 let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1255 prob.minimize = false;
1256 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
1257 }
1258
1259 #[test]
1260 fn classify_convex_qcqp() {
1261 // convex quadratic objective + a convex quadratic constraint.
1262 let obj = Expr::Binary(
1263 BinOp::Pow,
1264 Box::new(Expr::Var(0)),
1265 Box::new(Expr::Const(2.0)),
1266 );
1267 let con = Expr::Binary(
1268 BinOp::Add,
1269 Box::new(Expr::Binary(
1270 BinOp::Pow,
1271 Box::new(Expr::Var(0)),
1272 Box::new(Expr::Const(2.0)),
1273 )),
1274 Box::new(Expr::Binary(
1275 BinOp::Pow,
1276 Box::new(Expr::Var(1)),
1277 Box::new(Expr::Const(2.0)),
1278 )),
1279 );
1280 let prob = qp_stub(obj, vec![con]);
1281 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1282 }
1283
1284 /// Build a convex QCQP (linear objective + one convex quadratic
1285 /// constraint `x0² ≤ 1`) at an arbitrary declared `n`/`m`, padding the
1286 /// extra constraints with trivially-zero rows. Used to exercise the
1287 /// `SOCP_SIZE_BUDGET` routing cap without allocating `n×n` data.
1288 fn convex_qcqp_at_size(n: usize, m: usize) -> NlProblem {
1289 let mut con_nonlinear = vec![Expr::Const(0.0); m];
1290 con_nonlinear[0] = Expr::Binary(
1291 BinOp::Pow,
1292 Box::new(Expr::Var(0)),
1293 Box::new(Expr::Const(2.0)),
1294 );
1295 let g_l = vec![f64::NEG_INFINITY; m];
1296 let mut g_u = vec![f64::INFINITY; m];
1297 g_u[0] = 1.0; // upper-only bound ⇒ convex feasible set
1298 NlProblem {
1299 n,
1300 m,
1301 num_obj: 1,
1302 minimize: true,
1303 obj_nonlinear: Expr::Const(0.0),
1304 obj_linear: vec![(0, 1.0)],
1305 obj_constant: 0.0,
1306 con_nonlinear,
1307 con_linear: vec![vec![]; m],
1308 x_l: vec![f64::NEG_INFINITY; n],
1309 x_u: vec![f64::INFINITY; n],
1310 g_l,
1311 g_u,
1312 x0: vec![0.0; n],
1313 lambda0: vec![0.0; m],
1314 suffixes: Default::default(),
1315 imported_funcs: Vec::new(),
1316 ampl_options: Vec::new(),
1317 var_names: Vec::new(),
1318 con_names: Vec::new(),
1319 }
1320 }
1321
1322 /// A convex QCQP small enough to keep the conic path (n·m ≤ budget).
1323 #[test]
1324 fn small_convex_qcqp_routes_to_conic() {
1325 let prob = convex_qcqp_at_size(100, 100); // n·m = 1e4 ≪ budget
1326 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1327 }
1328
1329 /// A convex QCQP whose `n·m` exceeds [`SOCP_SIZE_BUDGET`] falls back to
1330 /// NLP rather than the conic path — the mittelmann `nql180`/`qssp180`
1331 /// regression, where the O(n·m) SOCP reformulation burned the whole CPU
1332 /// budget before the solver started.
1333 #[test]
1334 fn oversized_convex_qcqp_falls_back_to_nlp() {
1335 // 10001 · 10001 ≈ 1.0002e8 > SOCP_SIZE_BUDGET (1e8).
1336 let prob = convex_qcqp_at_size(10_001, 10_001);
1337 assert!((prob.n as u64) * (prob.m as u64) > SOCP_SIZE_BUDGET);
1338 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1339 }
1340
1341 /// Build a convex QCQP whose single quadratic constraint `(Σ xᵢ)² ≤ 1`
1342 /// couples all `k` variables (a dense rank-1 PSD Hessian over `k` vars),
1343 /// with `n = k`, `m = 1`. Exercises the per-row conic-reformulation guard
1344 /// independently of the `n·m` budget.
1345 fn coupled_convex_qcqp_with_k_vars(k: usize) -> NlProblem {
1346 // sum = x0 + x1 + … + x_{k-1}
1347 let mut sum = Expr::Var(0);
1348 for i in 1..k {
1349 sum = Expr::Binary(BinOp::Add, Box::new(sum), Box::new(Expr::Var(i)));
1350 }
1351 // constraint (Σ xᵢ)² ≤ 1 — convex feasible set, Hessian = 2·(all-ones),
1352 // PSD (rank 1) and fully coupled across all k variables.
1353 let con = Expr::Binary(BinOp::Pow, Box::new(sum), Box::new(Expr::Const(2.0)));
1354 NlProblem {
1355 n: k,
1356 m: 1,
1357 num_obj: 1,
1358 minimize: true,
1359 obj_nonlinear: Expr::Const(0.0),
1360 obj_linear: vec![(0, 1.0)],
1361 obj_constant: 0.0,
1362 con_nonlinear: vec![con],
1363 con_linear: vec![vec![]],
1364 x_l: vec![f64::NEG_INFINITY; k],
1365 x_u: vec![f64::INFINITY; k],
1366 g_l: vec![f64::NEG_INFINITY],
1367 g_u: vec![1.0],
1368 x0: vec![0.0; k],
1369 lambda0: vec![0.0],
1370 suffixes: Default::default(),
1371 imported_funcs: Vec::new(),
1372 ampl_options: Vec::new(),
1373 var_names: Vec::new(),
1374 con_names: Vec::new(),
1375 }
1376 }
1377
1378 /// A heavily-coupled *convex* QCQP constraint (here over 300 > 256 vars,
1379 /// `n·m = 300` well under [`SOCP_SIZE_BUDGET`]) must still fall back to NLP:
1380 /// the per-row SOC reformulation is `O(k³)` in the coupling width, which
1381 /// is the mittelmann `qcqp1000-*` hang (small `n·m`, ~1000-var coupled
1382 /// rows). The convexity certificate accepts it; the coupling guard routes
1383 /// it away from the conic path.
1384 #[test]
1385 fn heavily_coupled_convex_qcqp_falls_back_to_nlp() {
1386 let k = QCQP_SOCP_COUPLED_VARS + 44; // 300
1387 let prob = coupled_convex_qcqp_with_k_vars(k);
1388 assert!((prob.n as u64) * (prob.m as u64) <= SOCP_SIZE_BUDGET);
1389 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1390 }
1391
1392 /// The companion to the guard: a convex QCQP whose constraint couples few
1393 /// enough variables keeps the conic path. Same `(Σ xᵢ)² ≤ 1` shape over
1394 /// `k ≤ QCQP_SOCP_COUPLED_VARS` vars ⇒ `ConvexQcqp`.
1395 #[test]
1396 fn lightly_coupled_convex_qcqp_keeps_conic() {
1397 let k = QCQP_SOCP_COUPLED_VARS - 6; // 250 ≤ 256
1398 let prob = coupled_convex_qcqp_with_k_vars(k);
1399 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1400 }
1401
1402 /// Classification mirror of the boundary guard: a QP whose only
1403 /// curvature is a genuine (beyond-tolerance) negative direction is
1404 /// `NonconvexQp`, so `auto` routes it to NLP rather than the convex IPM.
1405 /// `minimize −x0²` is concave for a minimizer ⇒ indefinite.
1406 #[test]
1407 fn classify_concave_minimize_is_nonconvex() {
1408 let obj = Expr::Unary(
1409 UnaryOp::Neg,
1410 Box::new(Expr::Binary(
1411 BinOp::Pow,
1412 Box::new(Expr::Var(0)),
1413 Box::new(Expr::Const(2.0)),
1414 )),
1415 );
1416 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1417 assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1418 }
1419
1420 /// Conservative QCQP guard: a convex quadratic objective with an
1421 /// *indefinite* quadratic constraint must fall back to NLP — never be
1422 /// called `ConvexQcqp` and handed to the conic path, which would treat a
1423 /// nonconvex feasible region as convex.
1424 #[test]
1425 fn classify_qcqp_with_indefinite_constraint_falls_back_to_nlp() {
1426 // obj x0² (convex); constraint x0·x1 (indefinite Hessian).
1427 let obj = Expr::Binary(
1428 BinOp::Pow,
1429 Box::new(Expr::Var(0)),
1430 Box::new(Expr::Const(2.0)),
1431 );
1432 let con = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1433 let prob = qp_stub(obj, vec![con]);
1434 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1435 }
1436
1437 /// Sense guard: a PSD-Hessian quadratic constraint is convex only as an
1438 /// **upper** bound. With a finite *lower* bound (`g(x) ≥ g_l`) the
1439 /// feasible set is the nonconvex super-level set, so it must fall back to
1440 /// NLP — never be routed to the conic solver as if convex.
1441 #[test]
1442 fn classify_psd_quadratic_with_lower_bound_is_nonconvex() {
1443 let obj = Expr::Binary(
1444 BinOp::Pow,
1445 Box::new(Expr::Var(0)),
1446 Box::new(Expr::Const(2.0)),
1447 );
1448 let con = Expr::Binary(
1449 BinOp::Add,
1450 Box::new(Expr::Binary(
1451 BinOp::Pow,
1452 Box::new(Expr::Var(0)),
1453 Box::new(Expr::Const(2.0)),
1454 )),
1455 Box::new(Expr::Binary(
1456 BinOp::Pow,
1457 Box::new(Expr::Var(1)),
1458 Box::new(Expr::Const(2.0)),
1459 )),
1460 );
1461 let mut prob = qp_stub(obj, vec![con]);
1462 // g(x) ≥ 1 (finite lower, infinite upper) — convex function, but the
1463 // ≥ side is a nonconvex region.
1464 prob.g_l = vec![1.0];
1465 prob.g_u = vec![f64::INFINITY];
1466 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1467 }
1468
1469 /// Sense guard: a quadratic *equality* (`g(x) = c`) is nonconvex even
1470 /// with a PSD Hessian, so it must fall back to NLP, not ConvexQcqp.
1471 #[test]
1472 fn classify_quadratic_equality_is_nonconvex() {
1473 let obj = Expr::Const(0.0);
1474 let con = Expr::Binary(
1475 BinOp::Pow,
1476 Box::new(Expr::Var(0)),
1477 Box::new(Expr::Const(2.0)),
1478 );
1479 let mut prob = qp_stub(obj, vec![con]);
1480 prob.g_l = vec![1.0];
1481 prob.g_u = vec![1.0]; // x0² = 1 — nonconvex.
1482 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1483 }
1484
1485 /// A nonlinear objective expression whose quadratic part algebraically
1486 /// cancels has an empty Hessian ⇒ classify as `Lp`, not a spurious QP
1487 /// (which would otherwise route a linear problem to the QP IPM).
1488 #[test]
1489 fn classify_cancelling_quadratic_objective_is_lp() {
1490 // x0² − x0² ≡ 0: the degree-2 terms cancel in the polynomial walk.
1491 let sq = || {
1492 Expr::Binary(
1493 BinOp::Pow,
1494 Box::new(Expr::Var(0)),
1495 Box::new(Expr::Const(2.0)),
1496 )
1497 };
1498 let obj = Expr::Binary(BinOp::Sub, Box::new(sq()), Box::new(sq()));
1499 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1500 assert_eq!(classify_problem(&prob), ProblemClass::Lp);
1501 }
1502
1503 #[test]
1504 fn classify_nlp_from_transcendental_constraint() {
1505 let obj = Expr::Binary(
1506 BinOp::Pow,
1507 Box::new(Expr::Var(0)),
1508 Box::new(Expr::Const(2.0)),
1509 );
1510 let con = Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(1)));
1511 let prob = qp_stub(obj, vec![con]);
1512 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1513 }
1514
1515 /// Build a 2-var, 1-con problem stub with the given nonlinear
1516 /// objective and per-constraint nonlinear parts. Linear parts and
1517 /// bounds are filled with benign defaults.
1518 fn qp_stub(obj_nonlinear: Expr, con_nonlinear: Vec<Expr>) -> NlProblem {
1519 let m = con_nonlinear.len();
1520 NlProblem {
1521 n: 2,
1522 m,
1523 num_obj: 1,
1524 minimize: true,
1525 obj_nonlinear,
1526 obj_linear: vec![],
1527 obj_constant: 0.0,
1528 con_nonlinear,
1529 con_linear: vec![vec![]; m],
1530 x_l: vec![f64::NEG_INFINITY; 2],
1531 x_u: vec![f64::INFINITY; 2],
1532 g_l: vec![f64::NEG_INFINITY; m],
1533 g_u: vec![0.0; m],
1534 x0: vec![0.0; 2],
1535 lambda0: vec![0.0; m],
1536 suffixes: Default::default(),
1537 imported_funcs: Vec::new(),
1538 ampl_options: Vec::new(),
1539 var_names: Vec::new(),
1540 con_names: Vec::new(),
1541 }
1542 }
1543
1544 // Keep parse_nl_text reachable for a future header-fast-path test
1545 // against a committed .nl fixture.
1546 #[allow(dead_code)]
1547 fn _parse(txt: &str) -> NlProblem {
1548 parse_nl_text(txt).expect("valid .nl")
1549 }
1550
1551 /// **gh #492.** `min −x0 − 2·x1 s.t. x0 + x1 + 3 <= 6, x ∈ [0,3]²`,
1552 /// with the `3` written into the row's expression segment. `body` is
1553 /// the `C0` token stream for that constant.
1554 fn lp_with_row_constant(body: &str) -> NlProblem {
1555 let nl = format!(
1556 "g3 1 1 0
1557 2 1 1 0 0
1558 1 0 0 0 0 0
1559 0 0
1560 1 0 0
1561 0 0 0 1
1562 0 0 0 0 0
1563 2 2
1564 0 0
1565 0 0 0 0 0
1566C0
1567{body}
1568O0 0
1569n0
1570r
15711 6.0
1572b
15730 0 3
15740 0 3
1575k1
15761
1577J0 2
15780 1
15791 1
1580G0 2
15810 -1
15821 -2
1583"
1584 );
1585 parse_nl_text(&nl).expect("valid .nl")
1586 }
1587
1588 /// The classifier's fast path asks `is_trivially_zero` of every
1589 /// `con_nonlinear` entry, which is an *identity* test — it cannot tell
1590 /// "this row has a nonlinear part" from "this row's part is the
1591 /// constant 3". A bare literal survived that anyway, because the
1592 /// fallback polynomial walk lowers `Const` and finds no quadratic
1593 /// term; what it does not do is keep the constant, so the row's `+3`
1594 /// lived on only as `qp_extract`'s `const_shift`. After the parse-time
1595 /// fold the bound carries it and the fast path is exact.
1596 #[test]
1597 fn a_literal_row_constant_classifies_lp_and_moves_the_bound() {
1598 let prob = lp_with_row_constant("n3");
1599 assert_eq!(classify_problem(&prob), ProblemClass::Lp);
1600 // `x0 + x1 + 3 <= 6` is `x0 + x1 <= 3`.
1601 assert!((prob.g_u[0] - 3.0).abs() < 1e-12, "g_u = {}", prob.g_u[0]);
1602 assert!(
1603 matches!(prob.con_nonlinear[0], Expr::Const(c) if c == 0.0),
1604 "the row body should be the identity zero: {:?}",
1605 prob.con_nonlinear[0]
1606 );
1607 }
1608
1609 /// The case the polynomial walk cannot rescue: a constant it has to
1610 /// *compute*. `sqrt(9)` is not a degree-≤2 polynomial in any variable,
1611 /// so `analyze_quadratic` returns `None` and the row made the whole
1612 /// model NLP — an LP that never reached the convex route. The fold
1613 /// settles it at parse, where the value is known.
1614 #[test]
1615 fn a_computed_row_constant_does_not_make_an_lp_classify_nlp() {
1616 let prob = lp_with_row_constant("o39\nn9");
1617 assert_eq!(classify_problem(&prob), ProblemClass::Lp);
1618 assert!((prob.g_u[0] - 3.0).abs() < 1e-12, "g_u = {}", prob.g_u[0]);
1619 }
1620}