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::NlProblem;
39use pounce_common::types::{lower_bound_present, upper_bound_present};
40// `PSD_TOL` and `psd_band` live in `pounce_convex` (moved there, gh#1147 PR
41// review) so the C API's `PounceQp*` handle screens at the same band.
42#[cfg(test)]
43use pounce_convex::PSD_TOL;
44use pounce_convex::{Triplet, certify_psd_lower_triangle, psd_band};
45
46/// Budget on the **structural** cost of putting a convex QCQP into conic form,
47/// above which it is routed to the general NLP solver instead.
48///
49/// This is one of **two** independent guards on the conic path, and the split
50/// is the point. The `n · m` budget it replaces was silently doing two jobs:
51/// bounding the reformulation, and bounding the conic solve itself. Only the
52/// first was ever explained, and only the first has been fixed.
53///
54/// The reformulation cost genuinely used to scale with the problem's width —
55/// [`crate::qp_extract::extract_socp_with_map`] built a dense `n×n` Hessian and
56/// an `n`-column factor *per quadratic row*. It no longer does: rows are
57/// factored on their own support, and a diagonal row is factored in `O(k)`. So
58/// the model is now the actual work performed:
59///
60/// ```text
61/// Σ_rows k³ if the row's Hessian has off-diagonal entries
62/// k if it is diagonal (one √d per entry; no factorization)
63/// ```
64///
65/// where `k` is the number of variables that row couples. Units are
66/// floating-point operations, so the budget is a real time bound rather than a
67/// dimensionless guess. Measured: `qssp180` costs 1.96e5 flops and `nql180`
68/// 6.48e4 — both three orders of magnitude inside this budget, where the old
69/// proxy scored them at `n · m` ≈ 1e10 and rejected them.
70///
71/// **The value is deliberately conservative.** `2e7` sits just above `256³`,
72/// the per-row width the previous guard allowed, so every problem that routed
73/// to NLP for *reformulation* reasons still does — including the
74/// `qcqp1000-*`/`qcqp1500-*` rows, which Q0 measured solving well on the NLP
75/// path. Raising it to admit the dense thousand-variable rows is a separate
76/// experiment, now cheap to run because the `k³` term states its price.
77const SOCP_REFORM_FLOP_BUDGET: u128 = 20_000_000;
78
79/// The second guard: an **empirical** cap on the size of problem handed to the
80/// conic solve, independent of how cheap the reformulation is.
81///
82/// This exists because measurement, not theory, says so. With the extractor
83/// fixed, `qssp180` and `nql180` reformulate almost for free and the conic
84/// solver reaches an accurate optimum on both — and is *slower* doing it:
85///
86/// ```text
87/// NLP filter-IPM conic IPM
88/// qssp180 47.1 s / 27 it 178.5 s / 73 it 3.8x slower
89/// nql180 57.8 s / 36 it 156.5 s / 83 it 2.7x slower
90/// ```
91///
92/// Both reach `Optimal` with KKT error ≤ 1.5e-12, so this is a performance
93/// choice and not a soundness one. The conic path takes roughly 2.5x the
94/// iterations at this scale; until that is understood, the larger problems keep
95/// the solver that measurably wins.
96///
97/// `1e8` reproduces the routing the `n · m` proxy happened to produce for these
98/// two instances — the right answer for a reason that was never stated. It is a
99/// placeholder for a real conic-solve cost model, and it is deliberately the
100/// *only* thing still keyed to `n · m`.
101const SOCP_SOLVE_SIZE_CAP: u64 = 100_000_000;
102
103/// The mathematical class of a loaded problem, from most to least
104/// specialized. See the module docs and `dev-notes/lp-qp-routing.md`.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ProblemClass {
107 /// Linear objective, linear constraints.
108 Lp,
109 /// Convex quadratic objective, linear constraints (Hessian PSD).
110 ConvexQp,
111 /// Convex quadratic objective and/or convex quadratic constraints.
112 /// SOCP-representable; routes to the conic (SOCP) interior-point solver.
113 ConvexQcqp,
114 /// Quadratic objective with an indefinite (sense-adjusted) Hessian and
115 /// **linear** constraints. `auto` falls through to the NLP solver for a
116 /// local minimum; `solver_selection=qp-active-set` solves it directly with
117 /// the `pounce-qp` active-set engine, which takes an indefinite `H`
118 /// (gh #786) and returns a *local* minimum.
119 ///
120 /// What "local minimum" means on that path is narrower than it reads, and
121 /// was narrower still before gh #848. The §4.5 inertia control shifts
122 /// `H -> H + delta*I` so the *local model* is convex; it does not move the
123 /// iterate, and at a saddle `g = 0` makes the shifted step zero. So the
124 /// engine used to stop there and certify `Optimal` on the first-order
125 /// evidence alone — vanishing projected gradient, sign-admissible
126 /// working-set multipliers — which a saddle, and the constrained *maximum*
127 /// of `min x0*x1` on `x0 + x1 = 2`, satisfy exactly. The engine now
128 /// exhibits a direction `d` with `A_W d = 0` and `d'Hd < 0` and follows it
129 /// before certifying, so an `Optimal` here is second-order. It is still
130 /// only local: nothing on this path rules out a better minimum elsewhere.
131 ///
132 /// The linear-constraints half of that is load-bearing, not descriptive:
133 /// both consumers reach the model through
134 /// [`crate::qp_extract::extract_qp_with_map`], which keeps only the
135 /// degree-≤1 part of every row. A quadratic row here would be silently
136 /// dropped, so a model carrying one classifies [`Self::Nlp`] instead —
137 /// see [`ClassReason::NonconvexQcqp`].
138 NonconvexQp,
139 /// General nonlinear (transcendental terms, higher-degree
140 /// polynomials, or anything the classifier cannot prove quadratic).
141 Nlp,
142}
143
144impl ProblemClass {
145 /// Human-readable name for diagnostics and the
146 /// forced-solver-mismatch error message.
147 pub fn name(self) -> &'static str {
148 match self {
149 ProblemClass::Lp => "LP",
150 ProblemClass::ConvexQp => "convex QP",
151 ProblemClass::ConvexQcqp => "convex QCQP",
152 ProblemClass::NonconvexQp => "nonconvex QP",
153 ProblemClass::Nlp => "NLP",
154 }
155 }
156}
157
158/// Why [`classify_problem`] reached the class it did.
159///
160/// Every arm is a place the classifier *stops* — either because it proved a
161/// class or because it could not, and fell back to the more general one. The
162/// distinction matters most for a convex QCQP: four different findings
163/// (a nonconvex row, a nonconvex *sense*, an unaffordable reformulation, an
164/// oversized conic solve) all route to `Nlp`, and a user staring at
165/// `Problem class: NLP` on a model they know is a QCQP has so far had no way
166/// to tell which. `POUNCE_DBG_CLASSIFY=1` prints it.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ClassReason {
169 /// Neither the objective nor any row carries a nonlinear part.
170 NoNonlinearParts,
171 /// Nonlinear parts exist but all of them expanded to nothing of
172 /// degree 2 or higher — the model is linear after expansion.
173 NonlinearPartsCancelled,
174 /// The objective's nonlinear part is not a degree-2 polynomial.
175 ObjectiveNotQuadratic,
176 /// Row `row`'s nonlinear part is not a degree-2 polynomial.
177 ConstraintNotQuadratic { row: usize },
178 /// The objective is degree ≤ 2, but the recognizer *lost* a term
179 /// reaching its coefficients, so they are not the whole objective.
180 ObjectiveTermsDropped,
181 /// Row `row` is degree ≤ 2, but the recognizer *lost* a term reaching
182 /// its coefficients, so they are not the whole row (gh #685).
183 ///
184 /// "Lost", not merely "dropped": a term that cancels exactly is not
185 /// missing from the form, so those rows keep the fast path (gh #687).
186 ConstraintTermsDropped { row: usize },
187 /// The sense-adjusted objective Hessian has a negative eigenvalue, and
188 /// every constraint row is linear — a nonconvex **QP**.
189 ObjectiveHessianIndefinite,
190 /// The sense-adjusted objective Hessian has a negative eigenvalue *and*
191 /// some row carries curvature — a nonconvex **QCQP**, which is not a QP
192 /// and must not be classified as one (see [`ProblemClass::NonconvexQp`]).
193 NonconvexQcqp { row: usize },
194 /// Row `row` is quadratic with a PSD Hessian, but its bound sense
195 /// (`>=`, `=`, or two-sided) carves a nonconvex feasible set.
196 ConstraintSenseNonconvex { row: usize },
197 /// Row `row`'s quadratic Hessian is not PSD.
198 ConstraintHessianIndefinite { row: usize },
199 /// Convex QCQP, but the cone reformulation exceeds
200 /// [`SOCP_REFORM_FLOP_BUDGET`].
201 QcqpReformTooCostly { flops: u128 },
202 /// Convex QCQP whose reformulation is affordable, but whose conic
203 /// solve exceeds [`SOCP_SOLVE_SIZE_CAP`].
204 QcqpTooLargeToSolve { size: u64 },
205 /// Convex quadratic objective, linear constraints.
206 ConvexQuadraticObjective,
207 /// Convex QCQP inside both guards — the conic path is taken.
208 ConvexQcqpWithinBudgets { flops: u128, size: u64 },
209}
210
211impl ClassReason {
212 /// One-line explanation, for the `POUNCE_DBG_CLASSIFY` log.
213 pub fn explain(self) -> String {
214 match self {
215 ClassReason::NoNonlinearParts => {
216 "no nonlinear part in the objective or any row".to_string()
217 }
218 ClassReason::NonlinearPartsCancelled => {
219 "every nonlinear part expanded to a linear (or constant) polynomial".to_string()
220 }
221 ClassReason::ObjectiveNotQuadratic => {
222 "the objective's nonlinear part is not a degree-2 polynomial".to_string()
223 }
224 ClassReason::ConstraintNotQuadratic { row } => {
225 format!("row {row}'s nonlinear part is not a degree-2 polynomial")
226 }
227 ClassReason::ObjectiveTermsDropped => {
228 "the objective's quadratic form lost a term to an inexact \
229 floating-point fold or a flush to zero, so its coefficients are \
230 not the whole objective"
231 .to_string()
232 }
233 ClassReason::ConstraintTermsDropped { row } => format!(
234 "row {row}'s quadratic form lost a term to an inexact \
235 floating-point fold or a flush to zero, so its coefficients are \
236 not the whole row"
237 ),
238 ClassReason::ObjectiveHessianIndefinite => {
239 "the objective Hessian (sense-adjusted for minimization) is not PSD, \
240 and every row is linear"
241 .to_string()
242 }
243 ClassReason::NonconvexQcqp { row } => format!(
244 "the objective Hessian (sense-adjusted for minimization) is not PSD \
245 and row {row} is quadratic, so this is a nonconvex QCQP rather than \
246 a nonconvex QP"
247 ),
248 ClassReason::ConstraintSenseNonconvex { row } => format!(
249 "row {row} is a convex quadratic but its bound sense (>=, =, or \
250 two-sided) makes the feasible set nonconvex"
251 ),
252 ClassReason::ConstraintHessianIndefinite { row } => {
253 format!("row {row}'s quadratic Hessian is not PSD")
254 }
255 ClassReason::QcqpReformTooCostly { flops } => format!(
256 "convex QCQP downgraded: cone reformulation costs {flops} flops \
257 (budget {SOCP_REFORM_FLOP_BUDGET})"
258 ),
259 ClassReason::QcqpTooLargeToSolve { size } => format!(
260 "convex QCQP downgraded: conic solve size n·m = {size} \
261 (cap {SOCP_SOLVE_SIZE_CAP})"
262 ),
263 ClassReason::ConvexQuadraticObjective => {
264 "convex quadratic objective, linear rows".to_string()
265 }
266 ClassReason::ConvexQcqpWithinBudgets { flops, size } => format!(
267 "convex QCQP inside both guards: reformulation {flops} flops \
268 (budget {SOCP_REFORM_FLOP_BUDGET}), conic solve size n·m = {size} \
269 (cap {SOCP_SOLVE_SIZE_CAP})"
270 ),
271 }
272 }
273}
274
275/// The resolved solver to dispatch to, after combining a
276/// [`ProblemClass`] with the `solver_selection` option.
277///
278/// `auto` resolves an LP/convex-QP to [`SolverChoice::LpIpm`]/[`SolverChoice::QpIpm`],
279/// a convex QCQP to [`SolverChoice::SocpIpm`], and everything else to
280/// [`SolverChoice::Nlp`]; a forced `solver_selection` can pin any of them.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum SolverChoice {
283 /// The existing Wächter-Biegler filter-IPM.
284 Nlp,
285 /// LP interior-point in `pounce-convex`.
286 LpIpm,
287 /// Convex-QP interior-point in `pounce-convex`.
288 QpIpm,
289 /// Conic (SOCP) IPM in `pounce-convex`: convex QCQP, reformulated to
290 /// second-order cones.
291 SocpIpm,
292 /// Active-set QP in `pounce-qp` (parallel track).
293 QpActiveSet,
294}
295
296impl SolverChoice {
297 /// Human-readable description of the dispatched solver, for the
298 /// banner-level "Solving as …" log line. Names the algorithm and the
299 /// crate that implements it so a reader can tell which of pounce's
300 /// solvers actually ran.
301 pub fn describe(self) -> &'static str {
302 match self {
303 SolverChoice::Nlp => "NLP filter line-search interior-point (pounce-nlp)",
304 SolverChoice::LpIpm => "LP interior-point (pounce-convex)",
305 SolverChoice::QpIpm => "convex QP interior-point (pounce-convex)",
306 SolverChoice::SocpIpm => "convex QCQP conic interior-point (pounce-convex)",
307 SolverChoice::QpActiveSet => "active-set QP (pounce-qp)",
308 }
309 }
310}
311
312/// Parsed `solver_selection` option value.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum SolverSelection {
315 /// Pick the most specialized solver matching the class. Default.
316 Auto,
317 /// Force the NLP solver regardless of class (current behavior).
318 Nlp,
319 /// Force IPM-LP; error if the problem is not an LP.
320 LpIpm,
321 /// Force IPM-QP; error if the problem is not LP/convex-QP.
322 QpIpm,
323 /// Force the conic (SOCP) IPM; error if the problem is not a convex
324 /// LP / QP / QCQP (all of which the conic solver handles).
325 Socp,
326 /// Force active-set QP; error if the problem is not LP/convex-QP.
327 QpActiveSet,
328}
329
330impl SolverSelection {
331 /// Parse the `solver_selection` option string. Returns `None` for an
332 /// unrecognized value so the caller can surface a tidy error.
333 pub fn parse(s: &str) -> Option<Self> {
334 match s {
335 "auto" => Some(SolverSelection::Auto),
336 "nlp" => Some(SolverSelection::Nlp),
337 "lp-ipm" => Some(SolverSelection::LpIpm),
338 "qp-ipm" => Some(SolverSelection::QpIpm),
339 "socp" => Some(SolverSelection::Socp),
340 "qp-active-set" => Some(SolverSelection::QpActiveSet),
341 _ => None,
342 }
343 }
344
345 /// The accepted values, for error messages and option registration.
346 pub const VALUES: &'static [&'static str] =
347 &["auto", "nlp", "lp-ipm", "qp-ipm", "socp", "qp-active-set"];
348}
349
350/// Classify a parsed `.nl` problem.
351///
352/// Works off the already-split linear / nonlinear representation in
353/// [`NlProblem`]: a row contributes to the class only through its
354/// nonlinear `Expr` (the linear part is, by construction, linear). The
355/// classifier is deliberately conservative — see the module docs.
356pub fn classify_problem(prob: &NlProblem) -> ProblemClass {
357 classify_problem_explained(prob).0
358}
359
360/// [`classify_problem`], plus the finding that produced the class.
361///
362/// Also the single place the `POUNCE_DBG_CLASSIFY=1` routing log is
363/// emitted, so every caller — including the tests — sees the same line.
364pub fn classify_problem_explained(prob: &NlProblem) -> (ProblemClass, ClassReason) {
365 let verdict = classify_inner(prob);
366 if std::env::var_os("POUNCE_DBG_CLASSIFY").is_some() {
367 eprintln!(
368 "pounce: problem class {} — {} [{}]",
369 verdict.0.name(),
370 verdict.1.explain(),
371 header_census(prob)
372 );
373 }
374 verdict
375}
376
377/// The `.nl` header's nonlinearity census next to what the parsed trees
378/// actually say, for the `POUNCE_DBG_CLASSIFY` line.
379///
380/// The two are allowed to differ in exactly one direction — the header can
381/// over-state, because `parse_nl_text` folds a constant `C` body into the
382/// row bounds after AMPL took its census (`gh #492`). A header that
383/// *under*-states is a non-conforming writer, and the note says so rather
384/// than letting the discrepancy pass silently; nothing in the classifier
385/// trusts the header, so it is a diagnostic, not a guard.
386fn header_census(prob: &NlProblem) -> String {
387 let tree_rows = prob
388 .con_nonlinear
389 .iter()
390 .filter(|b| !b.is_trivially_zero())
391 .count();
392 let tree_obj = usize::from(!prob.obj_nonlinear.is_trivially_zero());
393 let Some(c) = prob.nl_counts else {
394 return format!("no .nl header census; trees: nl_rows={tree_rows} nl_obj={tree_obj}");
395 };
396 let flag = if c.nl_cons < tree_rows || c.nl_objs < tree_obj {
397 " — HEADER UNDER-STATES the trees; writer is non-conforming"
398 } else {
399 ""
400 };
401 format!(
402 "header nlc={} nlo={} nlvc={} nlvo={} nlvb={} ({} of {} vars nonlinear); \
403 trees: nl_rows={tree_rows} nl_obj={tree_obj}{flag}",
404 c.nl_cons,
405 c.nl_objs,
406 c.nl_vars_cons,
407 c.nl_vars_objs,
408 c.nl_vars_both,
409 c.nonlinear_vars(),
410 prob.n,
411 )
412}
413
414fn classify_inner(prob: &NlProblem) -> (ProblemClass, ClassReason) {
415 // Fast path: no nonlinear parts anywhere ⇒ LP.
416 //
417 // This deliberately stays a walk over the parsed rows rather than the
418 // O(1) header read (`nlc == 0 && nlo == 0`) the design note proposed.
419 // Two reasons, both found by writing it out: the test is already O(1)
420 // *per row* — `is_trivially_zero` matches the root node, it does not
421 // walk an `Expr` — so the header saves a pointer compare per row and
422 // nothing else; and the header answer is the writer's claim, while this
423 // one is a fact about the trees pounce will evaluate. Trusting the
424 // claim would route a model to the LP solver on the strength of a
425 // header field, which is not a trade this classifier makes anywhere
426 // else. The header is logged beside the verdict instead.
427 let obj_nl = !prob.obj_nonlinear.is_trivially_zero();
428 let cons_nl = prob.con_nonlinear.iter().any(|b| !b.is_trivially_zero());
429 if !obj_nl && !cons_nl {
430 return (ProblemClass::Lp, ClassReason::NoNonlinearParts);
431 }
432
433 // Objective curvature.
434 //
435 // The `None` arm covers two findings, and they are not the same
436 // finding: a genuinely non-quadratic term, and a degree-≤2 form the
437 // recognizer could not carry every coefficient of (gh #685). Both route
438 // NLP — the second *must*, since every consumer past this point reads
439 // those coefficients out — but a user reading `POUNCE_DBG_CLASSIFY=1` on
440 // a model they know is a QP is owed the difference.
441 let obj_quad = match prob.obj_nonlinear.analyze_quadratic() {
442 Some(q) => q,
443 None if prob.obj_nonlinear.quad_terms_dropped() => {
444 return (ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped);
445 }
446 // Objective has a non-quadratic nonlinear term ⇒ NLP.
447 None => return (ProblemClass::Nlp, ClassReason::ObjectiveNotQuadratic),
448 };
449
450 // Constraint curvature. A quadratic constraint makes this a QCQP;
451 // any non-quadratic constraint term makes the whole problem NLP.
452 let mut any_quadratic_constraint = false;
453 let mut first_quadratic_row = 0usize;
454 for (row, c) in prob.con_nonlinear.iter().enumerate() {
455 if c.is_trivially_zero() {
456 continue;
457 }
458 match c.analyze_quadratic() {
459 // Purely linear after all — and provably so. `analyze_quadratic`
460 // refuses a form that dropped a term, so an empty Hessian here
461 // is the absence of curvature rather than the failure to keep
462 // it. Before gh #685 it was not: a row whose quadratic
463 // coefficients cancelled arrived here empty, classified linear,
464 // and then vanished out of the extracted LP entirely.
465 Some(q) if q.is_empty() => {}
466 Some(_) => {
467 if !any_quadratic_constraint {
468 first_quadratic_row = row;
469 }
470 any_quadratic_constraint = true;
471 }
472 None if c.quad_terms_dropped() => {
473 return (
474 ProblemClass::Nlp,
475 ClassReason::ConstraintTermsDropped { row },
476 );
477 }
478 None => {
479 return (
480 ProblemClass::Nlp,
481 ClassReason::ConstraintNotQuadratic { row },
482 );
483 }
484 }
485 }
486
487 // Objective Hessian definiteness, as the *minimizer* sees it. A
488 // `maximize` problem is internally negated to a minimization, so a
489 // concave-up (PSD-Hessian) maximize is a nonconvex minimize. Test the
490 // sense-adjusted Hessian, not the raw one, or maximize-of-convex slips
491 // through to the convex IPM and produces a wrong (max/saddle) answer.
492 if !obj_quad.is_empty() {
493 let effective: QuadHessian = if prob.minimize {
494 obj_quad.clone()
495 } else {
496 obj_quad.iter().map(|(k, v)| (*k, -v)).collect()
497 };
498 if !hessian_is_psd(&effective, prob.n) {
499 // A nonconvex objective over *quadratic* rows is a nonconvex QCQP,
500 // not a nonconvex QP, and the distinction is a correctness one now
501 // that `ProblemClass::NonconvexQp` has a consumer: the QP extractor
502 // keeps only the degree-≤1 part of each row, so calling this a QP
503 // would hand `qp-active-set` a model with its curved constraints
504 // quietly deleted. NLP solves it soundly either way, which is where
505 // both used to go.
506 if any_quadratic_constraint {
507 return (
508 ProblemClass::Nlp,
509 ClassReason::NonconvexQcqp {
510 row: first_quadratic_row,
511 },
512 );
513 }
514 return (
515 ProblemClass::NonconvexQp,
516 ClassReason::ObjectiveHessianIndefinite,
517 );
518 }
519 }
520
521 if any_quadratic_constraint {
522 // Convex QCQP requires every quadratic constraint to be convex *as a
523 // feasible set*, not merely to have a PSD Hessian. A quadratic
524 // `g(x) = ½xᵀQx + … ` carves a convex region only when it is a
525 // one-sided **upper** bound `g(x) ≤ g_u` *and* `Q ⪰ 0`. The other
526 // senses are nonconvex even with a PSD Hessian:
527 // - `g(x) ≥ g_l` (finite lower bound): the super-level set of a
528 // convex function is nonconvex;
529 // - a quadratic equality `g(x) = c`;
530 // - a two-sided range `g_l ≤ g(x) ≤ g_u` (includes the `≥` side).
531 // This sense test matters now that ConvexQcqp is dispatched to the
532 // conic solver (it is SOC-representable only in the convex case); a
533 // misclassified nonconvex row would return a spurious "optimum".
534 // Anything not provably convex falls back to NLP (sound: the
535 // filter-IPM finds a local minimum either way).
536 let mut reform_flops: u128 = 0;
537 for (row, c) in prob.con_nonlinear.iter().enumerate() {
538 if c.is_trivially_zero() {
539 continue;
540 }
541 match c.analyze_quadratic() {
542 Some(q) if q.is_empty() => {} // purely linear after all
543 Some(q) => {
544 let lo = prob.g_l[row];
545 let hi = prob.g_u[row];
546 // Presence is directional (gh #401). The symmetric
547 // `|v| < 1e19` test this used to run called a row with a
548 // real bound past the *opposite* sentinel — `g(x) >= 5e20`
549 // arrives as `g_l = 5e20`, `g_u = 1e19` — free on both
550 // sides, and `continue` below then dropped a real
551 // constraint from the convexity decision.
552 let lo_present = lower_bound_present(lo);
553 let hi_present = upper_bound_present(hi);
554 let vacuous = !lo_present && !hi_present;
555 let upper_only = hi_present && !lo_present;
556 if vacuous {
557 // Free row: imposes nothing, so it cannot make the
558 // problem nonconvex. Ignore it.
559 continue;
560 }
561 // Convexity (cheap sparse certificate) gates the QCQP
562 // class; the per-row coupling guard then gates the *conic*
563 // path: a convex but heavily-coupled constraint Hessian is
564 // ruinous to put in SOC form, so route the whole QCQP to
565 // NLP (which solves it soundly) rather than burn the budget
566 // in the reformulation — the mittelmann `qcqp1000-*` rows.
567 if !upper_only {
568 return (
569 ProblemClass::Nlp,
570 ClassReason::ConstraintSenseNonconvex { row },
571 );
572 }
573 if !hessian_is_psd(&q, prob.n) {
574 return (
575 ProblemClass::Nlp,
576 ClassReason::ConstraintHessianIndefinite { row },
577 );
578 }
579 reform_flops = reform_flops.saturating_add(socp_reform_flops(&q));
580 }
581 // Both `None` arms are defensive here: the curvature loop
582 // above walks the same rows and has already returned for
583 // any row that answers `None`. They are kept so this
584 // `match` stays correct on its own terms rather than on
585 // the order of two loops.
586 None if c.quad_terms_dropped() => {
587 return (
588 ProblemClass::Nlp,
589 ClassReason::ConstraintTermsDropped { row },
590 );
591 }
592 None => {
593 return (
594 ProblemClass::Nlp,
595 ClassReason::ConstraintNotQuadratic { row },
596 );
597 }
598 }
599 }
600 // Two independent guards, for two different costs. A convex QCQP whose
601 // *reformulation* is too expensive falls back to NLP (see
602 // `SOCP_REFORM_FLOP_BUDGET`); so does one whose reformulation is cheap
603 // but whose *conic solve* is measurably slower than the filter-IPM at
604 // that scale (see `SOCP_SOLVE_SIZE_CAP`). Both fall back to a solver
605 // that answers the same question soundly, so either is a performance
606 // decision only.
607 let solve_size = (prob.n as u64).saturating_mul(prob.m as u64);
608 let too_costly_to_reform = reform_flops > SOCP_REFORM_FLOP_BUDGET;
609 let too_large_to_solve = solve_size > SOCP_SOLVE_SIZE_CAP;
610 if std::env::var_os("POUNCE_DBG_SOCP_COST").is_some() {
611 eprintln!(
612 "pounce: QCQP conic reformulation cost {reform_flops} flops \
613 (budget {SOCP_REFORM_FLOP_BUDGET}), conic solve size n·m \
614 {solve_size} (cap {SOCP_SOLVE_SIZE_CAP}) → {}",
615 if too_costly_to_reform || too_large_to_solve {
616 "NLP"
617 } else {
618 "ConvexQcqp"
619 }
620 );
621 }
622 if too_costly_to_reform {
623 return (
624 ProblemClass::Nlp,
625 ClassReason::QcqpReformTooCostly {
626 flops: reform_flops,
627 },
628 );
629 }
630 if too_large_to_solve {
631 return (
632 ProblemClass::Nlp,
633 ClassReason::QcqpTooLargeToSolve { size: solve_size },
634 );
635 }
636 return (
637 ProblemClass::ConvexQcqp,
638 ClassReason::ConvexQcqpWithinBudgets {
639 flops: reform_flops,
640 size: solve_size,
641 },
642 );
643 }
644
645 // Quadratic (or linear) convex objective with linear constraints.
646 if obj_quad.is_empty() {
647 // Objective nonlinear part collapsed to nothing quadratic and no
648 // constraints are quadratic — it was effectively linear.
649 (ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
650 } else {
651 (
652 ProblemClass::ConvexQp,
653 ClassReason::ConvexQuadraticObjective,
654 )
655 }
656}
657
658/// Resolve a [`ProblemClass`] and a [`SolverSelection`] into the solver
659/// to dispatch to, or an error string when a forced selection does not
660/// match the detected class.
661///
662/// `auto` routes LP / convex QP to the convex IPM (`QpIpm`) and convex
663/// QCQP to the conic IPM (`SocpIpm`); nonconvex QP and general NLP resolve
664/// to `Nlp`. A forced selection that does not match the detected class is
665/// rejected with a clear message.
666///
667/// `QpActiveSet` is the one forced selection that is **not** restricted to the
668/// convex classes: `pounce-qp` handles an indefinite Hessian by construction
669/// (§4.5 inertia control), which is what `docs/src/choosing-a-solver.md` has
670/// always advertised, so a `NonconvexQp` is accepted here and dispatched to it
671/// (gh #786). `auto` still sends that class to the NLP filter-IPM — the class
672/// is our inference, and the general path is the safer default for it — so
673/// this is reachable only by asking for the engine by name.
674///
675/// **What the verdict on an indefinite QP does and does not mean (gh #848).**
676/// This comment used to say the engine returns "a *local* solution", reading
677/// §4.5 inertia control as a second-order guarantee. It is not one: inertia
678/// control shifts the KKT diagonal so each *factorization* has the right
679/// inertia, which makes the linear algebra work and says nothing about the
680/// curvature of `P` on the feasible directions at the point finally returned.
681/// On `P = [[1, 5], [5, 1]]` over `[-1, 1]²` the engine reported `Optimal` at
682/// the strict saddle `x = 0`, `f = 0`, where `x = (1, -1)` is feasible at
683/// `f = -4`.
684///
685/// Second-order evidence is now part of the verdict, from two guards that
686/// cover different classes (both described on
687/// [`pounce_convex::solve_qp_active_set_inertia`]). The engine certifies the
688/// reduced Hessian on its working set's null space and, where it finds a
689/// witness of negative curvature, escapes along it and returns the *better
690/// point* — so the fix is usually a better answer rather than a worse status.
691/// The driver then screens what comes back by exhibition, which reaches the
692/// degenerate-active-bound class the first cannot, and refuses a verdict only
693/// where it holds a strictly better feasible point in hand.
694///
695/// Local, still, and not even that in general: seeing past every working set
696/// is the NP-hard part of nonconvex QP. `sqp_qp_certify_second_order` turns
697/// the engine-side check off.
698pub fn resolve_solver(
699 class: ProblemClass,
700 selection: SolverSelection,
701) -> Result<SolverChoice, String> {
702 use ProblemClass as P;
703 use SolverSelection as S;
704
705 // Is this class within the convex-QP family (LP or convex QP)?
706 let is_lp = class == P::Lp;
707 let is_convex_qp = matches!(class, P::Lp | P::ConvexQp);
708 // The conic solver handles the whole convex cone family: LP, convex QP,
709 // and (reformulated to second-order cones) convex QCQP.
710 let is_conic = matches!(class, P::Lp | P::ConvexQp | P::ConvexQcqp);
711
712 match selection {
713 // `auto`: route LP and convex QP to the specialized convex IPM
714 // (`pounce-convex`) and convex QCQP to the same crate's conic
715 // (SOCP) IPM; nonconvex QP and general NLP fall through to the NLP
716 // filter-IPM. LP is solved by the same QP IPM (P = 0), so it
717 // resolves to `QpIpm` rather than a distinct LP entry point.
718 S::Auto => match class {
719 P::Lp | P::ConvexQp => Ok(SolverChoice::QpIpm),
720 P::ConvexQcqp => Ok(SolverChoice::SocpIpm),
721 _ => Ok(SolverChoice::Nlp),
722 },
723 S::Nlp => Ok(SolverChoice::Nlp),
724 S::LpIpm => {
725 if is_lp {
726 Ok(SolverChoice::LpIpm)
727 } else {
728 Err(mismatch_msg(class, "lp-ipm", "an LP"))
729 }
730 }
731 S::QpIpm => {
732 if is_convex_qp {
733 Ok(SolverChoice::QpIpm)
734 } else {
735 Err(mismatch_msg(class, "qp-ipm", "an LP or convex QP"))
736 }
737 }
738 S::Socp => {
739 if is_conic {
740 Ok(SolverChoice::SocpIpm)
741 } else {
742 Err(mismatch_msg(class, "socp", "a convex LP, QP, or QCQP"))
743 }
744 }
745 S::QpActiveSet => {
746 if is_convex_qp || class == P::NonconvexQp {
747 Ok(SolverChoice::QpActiveSet)
748 } else {
749 Err(mismatch_msg(
750 class,
751 "qp-active-set",
752 "an LP or a QP with linear constraints, convex or indefinite",
753 ))
754 }
755 }
756 }
757}
758
759fn mismatch_msg(class: ProblemClass, forced: &str, expected: &str) -> String {
760 format!(
761 "problem class {} does not match forced solver {} (expected {})",
762 class.name(),
763 forced,
764 expected
765 )
766}
767
768// ---------------------------------------------------------------------
769// Quadratic-form analysis
770// ---------------------------------------------------------------------
771
772// The recognizer itself lives in `pounce-nl` (`nl_quadratic`), next to the
773// `Expr` DAG it walks, so that the consumers that are not this binary can
774// use it — see that module's docs. What stays here is the *routing*: which
775// `ProblemClass` a recognized form implies, and which guards a QCQP has to
776// clear to reach the conic path. These re-exports keep the call sites in
777// `qp_extract` and in the tests below spelled as they were.
778pub(crate) use pounce_nl::nl_quadratic::QuadHessian;
779#[cfg(test)]
780use pounce_nl::nl_quadratic::analyze_quadratic;
781
782// ---------------------------------------------------------------------
783// PSD test
784// ---------------------------------------------------------------------
785
786/// Number of distinct variables that couple inside a quadratic form — the
787/// dimension `k` of the matrix that would be factored.
788fn hessian_active_vars(h: &QuadHessian) -> usize {
789 let mut active: Vec<usize> = Vec::with_capacity(2 * h.len());
790 for (i, j) in h.keys() {
791 active.push(*i);
792 active.push(*j);
793 }
794 active.sort_unstable();
795 active.dedup();
796 active.len()
797}
798
799/// Flops [`crate::qp_extract::socp_factor_rows`] will spend putting one
800/// quadratic row into cone form.
801///
802/// A diagonal Hessian takes the `O(k)` path — one `√d` per entry, no
803/// factorization — which is why the very large diagonal QCQPs are cheap to
804/// reformulate despite their width. Anything with an off-diagonal entry gets a
805/// pivoted Cholesky on a dense `k×k`, i.e. `O(k³)`.
806fn socp_reform_flops(h: &QuadHessian) -> u128 {
807 let k = hessian_active_vars(h) as u128;
808 if h.keys().any(|(i, j)| i != j) {
809 k.saturating_mul(k).saturating_mul(k)
810 } else {
811 k
812 }
813}
814
815/// Is the (symmetric, sparse) Hessian positive semidefinite?
816///
817/// A diagonal Hessian is settled in `O(nnz)` by sign before converting to the
818/// reusable triplet API. A coupled Hessian is certified from the inertia of
819/// `H + tol·I`. An inconclusive certificate conservatively routes to NLP.
820///
821/// `tol` is [`pounce_convex::PSD_TOL`] scaled by `‖H‖∞`, but **only
822/// downwards** — see [`pounce_convex::psd_band`]. An eigenvalue is in the
823/// units of `H`, so a fixed `1e-9` is an absolute threshold on a
824/// scale-dependent quantity, and the failure it produced (gh#872) is worse than a widened band: on a `‖H‖∞ ~ 1e-10` model
825/// the shift *dominates* `H`, so the inertia count certifies `1e-9·I` rather
826/// than `H` and the certificate is vacuous. A pure change of variable units —
827/// metres to micrometres — was enough to route a strongly indefinite Hessian
828/// (`|λ_min| / λ_max = 0.667`) to the convex engine, which returned the saddle
829/// at the start point as `Optimal Solution Found` with zero iterations.
830///
831/// The relative form keeps a constant ~7 orders of margin over the
832/// factorization's own roundoff (`ε·‖H‖∞`) at every scale, where the absolute
833/// one had that margin only near `‖H‖∞ ≈ 1`. On badly scaled, rank-deficient
834/// PSD matrices the shift can still fall below floating-point resolution, in
835/// which case FERAL reports a zero pivot and this dispatch takes the slower
836/// NLP path.
837fn hessian_is_psd(h: &QuadHessian, n: usize) -> bool {
838 if h.is_empty() {
839 return true;
840 }
841 let tol = psd_band(h.values().copied());
842 if h.keys().all(|(i, j)| i == j) {
843 return h.values().all(|value| *value >= -tol);
844 }
845
846 let lower: Vec<_> = h
847 .iter()
848 // QuadHessian is upper-triangular (i <= j). The certificate wants the
849 // lower triangle, so (i, j) is emitted at (row = j, col = i).
850 .map(|(&(i, j), &val)| Triplet::new(j, i, val))
851 .collect();
852 certify_psd_lower_triangle(n, &lower, tol, || {
853 Box::new(pounce_feral::FeralSolverInterface::with_config(
854 pounce_feral::FeralConfig::default(),
855 ))
856 })
857 .unwrap_or(false)
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use crate::nl_reader::NlBody;
864 use crate::nl_reader::{BinOp, Expr, UnaryOp, parse_nl_text};
865
866 // --- SolverSelection parsing ---
867
868 #[test]
869 fn parse_selection_values() {
870 assert_eq!(SolverSelection::parse("auto"), Some(SolverSelection::Auto));
871 assert_eq!(SolverSelection::parse("nlp"), Some(SolverSelection::Nlp));
872 assert_eq!(
873 SolverSelection::parse("lp-ipm"),
874 Some(SolverSelection::LpIpm)
875 );
876 assert_eq!(
877 SolverSelection::parse("qp-ipm"),
878 Some(SolverSelection::QpIpm)
879 );
880 assert_eq!(
881 SolverSelection::parse("qp-active-set"),
882 Some(SolverSelection::QpActiveSet)
883 );
884 assert_eq!(SolverSelection::parse("lp-simplex"), None);
885 assert_eq!(SolverSelection::parse("bogus"), None);
886 }
887
888 // --- resolve_solver: auto routes LP/convex-QP to the convex IPM,
889 // everything else to NLP ---
890
891 #[test]
892 fn auto_routes_convex_qp_family_to_qp_ipm() {
893 assert_eq!(
894 resolve_solver(ProblemClass::Lp, SolverSelection::Auto),
895 Ok(SolverChoice::QpIpm),
896 "auto should route LP to the convex IPM (P=0)"
897 );
898 assert_eq!(
899 resolve_solver(ProblemClass::ConvexQp, SolverSelection::Auto),
900 Ok(SolverChoice::QpIpm),
901 "auto should route convex QP to the convex IPM"
902 );
903 }
904
905 #[test]
906 fn auto_routes_convex_qcqp_to_socp() {
907 assert_eq!(
908 resolve_solver(ProblemClass::ConvexQcqp, SolverSelection::Auto),
909 Ok(SolverChoice::SocpIpm),
910 "auto should route convex QCQP to the conic IPM"
911 );
912 }
913
914 #[test]
915 fn auto_routes_nonconvex_to_nlp() {
916 for class in [ProblemClass::NonconvexQp, ProblemClass::Nlp] {
917 assert_eq!(
918 resolve_solver(class, SolverSelection::Auto),
919 Ok(SolverChoice::Nlp),
920 "auto must resolve to Nlp for {:?}",
921 class
922 );
923 }
924 }
925
926 #[test]
927 fn forced_socp_accepts_convex_cone_family_only() {
928 for class in [
929 ProblemClass::Lp,
930 ProblemClass::ConvexQp,
931 ProblemClass::ConvexQcqp,
932 ] {
933 assert_eq!(
934 resolve_solver(class, SolverSelection::Socp),
935 Ok(SolverChoice::SocpIpm),
936 "socp should accept {:?}",
937 class
938 );
939 }
940 assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::Socp).is_err());
941 assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::Socp).is_err());
942 }
943
944 #[test]
945 fn forced_nlp_always_ok() {
946 assert_eq!(
947 resolve_solver(ProblemClass::ConvexQp, SolverSelection::Nlp),
948 Ok(SolverChoice::Nlp)
949 );
950 }
951
952 #[test]
953 fn forced_lp_on_nlp_errors() {
954 let err = resolve_solver(ProblemClass::Nlp, SolverSelection::LpIpm).unwrap_err();
955 assert!(err.contains("NLP"), "msg should name detected class: {err}");
956 assert!(
957 err.contains("lp-ipm"),
958 "msg should name forced solver: {err}"
959 );
960 }
961
962 #[test]
963 fn forced_lp_on_lp_ok() {
964 assert_eq!(
965 resolve_solver(ProblemClass::Lp, SolverSelection::LpIpm),
966 Ok(SolverChoice::LpIpm)
967 );
968 }
969
970 /// gh #786: `qp-active-set` is the one forced selection that takes an
971 /// indefinite Hessian — `pounce-qp` controls the inertia of the reduced
972 /// Hessian by construction, which is what `choosing-a-solver.md` has always
973 /// advertised. It still refuses a class it cannot *extract*: a QCQP carries
974 /// curvature in its rows, and the QP extractor would drop it.
975 #[test]
976 fn forced_qp_active_set_accepts_indefinite_qp_but_not_a_qcqp() {
977 for class in [
978 ProblemClass::Lp,
979 ProblemClass::ConvexQp,
980 ProblemClass::NonconvexQp,
981 ] {
982 assert_eq!(
983 resolve_solver(class, SolverSelection::QpActiveSet),
984 Ok(SolverChoice::QpActiveSet),
985 "qp-active-set should accept {class:?}"
986 );
987 }
988 for class in [ProblemClass::ConvexQcqp, ProblemClass::Nlp] {
989 let err = resolve_solver(class, SolverSelection::QpActiveSet).unwrap_err();
990 assert!(err.contains("qp-active-set"), "{err}");
991 assert!(err.contains(class.name()), "{err}");
992 }
993 }
994
995 /// The lift above is scoped to the *named* engine. `auto` keeps sending a
996 /// nonconvex QP to the NLP filter-IPM: the class is our inference, and the
997 /// general path is the safer default for it.
998 #[test]
999 fn auto_still_routes_a_nonconvex_qp_to_nlp() {
1000 assert_eq!(
1001 resolve_solver(ProblemClass::NonconvexQp, SolverSelection::Auto),
1002 Ok(SolverChoice::Nlp)
1003 );
1004 }
1005
1006 #[test]
1007 fn forced_qp_accepts_lp_and_convex_qp_only() {
1008 assert_eq!(
1009 resolve_solver(ProblemClass::Lp, SolverSelection::QpIpm),
1010 Ok(SolverChoice::QpIpm)
1011 );
1012 assert_eq!(
1013 resolve_solver(ProblemClass::ConvexQp, SolverSelection::QpIpm),
1014 Ok(SolverChoice::QpIpm)
1015 );
1016 assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::QpIpm).is_err());
1017 assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::QpIpm).is_err());
1018 }
1019
1020 // --- Poly / quadratic analysis unit tests ---
1021
1022 #[test]
1023 fn poly_of_quadratic_diagonal() {
1024 // (x0 - 1)^2 => x0^2 - 2 x0 + 1
1025 let e = Expr::Binary(
1026 BinOp::Pow,
1027 Box::new(Expr::Binary(
1028 BinOp::Sub,
1029 Box::new(Expr::Var(0)),
1030 Box::new(Expr::Const(1.0)),
1031 )),
1032 Box::new(Expr::Const(2.0)),
1033 );
1034 let h = analyze_quadratic(&e).expect("degree-2 polynomial");
1035 // d²/dx0² (x0²) = 2
1036 assert_eq!(h.get(&(0, 0)), Some(&2.0));
1037 }
1038
1039 #[test]
1040 fn poly_rejects_transcendental() {
1041 // sin(x0) is not polynomial.
1042 let e = Expr::Unary(UnaryOp::Sin, Box::new(Expr::Var(0)));
1043 assert!(analyze_quadratic(&e).is_none());
1044 }
1045
1046 #[test]
1047 fn poly_rejects_cubic() {
1048 // x0^3
1049 let e = Expr::Binary(
1050 BinOp::Pow,
1051 Box::new(Expr::Var(0)),
1052 Box::new(Expr::Const(3.0)),
1053 );
1054 assert!(analyze_quadratic(&e).is_none());
1055 }
1056
1057 #[test]
1058 fn cross_term_hessian() {
1059 // x0 * x1 => H[0,1] = 1
1060 let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1061 let h = analyze_quadratic(&e).expect("degree-2");
1062 assert_eq!(h.get(&(0, 1)), Some(&1.0));
1063 }
1064
1065 #[test]
1066 fn large_quadratic_sum_lowers_without_quadratic_blowup() {
1067 // Regression guard for the `solver_selection=auto` classifier hang
1068 // (mittelmann QCQP/bearing_400/qssp180 emitted zero iterations and
1069 // burned the full CPU budget). A quadratic expressed as a large
1070 // `Sum` of monomials must lower in O(N log N): the recognizer used
1071 // to re-scan the whole accumulated polynomial for zeros on every
1072 // merged item, so an N-monomial sum was O(N²) and spun for >300 s
1073 // before the solver started (Ipopt solved the same problems in
1074 // seconds). Build a 5000-term sum of distinct squares and confirm
1075 // the full diagonal Hessian is recovered.
1076 //
1077 // That fix covered the n-ary `Sum` node only. The same quadratic
1078 // written as a chain of binary `Add`s — which is what a `.nl` writer
1079 // emitting `o0` produces — kept the re-scan until Q3 moved the
1080 // per-merge zero check onto the merged terms; see
1081 // `wide_diagonal_convex_qcqp_keeps_conic` for that shape.
1082 const N: usize = 5000;
1083 let terms: Vec<Expr> = (0..N)
1084 .map(|i| Expr::Binary(BinOp::Mul, Box::new(Expr::Var(i)), Box::new(Expr::Var(i))))
1085 .collect();
1086 let e = Expr::Sum(terms);
1087 let h = analyze_quadratic(&e).expect("degree-2 sum of squares is a QP");
1088 assert_eq!(h.len(), N, "every xᵢ² contributes one diagonal entry");
1089 assert_eq!(h.get(&(0, 0)), Some(&2.0));
1090 assert_eq!(h.get(&(N - 1, N - 1)), Some(&2.0));
1091 }
1092
1093 // --- PSD test ---
1094
1095 #[test]
1096 fn psd_accepts_convex_separable() {
1097 // diag(2, 4): both eigenvalues positive.
1098 let mut h = QuadHessian::new();
1099 h.insert((0, 0), 2.0);
1100 h.insert((1, 1), 4.0);
1101 assert!(hessian_is_psd(&h, 2));
1102 }
1103
1104 #[test]
1105 fn psd_rejects_indefinite() {
1106 // [[0,1],[1,0]] has eigenvalues ±1.
1107 let mut h = QuadHessian::new();
1108 h.insert((0, 1), 1.0);
1109 assert!(!hessian_is_psd(&h, 2));
1110 }
1111
1112 #[test]
1113 fn psd_accepts_psd_with_zero_eigenvalue() {
1114 // [[1,1],[1,1]] is PSD (eigenvalues 0 and 2).
1115 let mut h = QuadHessian::new();
1116 h.insert((0, 0), 1.0);
1117 h.insert((0, 1), 1.0);
1118 h.insert((1, 1), 1.0);
1119 assert!(hessian_is_psd(&h, 2));
1120 }
1121
1122 // --- A1: ±PSD_TOL boundary of the convexity test (silent-misroute guard) ---
1123
1124 /// The safety-critical case: a *real* negative direction — even a small
1125 /// one, well beyond `PSD_TOL` — must read non-PSD so an indefinite QP
1126 /// routes to NLP, never to the convex IPM (which would return a spurious
1127 /// "optimal" at a saddle/maximum).
1128 #[test]
1129 fn psd_rejects_small_but_real_negative_curvature() {
1130 // diag(2, −1e-3): min eigenvalue −1e-3 ≪ −PSD_TOL.
1131 let mut h = QuadHessian::new();
1132 h.insert((0, 0), 2.0);
1133 h.insert((1, 1), -1e-3);
1134 assert!(
1135 !hessian_is_psd(&h, 2),
1136 "a −1e-3 eigenvalue must read indefinite, not be rounded to PSD"
1137 );
1138 }
1139
1140 /// Pin the band at `±PSD_TOL` (1e-9) for a Hessian whose own scale is at
1141 /// or above 1 — where [`psd_band`]'s `.min(1.0)` leaves it untouched.
1142 ///
1143 /// Within the band a tiny negative eigenvalue rounds to PSD **by design**:
1144 /// a genuinely semidefinite Hessian whose smallest eigenvalue computes as
1145 /// a tiny negative (roundoff) must not be misread as nonconvex. At this
1146 /// scale the band is far below the error of solving a convex QP with that
1147 /// much curvature, so it is the sound tradeoff — see the A1 Finding in
1148 /// `dev-notes/pr70-hardening.md`. (Diagonal Hessians take the exact sign
1149 /// path, so this is deterministic.)
1150 #[test]
1151 fn psd_threshold_is_psd_tol_at_unit_scale() {
1152 let mut just_inside = QuadHessian::new();
1153 just_inside.insert((0, 0), 1.0);
1154 just_inside.insert((1, 1), -1e-10); // |λ| < PSD_TOL ⇒ treated as zero
1155 assert!(
1156 hessian_is_psd(&just_inside, 2),
1157 "−1e-10 against ‖H‖ = 1 is within tolerance and must round to PSD"
1158 );
1159
1160 let mut just_outside = QuadHessian::new();
1161 just_outside.insert((0, 0), 1.0);
1162 just_outside.insert((1, 1), -1e-7); // |λ| > PSD_TOL ⇒ genuine negative
1163 assert!(
1164 !hessian_is_psd(&just_outside, 2),
1165 "−1e-7 against ‖H‖ = 1 is beyond tolerance and must read indefinite"
1166 );
1167 }
1168
1169 /// The band scales with `‖H‖∞`, so the verdict survives a pure change of
1170 /// variable units (gh#872).
1171 ///
1172 /// `H = K⁻² · [[1, 5], [5, 1]]` is strongly indefinite at every `K` —
1173 /// `|λ_min| / λ_max = 2/3`, nowhere near roundoff — and the objective
1174 /// *values* of the model it comes from do not depend on `K` at all. Under
1175 /// the old absolute `1e-9` this read PSD from `K = 1e5` on, and the convex
1176 /// engine then returned the saddle at the start point as `Optimal` with
1177 /// zero iterations and an objective wrong by 100%.
1178 ///
1179 /// Both branches of `hessian_is_psd` are exercised: the coupled matrix
1180 /// goes through the factorization certificate, the diagonal one through
1181 /// the `O(nnz)` sign path. A band that is relative in one and absolute in
1182 /// the other would pass a test that used only the first.
1183 #[test]
1184 fn psd_verdict_is_invariant_under_a_change_of_units() {
1185 for k in [1.0_f64, 1e2, 1e5, 1e8, 1e-4] {
1186 let f = k.powi(-2);
1187
1188 let mut coupled = QuadHessian::new();
1189 coupled.insert((0, 0), f);
1190 coupled.insert((0, 1), 5.0 * f);
1191 coupled.insert((1, 1), f);
1192 assert!(
1193 !hessian_is_psd(&coupled, 2),
1194 "K = {k:e}: [[1,5],[5,1]] scaled by K⁻² is indefinite at every \
1195 scale (|λ_min|/λ_max = 2/3); an absolute band hides it"
1196 );
1197
1198 let mut diagonal = QuadHessian::new();
1199 diagonal.insert((0, 0), 6.0 * f);
1200 diagonal.insert((1, 1), -4.0 * f);
1201 assert!(
1202 !hessian_is_psd(&diagonal, 2),
1203 "K = {k:e}: diag(6, −4) scaled by K⁻² is indefinite at every scale"
1204 );
1205
1206 let mut convex = QuadHessian::new();
1207 convex.insert((0, 0), 6.0 * f);
1208 convex.insert((1, 1), 4.0 * f);
1209 assert!(
1210 hessian_is_psd(&convex, 2),
1211 "K = {k:e}: diag(6, 4) scaled by K⁻² is PD at every scale and \
1212 must keep reaching the convex engine"
1213 );
1214 }
1215 }
1216
1217 /// The band never *widens*, which is the half of gh#872's fix that is not
1218 /// "make it relative".
1219 ///
1220 /// A plain `PSD_TOL * ‖H‖∞` would put the band at `1e-3` on a `‖H‖∞ = 1e6`
1221 /// model, handing the convex engine Hessians it correctly rejects today —
1222 /// a wrong answer traded for a wrong answer. `psd_band` clamps the factor
1223 /// at 1, so above unit scale the threshold is exactly what it always was.
1224 #[test]
1225 fn psd_band_does_not_widen_above_unit_scale() {
1226 let mut h = QuadHessian::new();
1227 h.insert((0, 0), 1e6);
1228 h.insert((1, 1), -1e-7);
1229 assert!(
1230 !hessian_is_psd(&h, 2),
1231 "−1e-7 must stay indefinite however large the rest of H is"
1232 );
1233 assert_eq!(
1234 psd_band(h.values().copied()),
1235 PSD_TOL,
1236 "the band is clamped at PSD_TOL for ‖H‖∞ ≥ 1"
1237 );
1238 }
1239
1240 // --- Sparse-factorization PSD certificate (CVXQP family) ---
1241
1242 /// A large *diagonal* Hessian must take the O(nnz) sign fast path — no
1243 /// factorization at all — and read PSD. This is the large separable /
1244 /// least-squares QP shape (AUG2D, LISWET, …) that stays on the convex
1245 /// fast path.
1246 #[test]
1247 fn large_diagonal_hessian_is_cheap_and_psd() {
1248 let n = 50_000;
1249 let mut h = QuadHessian::new();
1250 for i in 0..n {
1251 h.insert((i, i), 2.0);
1252 }
1253 assert!(
1254 hessian_is_psd(&h, n),
1255 "diag(2,…,2) is PSD and must be settled by the O(nnz) sign path"
1256 );
1257 }
1258
1259 /// A large *coupled* convex Hessian (off-diagonal terms over many
1260 /// variables) is the CVXQP-family shape that the old dense-Jacobi cap
1261 /// refused to certify (routing it to NLP). The sparse-factorization
1262 /// certificate now proves it PSD cheaply, so it reaches the convex
1263 /// solver. This is the regression fix.
1264 #[test]
1265 fn large_coupled_convex_hessian_is_certified_psd() {
1266 let k = 1_000;
1267 let mut h = QuadHessian::new();
1268 // Diagonally dominant tridiagonal: SPD. 2 on the diagonal, 0.1 on
1269 // the off-diagonal coupling chain ⇒ strictly diagonally dominant.
1270 for i in 0..k {
1271 h.insert((i, i), 2.0);
1272 }
1273 for i in 0..(k - 1) {
1274 h.insert((i, i + 1), 0.1);
1275 }
1276 assert!(
1277 hessian_is_psd(&h, k),
1278 "a diagonally-dominant coupled Hessian over {k} vars must be \
1279 certified PSD by the sparse factorization (CVXQP regression)"
1280 );
1281 }
1282
1283 /// The sparse certificate must still *reject* a large coupled Hessian
1284 /// that is genuinely indefinite — size does not buy a free pass.
1285 #[test]
1286 fn large_coupled_indefinite_hessian_is_rejected() {
1287 let k = 1_000;
1288 let mut h = QuadHessian::new();
1289 for i in 0..k {
1290 h.insert((i, i), 2.0);
1291 }
1292 for i in 0..(k - 1) {
1293 h.insert((i, i + 1), 0.1);
1294 }
1295 // Flip one diagonal strongly negative ⇒ an indefinite direction.
1296 h.insert((0, 0), -5.0);
1297 assert!(
1298 !hessian_is_psd(&h, k),
1299 "a coupled Hessian with a strong negative-curvature direction \
1300 must be rejected regardless of size"
1301 );
1302 }
1303
1304 /// A *small* coupled Hessian is certified by the same sparse path.
1305 #[test]
1306 fn small_coupled_hessian_is_certified_psd() {
1307 // [[2, 1], [1, 2]] — eigenvalues 1 and 3, PSD.
1308 let mut h = QuadHessian::new();
1309 h.insert((0, 0), 2.0);
1310 h.insert((0, 1), 1.0);
1311 h.insert((1, 1), 2.0);
1312 assert!(hessian_is_psd(&h, 2));
1313 }
1314
1315 // --- End-to-end classify_problem on parsed .nl text ---
1316
1317 /// Minimal `g`-format `.nl` text builder is overkill; instead use the
1318 /// reader's own fixtures via parse_nl_text on hand-written stubs.
1319 /// These cover the header LP fast-path and the AST walk.
1320
1321 #[test]
1322 fn classify_pure_lp() {
1323 // minimize x0 + x1 s.t. x0 + x1 <= 1, no nonlinear parts.
1324 // Build an NlProblem directly for a hermetic test.
1325 let prob = NlProblem {
1326 src: None,
1327 cse_bodies: Vec::new(),
1328 complementarity: Vec::new(),
1329 n: 2,
1330 m: 1,
1331 num_obj: 1,
1332 minimize: true,
1333 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1334 obj_linear: vec![(0, 1.0), (1, 1.0)],
1335 obj_constant: 0.0,
1336 con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
1337 con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
1338 x_l: vec![0.0, 0.0],
1339 x_u: vec![f64::INFINITY, f64::INFINITY],
1340 g_l: vec![f64::NEG_INFINITY],
1341 g_u: vec![1.0],
1342 x0: vec![0.0, 0.0],
1343 lambda0: vec![0.0],
1344 suffixes: Default::default(),
1345 imported_funcs: Vec::new(),
1346 ampl_options: Vec::new(),
1347 nl_counts: None,
1348 n_discrete: None,
1349 var_names: Vec::new(),
1350 con_names: Vec::new(),
1351 };
1352 assert_eq!(classify_problem(&prob), ProblemClass::Lp);
1353 }
1354
1355 #[test]
1356 fn classify_convex_qp() {
1357 // minimize x0^2 + x1^2 s.t. linear; convex (H = diag(2,2)).
1358 let obj = Expr::Binary(
1359 BinOp::Add,
1360 Box::new(Expr::Binary(
1361 BinOp::Pow,
1362 Box::new(Expr::Var(0)),
1363 Box::new(Expr::Const(2.0)),
1364 )),
1365 Box::new(Expr::Binary(
1366 BinOp::Pow,
1367 Box::new(Expr::Var(1)),
1368 Box::new(Expr::Const(2.0)),
1369 )),
1370 );
1371 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1372 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
1373 }
1374
1375 /// **gh #401.** A quadratic row whose real bound lies past the *opposite*
1376 /// sentinel must not be waved through as a free row.
1377 ///
1378 /// `x0² + x1² >= 5e20` arrives as `g_l = 5e20` (real), `g_u = 1e19`
1379 /// (the absent-upper sentinel). The symmetric `|v| < 1e19` test called
1380 /// *both* sides infinite, so `vacuous` was true and the row was skipped
1381 /// with "Free row: imposes nothing" — and the model then classified as a
1382 /// convex QCQP and went to the conic solver as if the constraint were not
1383 /// there. It is a reverse-convex row: the honest answer is NLP.
1384 #[test]
1385 fn a_quadratic_row_bounded_past_the_sentinel_is_not_vacuous() {
1386 let con = Expr::Binary(
1387 BinOp::Add,
1388 Box::new(Expr::Binary(
1389 BinOp::Pow,
1390 Box::new(Expr::Var(0)),
1391 Box::new(Expr::Const(2.0)),
1392 )),
1393 Box::new(Expr::Binary(
1394 BinOp::Pow,
1395 Box::new(Expr::Var(1)),
1396 Box::new(Expr::Const(2.0)),
1397 )),
1398 );
1399 let mut prob = qp_stub(Expr::Const(0.0), vec![con]);
1400 prob.obj_linear = vec![(0, 1.0)];
1401 prob.g_l = vec![5e20]; // real lower bound
1402 prob.g_u = vec![1e19]; // absent-upper sentinel
1403 assert_eq!(
1404 classify_problem(&prob),
1405 ProblemClass::Nlp,
1406 "a `>=` quadratic row is reverse-convex and must route to NLP; \
1407 treating it as a free row sent the model to the conic solver \
1408 with the constraint silently dropped"
1409 );
1410 }
1411
1412 #[test]
1413 fn classify_nonconvex_qp() {
1414 // minimize x0 * x1 (indefinite Hessian) s.t. linear.
1415 let obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1416 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1417 assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1418 }
1419
1420 /// gh #786: an indefinite objective over a *quadratic* row is a nonconvex
1421 /// QCQP, and must not be handed the `NonconvexQp` label.
1422 ///
1423 /// This is not a naming preference. `NonconvexQp` now has a consumer —
1424 /// `solver_selection=qp-active-set` reaches the QP extractor through it —
1425 /// and that extractor keeps only the degree-≤1 part of every row. Labelling
1426 /// this a QP would send the engine a model with `x0² + x1² ≤ 1` deleted,
1427 /// and it would report an "optimal" outside the ball.
1428 #[test]
1429 fn classify_indefinite_objective_with_quadratic_row_is_not_a_qp() {
1430 let obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1431 let ball = Expr::Binary(
1432 BinOp::Add,
1433 Box::new(Expr::Binary(
1434 BinOp::Pow,
1435 Box::new(Expr::Var(0)),
1436 Box::new(Expr::Const(2.0)),
1437 )),
1438 Box::new(Expr::Binary(
1439 BinOp::Pow,
1440 Box::new(Expr::Var(1)),
1441 Box::new(Expr::Const(2.0)),
1442 )),
1443 );
1444 let mut prob = qp_stub(obj, vec![ball]);
1445 prob.g_l = vec![f64::NEG_INFINITY];
1446 prob.g_u = vec![1.0];
1447 let (class, reason) = classify_problem_explained(&prob);
1448 assert_eq!(class, ProblemClass::Nlp);
1449 assert_eq!(reason, ClassReason::NonconvexQcqp { row: 0 });
1450 // And the label it must not get, stated as the routing consequence.
1451 assert!(
1452 resolve_solver(class, SolverSelection::QpActiveSet).is_err(),
1453 "a nonconvex QCQP must not reach the active-set QP engine"
1454 );
1455 }
1456
1457 #[test]
1458 fn classify_nlp_from_transcendental_objective() {
1459 let obj = Expr::Unary(UnaryOp::Exp, Box::new(Expr::Var(0)));
1460 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1461 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1462 }
1463
1464 /// Regression: a `maximize` of a PSD-Hessian objective is a *concave*
1465 /// maximization ⇒ nonconvex minimization. The convexity test must run
1466 /// on the sense-adjusted Hessian, or this slips through to the convex
1467 /// IPM and returns a wrong (maximum/saddle) answer.
1468 #[test]
1469 fn classify_maximize_psd_objective_is_nonconvex() {
1470 // maximize x0^2 + x1^2 (H = diag(2,2), PSD) — concave max.
1471 let obj = Expr::Binary(
1472 BinOp::Add,
1473 Box::new(Expr::Binary(
1474 BinOp::Pow,
1475 Box::new(Expr::Var(0)),
1476 Box::new(Expr::Const(2.0)),
1477 )),
1478 Box::new(Expr::Binary(
1479 BinOp::Pow,
1480 Box::new(Expr::Var(1)),
1481 Box::new(Expr::Const(2.0)),
1482 )),
1483 );
1484 let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1485 prob.minimize = false;
1486 assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1487 }
1488
1489 /// Mirror: `maximize` of a concave (NSD-Hessian) objective is a convex
1490 /// minimization once negated, so it is a legitimate `ConvexQp`.
1491 #[test]
1492 fn classify_maximize_concave_objective_is_convex() {
1493 // maximize −(x0^2 + x1^2) (H = diag(−2,−2)); negated ⇒ PSD.
1494 let neg_sq = |v: usize| {
1495 Expr::Unary(
1496 UnaryOp::Neg,
1497 Box::new(Expr::Binary(
1498 BinOp::Pow,
1499 Box::new(Expr::Var(v)),
1500 Box::new(Expr::Const(2.0)),
1501 )),
1502 )
1503 };
1504 let obj = Expr::Binary(BinOp::Add, Box::new(neg_sq(0)), Box::new(neg_sq(1)));
1505 let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1506 prob.minimize = false;
1507 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
1508 }
1509
1510 #[test]
1511 fn classify_convex_qcqp() {
1512 // convex quadratic objective + a convex quadratic constraint.
1513 let obj = Expr::Binary(
1514 BinOp::Pow,
1515 Box::new(Expr::Var(0)),
1516 Box::new(Expr::Const(2.0)),
1517 );
1518 let con = Expr::Binary(
1519 BinOp::Add,
1520 Box::new(Expr::Binary(
1521 BinOp::Pow,
1522 Box::new(Expr::Var(0)),
1523 Box::new(Expr::Const(2.0)),
1524 )),
1525 Box::new(Expr::Binary(
1526 BinOp::Pow,
1527 Box::new(Expr::Var(1)),
1528 Box::new(Expr::Const(2.0)),
1529 )),
1530 );
1531 let prob = qp_stub(obj, vec![con]);
1532 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1533 }
1534
1535 /// Build a convex QCQP (linear objective + one convex quadratic
1536 /// constraint `x0² ≤ 1`) at an arbitrary declared `n`/`m`, padding the
1537 /// extra constraints with trivially-zero rows. Used to exercise the
1538 /// two routing caps (`SOCP_REFORM_FLOP_BUDGET`, `SOCP_SOLVE_SIZE_CAP`)
1539 /// without allocating `n×n` data.
1540 fn convex_qcqp_at_size(n: usize, m: usize) -> NlProblem {
1541 let mut con_nonlinear = vec![NlBody::Tree(Expr::Const(0.0)); m];
1542 con_nonlinear[0] = NlBody::Tree(Expr::Binary(
1543 BinOp::Pow,
1544 Box::new(Expr::Var(0)),
1545 Box::new(Expr::Const(2.0)),
1546 ));
1547 let g_l = vec![f64::NEG_INFINITY; m];
1548 let mut g_u = vec![f64::INFINITY; m];
1549 g_u[0] = 1.0; // upper-only bound ⇒ convex feasible set
1550 NlProblem {
1551 src: None,
1552 cse_bodies: Vec::new(),
1553 complementarity: Vec::new(),
1554 n,
1555 m,
1556 num_obj: 1,
1557 minimize: true,
1558 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1559 obj_linear: vec![(0, 1.0)],
1560 obj_constant: 0.0,
1561 con_nonlinear,
1562 con_linear: vec![vec![]; m],
1563 x_l: vec![f64::NEG_INFINITY; n],
1564 x_u: vec![f64::INFINITY; n],
1565 g_l,
1566 g_u,
1567 x0: vec![0.0; n],
1568 lambda0: vec![0.0; m],
1569 suffixes: Default::default(),
1570 imported_funcs: Vec::new(),
1571 ampl_options: Vec::new(),
1572 nl_counts: None,
1573 n_discrete: None,
1574 var_names: Vec::new(),
1575 con_names: Vec::new(),
1576 }
1577 }
1578
1579 /// A convex QCQP small enough to keep the conic path (n·m ≤ budget).
1580 #[test]
1581 fn small_convex_qcqp_routes_to_conic() {
1582 let prob = convex_qcqp_at_size(100, 100); // n·m = 1e4 ≪ budget
1583 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1584 }
1585
1586 /// The two guards are independent, and this problem shows why both are
1587 /// needed. Its one quadratic row is `x0² ≤ 1` — a single variable,
1588 /// diagonal, one flop to put in cone form — so the *reformulation* guard
1589 /// passes easily. It is still routed to NLP, by `SOCP_SOLVE_SIZE_CAP`,
1590 /// because at 1e4 × 1e4 the conic solve itself measured slower than the
1591 /// filter-IPM on exactly this shape (`nql180`, `qssp180`).
1592 ///
1593 /// Keeping the two apart matters: the old single `n·m` proxy conflated
1594 /// them, so fixing the extractor's cost premise silently moved a *routing*
1595 /// decision that measurement says should not move.
1596 #[test]
1597 fn large_qcqp_cheap_to_reform_still_falls_back_on_solve_size() {
1598 let prob = convex_qcqp_at_size(10_001, 10_001);
1599 assert!((prob.n as u64) * (prob.m as u64) > SOCP_SOLVE_SIZE_CAP);
1600 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1601 }
1602
1603 /// The same shape just *under* the solve-size cap keeps the conic path —
1604 /// confirming the fallback above is the size cap talking and not the
1605 /// reformulation budget rejecting a one-variable diagonal row.
1606 #[test]
1607 fn large_qcqp_under_the_solve_size_cap_keeps_conic() {
1608 let prob = convex_qcqp_at_size(10_000, 9_000);
1609 assert!((prob.n as u64) * (prob.m as u64) <= SOCP_SOLVE_SIZE_CAP);
1610 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1611 }
1612
1613 /// Four different findings route a QCQP to `Nlp`, and until now the log
1614 /// said only "NLP" for all of them. Each guard must name itself.
1615 #[test]
1616 fn qcqp_downgrades_say_which_guard_fired() {
1617 // Solve-size cap: one diagonal row, trivial to reform.
1618 let (class, reason) = classify_problem_explained(&convex_qcqp_at_size(10_001, 10_001));
1619 assert_eq!(class, ProblemClass::Nlp);
1620 assert!(
1621 matches!(reason, ClassReason::QcqpTooLargeToSolve { size } if size == 10_001 * 10_001),
1622 "expected the solve-size cap, got {reason:?}"
1623 );
1624
1625 // Reformulation budget: one dense row coupling 400 variables, k³ ≫ 2e7.
1626 let (class, reason) = classify_problem_explained(&coupled_convex_qcqp_with_k_vars(400));
1627 assert_eq!(class, ProblemClass::Nlp);
1628 assert!(
1629 matches!(reason, ClassReason::QcqpReformTooCostly { .. }),
1630 "expected the reformulation budget, got {reason:?}"
1631 );
1632
1633 // Inside both guards: the reason carries the numbers that decided it.
1634 let (class, reason) = classify_problem_explained(&convex_qcqp_at_size(100, 100));
1635 assert_eq!(class, ProblemClass::ConvexQcqp);
1636 assert!(
1637 matches!(
1638 reason,
1639 ClassReason::ConvexQcqpWithinBudgets { size, .. } if size == 10_000
1640 ),
1641 "expected a within-budget QCQP, got {reason:?}"
1642 );
1643 }
1644
1645 /// A quadratic row that is convex as a *function* but bounded from
1646 /// below carves a nonconvex set. That is a different finding from a row
1647 /// whose Hessian is indefinite, and the two used to be one `return`.
1648 #[test]
1649 fn nonconvex_sense_and_indefinite_hessian_are_distinct_reasons() {
1650 // x0² ≥ 1 — PSD Hessian, nonconvex feasible set.
1651 let mut prob = convex_qcqp_at_size(10, 10);
1652 prob.g_l[0] = 1.0;
1653 prob.g_u[0] = f64::INFINITY;
1654 let (class, reason) = classify_problem_explained(&prob);
1655 assert_eq!(class, ProblemClass::Nlp);
1656 assert_eq!(reason, ClassReason::ConstraintSenseNonconvex { row: 0 });
1657
1658 // −x0² ≤ 1 — upper-bounded, but the Hessian is negative definite.
1659 let mut prob = convex_qcqp_at_size(10, 10);
1660 prob.con_nonlinear[0] = NlBody::Tree(Expr::Unary(
1661 UnaryOp::Neg,
1662 Box::new(Expr::Binary(
1663 BinOp::Pow,
1664 Box::new(Expr::Var(0)),
1665 Box::new(Expr::Const(2.0)),
1666 )),
1667 ));
1668 let (class, reason) = classify_problem_explained(&prob);
1669 assert_eq!(class, ProblemClass::Nlp);
1670 assert_eq!(reason, ClassReason::ConstraintHessianIndefinite { row: 0 });
1671 }
1672
1673 /// The LP fast path reports *why* it is an LP, and the ways of getting
1674 /// there are told apart: nothing nonlinear at all, versus a nonlinear
1675 /// part that expanded to something of degree ≤ 1 — versus one that only
1676 /// *looks* that way because a coefficient was dropped.
1677 #[test]
1678 fn lp_reasons_distinguish_absent_from_cancelled() {
1679 let prob = qp_stub(Expr::Const(0.0), vec![Expr::Const(0.0)]);
1680 assert_eq!(
1681 classify_problem_explained(&prob),
1682 (ProblemClass::Lp, ClassReason::NoNonlinearParts)
1683 );
1684
1685 // 2·x0 in the objective's nonlinear part: present, nonlinear to the
1686 // header, degree 1 once expanded, and nothing dropped getting there.
1687 let expands = Expr::Binary(
1688 BinOp::Mul,
1689 Box::new(Expr::Const(2.0)),
1690 Box::new(Expr::Var(0)),
1691 );
1692 let prob = qp_stub(expands, vec![Expr::Const(0.0)]);
1693 assert_eq!(
1694 classify_problem_explained(&prob),
1695 (ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
1696 );
1697
1698 // x0 − x0 reaches degree 0 the other way: by the two coefficients
1699 // summing to zero and the term being dropped. That sum is *exact*
1700 // — `fl(1) + fl(−1)` loses nothing — so the empty form really is
1701 // the objective, and this stays LP. (gh #685 sent it to NLP; gh
1702 // #687 sharpened the gate to the inexact fold and handed the reach
1703 // back.)
1704 let cancels = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(Expr::Var(0)));
1705 let prob = qp_stub(cancels, vec![Expr::Const(0.0)]);
1706 assert_eq!(
1707 classify_problem_explained(&prob),
1708 (ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
1709 );
1710
1711 // 2⁵³·x0 + x0 − 2⁵³·x0 empties the same map, but the `x0` was lost
1712 // at `fl(2⁵³ + 1) = 2⁵³` — an inexact add — so the read-out is a
1713 // whole `x0` short of the objective and the LP built from it would
1714 // be a different problem. This is the case that must not route LP
1715 // (gh #685).
1716 let big = 9007199254740992.0_f64; // 2⁵³
1717 let loses = Expr::Binary(
1718 BinOp::Sub,
1719 Box::new(Expr::Binary(
1720 BinOp::Add,
1721 Box::new(Expr::Binary(
1722 BinOp::Mul,
1723 Box::new(Expr::Const(big)),
1724 Box::new(Expr::Var(0)),
1725 )),
1726 Box::new(Expr::Var(0)),
1727 )),
1728 Box::new(Expr::Binary(
1729 BinOp::Mul,
1730 Box::new(Expr::Const(big)),
1731 Box::new(Expr::Var(0)),
1732 )),
1733 );
1734 let prob = qp_stub(loses, vec![Expr::Const(0.0)]);
1735 assert_eq!(
1736 classify_problem_explained(&prob),
1737 (ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped)
1738 );
1739 }
1740
1741 /// Build a convex QCQP whose single quadratic constraint `(Σ xᵢ)² ≤ 1`
1742 /// couples all `k` variables (a dense rank-1 PSD Hessian over `k` vars),
1743 /// with `n = k`, `m = 1`. Exercises the per-row conic-reformulation guard
1744 /// independently of the `n·m` budget.
1745 fn coupled_convex_qcqp_with_k_vars(k: usize) -> NlProblem {
1746 // sum = x0 + x1 + … + x_{k-1}
1747 let mut sum = Expr::Var(0);
1748 for i in 1..k {
1749 sum = Expr::Binary(BinOp::Add, Box::new(sum), Box::new(Expr::Var(i)));
1750 }
1751 // constraint (Σ xᵢ)² ≤ 1 — convex feasible set, Hessian = 2·(all-ones),
1752 // PSD (rank 1) and fully coupled across all k variables.
1753 let con = Expr::Binary(BinOp::Pow, Box::new(sum), Box::new(Expr::Const(2.0)));
1754 NlProblem {
1755 src: None,
1756 cse_bodies: Vec::new(),
1757 complementarity: Vec::new(),
1758 n: k,
1759 m: 1,
1760 num_obj: 1,
1761 minimize: true,
1762 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1763 obj_linear: vec![(0, 1.0)],
1764 obj_constant: 0.0,
1765 con_nonlinear: vec![NlBody::Tree(con)],
1766 con_linear: vec![vec![]],
1767 x_l: vec![f64::NEG_INFINITY; k],
1768 x_u: vec![f64::INFINITY; k],
1769 g_l: vec![f64::NEG_INFINITY],
1770 g_u: vec![1.0],
1771 x0: vec![0.0; k],
1772 lambda0: vec![0.0],
1773 suffixes: Default::default(),
1774 imported_funcs: Vec::new(),
1775 ampl_options: Vec::new(),
1776 nl_counts: None,
1777 n_discrete: None,
1778 var_names: Vec::new(),
1779 con_names: Vec::new(),
1780 }
1781 }
1782
1783 /// Build a convex QCQP whose single quadratic constraint `Σ xᵢ² ≤ 1` is
1784 /// **separable** — a diagonal Hessian over `k` variables, no coupling.
1785 /// This is the `qssp180`/`nql180` shape: very wide, trivially factorable.
1786 fn separable_convex_qcqp_with_k_vars(k: usize) -> NlProblem {
1787 let sq = |i: usize| {
1788 Expr::Binary(
1789 BinOp::Pow,
1790 Box::new(Expr::Var(i)),
1791 Box::new(Expr::Const(2.0)),
1792 )
1793 };
1794 let mut con = sq(0);
1795 for i in 1..k {
1796 con = Expr::Binary(BinOp::Add, Box::new(con), Box::new(sq(i)));
1797 }
1798 NlProblem {
1799 src: None,
1800 cse_bodies: Vec::new(),
1801 complementarity: Vec::new(),
1802 n: k,
1803 m: 1,
1804 num_obj: 1,
1805 minimize: true,
1806 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1807 obj_linear: vec![(0, 1.0)],
1808 obj_constant: 0.0,
1809 con_nonlinear: vec![NlBody::Tree(con)],
1810 con_linear: vec![vec![]],
1811 x_l: vec![f64::NEG_INFINITY; k],
1812 x_u: vec![f64::INFINITY; k],
1813 g_l: vec![f64::NEG_INFINITY],
1814 g_u: vec![1.0],
1815 x0: vec![0.0; k],
1816 lambda0: vec![0.0],
1817 suffixes: Default::default(),
1818 imported_funcs: Vec::new(),
1819 ampl_options: Vec::new(),
1820 nl_counts: None,
1821 n_discrete: None,
1822 var_names: Vec::new(),
1823 con_names: Vec::new(),
1824 }
1825 }
1826
1827 /// The converse: a *small* problem can still be too expensive to
1828 /// reformulate. This one is `n·m = 300`, but its single row couples all
1829 /// 300 variables densely, so the pivoted Cholesky costs `300³ = 2.7e7`
1830 /// flops — over budget. Route to NLP. This is the mittelmann `qcqp1000-*`
1831 /// shape (few constraints, ~1000-var coupled rows), and Q0 measured those
1832 /// solving well on the NLP path.
1833 #[test]
1834 fn heavily_coupled_convex_qcqp_falls_back_to_nlp() {
1835 let k = 300;
1836 let prob = coupled_convex_qcqp_with_k_vars(k);
1837 assert!((k as u128).pow(3) > SOCP_REFORM_FLOP_BUDGET);
1838 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1839 }
1840
1841 /// The companion to the guard: a convex QCQP whose dense row is narrow
1842 /// enough to factor inside the budget keeps the conic path.
1843 /// `250³ = 1.56e7 ≤ 2e7`.
1844 #[test]
1845 fn lightly_coupled_convex_qcqp_keeps_conic() {
1846 let k = 250;
1847 let prob = coupled_convex_qcqp_with_k_vars(k);
1848 assert!((k as u128).pow(3) <= SOCP_REFORM_FLOP_BUDGET);
1849 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1850 }
1851
1852 /// A diagonal row is factored in `O(k)`, so width alone must not push a
1853 /// separable QCQP off the conic path — 100 000 uncoupled variables cost
1854 /// 100 000 flops, where the same width densely coupled would cost 1e15.
1855 ///
1856 /// `k` was held at 1 000 by an unrelated defect rather than by the cost
1857 /// model: the constraint is a left-deep `Add` tree, the recognizer walked
1858 /// it recursively, and a sum of a few thousand squares overflowed the
1859 /// stack during classification (Q1 found this; a left-deep `o0` chain is
1860 /// what a `.nl` writer emits for a long sum). Q3 made the recognizer
1861 /// iterative, so the cost model can now be tested at a width that means
1862 /// something. See `pounce_nl::nl_quadratic` for the depth tests
1863 /// themselves.
1864 ///
1865 /// The problem is leaked rather than dropped: `Expr`'s derived `Drop` is
1866 /// still recursive and would overflow tearing a tree this deep down. That
1867 /// is a real remaining defect on the same shape — the Python bindings
1868 /// work around it with a big-stack worker thread (pounce#472) — and it is
1869 /// not this test's subject.
1870 #[test]
1871 fn wide_diagonal_convex_qcqp_keeps_conic() {
1872 let k = 100_000;
1873 let prob = separable_convex_qcqp_with_k_vars(k);
1874 assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1875 std::mem::forget(prob);
1876 }
1877
1878 /// Classification mirror of the boundary guard: a QP whose only
1879 /// curvature is a genuine (beyond-tolerance) negative direction is
1880 /// `NonconvexQp`, so `auto` routes it to NLP rather than the convex IPM.
1881 /// `minimize −x0²` is concave for a minimizer ⇒ indefinite.
1882 #[test]
1883 fn classify_concave_minimize_is_nonconvex() {
1884 let obj = Expr::Unary(
1885 UnaryOp::Neg,
1886 Box::new(Expr::Binary(
1887 BinOp::Pow,
1888 Box::new(Expr::Var(0)),
1889 Box::new(Expr::Const(2.0)),
1890 )),
1891 );
1892 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1893 assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1894 }
1895
1896 /// Conservative QCQP guard: a convex quadratic objective with an
1897 /// *indefinite* quadratic constraint must fall back to NLP — never be
1898 /// called `ConvexQcqp` and handed to the conic path, which would treat a
1899 /// nonconvex feasible region as convex.
1900 #[test]
1901 fn classify_qcqp_with_indefinite_constraint_falls_back_to_nlp() {
1902 // obj x0² (convex); constraint x0·x1 (indefinite Hessian).
1903 let obj = Expr::Binary(
1904 BinOp::Pow,
1905 Box::new(Expr::Var(0)),
1906 Box::new(Expr::Const(2.0)),
1907 );
1908 let con = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1909 let prob = qp_stub(obj, vec![con]);
1910 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1911 }
1912
1913 /// Sense guard: a PSD-Hessian quadratic constraint is convex only as an
1914 /// **upper** bound. With a finite *lower* bound (`g(x) ≥ g_l`) the
1915 /// feasible set is the nonconvex super-level set, so it must fall back to
1916 /// NLP — never be routed to the conic solver as if convex.
1917 #[test]
1918 fn classify_psd_quadratic_with_lower_bound_is_nonconvex() {
1919 let obj = Expr::Binary(
1920 BinOp::Pow,
1921 Box::new(Expr::Var(0)),
1922 Box::new(Expr::Const(2.0)),
1923 );
1924 let con = Expr::Binary(
1925 BinOp::Add,
1926 Box::new(Expr::Binary(
1927 BinOp::Pow,
1928 Box::new(Expr::Var(0)),
1929 Box::new(Expr::Const(2.0)),
1930 )),
1931 Box::new(Expr::Binary(
1932 BinOp::Pow,
1933 Box::new(Expr::Var(1)),
1934 Box::new(Expr::Const(2.0)),
1935 )),
1936 );
1937 let mut prob = qp_stub(obj, vec![con]);
1938 // g(x) ≥ 1 (finite lower, infinite upper) — convex function, but the
1939 // ≥ side is a nonconvex region.
1940 prob.g_l = vec![1.0];
1941 prob.g_u = vec![f64::INFINITY];
1942 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1943 }
1944
1945 /// Sense guard: a quadratic *equality* (`g(x) = c`) is nonconvex even
1946 /// with a PSD Hessian, so it must fall back to NLP, not ConvexQcqp.
1947 #[test]
1948 fn classify_quadratic_equality_is_nonconvex() {
1949 let obj = Expr::Const(0.0);
1950 let con = Expr::Binary(
1951 BinOp::Pow,
1952 Box::new(Expr::Var(0)),
1953 Box::new(Expr::Const(2.0)),
1954 );
1955 let mut prob = qp_stub(obj, vec![con]);
1956 prob.g_l = vec![1.0];
1957 prob.g_u = vec![1.0]; // x0² = 1 — nonconvex.
1958 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1959 }
1960
1961 /// A nonlinear objective whose quadratic part cancels has an empty
1962 /// Hessian — and whether that empty map is evidence depends on how it
1963 /// got empty. `x0² − x0²` cancels exactly, so the map is the whole
1964 /// objective and the model is an LP. `2⁵³·x0² + x0² − 2⁵³·x0²` empties
1965 /// the same map having lost an entire `x0²` at `fl(2⁵³ + 1)`, so the
1966 /// read-out is not the objective and the model must go to NLP (gh
1967 /// #685, gated on the inexact fold per gh #687).
1968 ///
1969 /// The spurious-QP concern this test was written for is unaffected in
1970 /// either case: an empty Hessian never reaches the QP IPM.
1971 #[test]
1972 fn classify_cancelling_quadratic_objective_routes_on_exactness() {
1973 // x0² − x0² ≡ 0: the degree-2 terms cancel in the polynomial walk,
1974 // and the sum that cancels them is exact.
1975 let sq = |c: f64| {
1976 let p = Expr::Binary(
1977 BinOp::Pow,
1978 Box::new(Expr::Var(0)),
1979 Box::new(Expr::Const(2.0)),
1980 );
1981 if c == 1.0 {
1982 p
1983 } else {
1984 Expr::Binary(BinOp::Mul, Box::new(Expr::Const(c)), Box::new(p))
1985 }
1986 };
1987 let obj = Expr::Binary(BinOp::Sub, Box::new(sq(1.0)), Box::new(sq(1.0)));
1988 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1989 assert_eq!(
1990 classify_problem_explained(&prob),
1991 (ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
1992 );
1993
1994 // 2⁵³·x0² + x0² − 2⁵³·x0² ≡ x0²: same empty map, one lost term.
1995 let big = 9007199254740992.0_f64; // 2⁵³
1996 let obj = Expr::Binary(
1997 BinOp::Sub,
1998 Box::new(Expr::Binary(
1999 BinOp::Add,
2000 Box::new(sq(big)),
2001 Box::new(sq(1.0)),
2002 )),
2003 Box::new(sq(big)),
2004 );
2005 let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
2006 assert_eq!(
2007 classify_problem_explained(&prob),
2008 (ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped)
2009 );
2010 }
2011
2012 #[test]
2013 fn classify_nlp_from_transcendental_constraint() {
2014 let obj = Expr::Binary(
2015 BinOp::Pow,
2016 Box::new(Expr::Var(0)),
2017 Box::new(Expr::Const(2.0)),
2018 );
2019 let con = Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(1)));
2020 let prob = qp_stub(obj, vec![con]);
2021 assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
2022 }
2023
2024 /// Build a 2-var, 1-con problem stub with the given nonlinear
2025 /// objective and per-constraint nonlinear parts. Linear parts and
2026 /// bounds are filled with benign defaults.
2027 fn qp_stub(obj_nonlinear: Expr, con_nonlinear: Vec<Expr>) -> NlProblem {
2028 let obj_nonlinear = NlBody::Tree(obj_nonlinear);
2029 let con_nonlinear: Vec<NlBody> = con_nonlinear.into_iter().map(NlBody::Tree).collect();
2030 let m = con_nonlinear.len();
2031 NlProblem {
2032 src: None,
2033 cse_bodies: Vec::new(),
2034 complementarity: Vec::new(),
2035 n: 2,
2036 m,
2037 num_obj: 1,
2038 minimize: true,
2039 obj_nonlinear,
2040 obj_linear: vec![],
2041 obj_constant: 0.0,
2042 con_nonlinear,
2043 con_linear: vec![vec![]; m],
2044 x_l: vec![f64::NEG_INFINITY; 2],
2045 x_u: vec![f64::INFINITY; 2],
2046 g_l: vec![f64::NEG_INFINITY; m],
2047 g_u: vec![0.0; m],
2048 x0: vec![0.0; 2],
2049 lambda0: vec![0.0; m],
2050 suffixes: Default::default(),
2051 imported_funcs: Vec::new(),
2052 ampl_options: Vec::new(),
2053 nl_counts: None,
2054 n_discrete: None,
2055 var_names: Vec::new(),
2056 con_names: Vec::new(),
2057 }
2058 }
2059
2060 // Keep parse_nl_text reachable for a future header-fast-path test
2061 // against a committed .nl fixture.
2062 #[allow(dead_code)]
2063 fn _parse(txt: &str) -> NlProblem {
2064 parse_nl_text(txt).expect("valid .nl")
2065 }
2066
2067 /// **gh #492.** `min −x0 − 2·x1 s.t. x0 + x1 + 3 <= 6, x ∈ [0,3]²`,
2068 /// with the `3` written into the row's expression segment. `body` is
2069 /// the `C0` token stream for that constant.
2070 fn lp_with_row_constant(body: &str) -> NlProblem {
2071 let nl = format!(
2072 "g3 1 1 0
2073 2 1 1 0 0
2074 1 0 0 0 0 0
2075 0 0
2076 1 0 0
2077 0 0 0 1
2078 0 0 0 0 0
2079 2 2
2080 0 0
2081 0 0 0 0 0
2082C0
2083{body}
2084O0 0
2085n0
2086r
20871 6.0
2088b
20890 0 3
20900 0 3
2091k1
20921
2093J0 2
20940 1
20951 1
2096G0 2
20970 -1
20981 -2
2099"
2100 );
2101 parse_nl_text(&nl).expect("valid .nl")
2102 }
2103
2104 /// The classifier's fast path asks `is_trivially_zero` of every
2105 /// `con_nonlinear` entry, which is an *identity* test — it cannot tell
2106 /// "this row has a nonlinear part" from "this row's part is the
2107 /// constant 3". A bare literal survived that anyway, because the
2108 /// fallback polynomial walk lowers `Const` and finds no quadratic
2109 /// term; what it does not do is keep the constant, so the row's `+3`
2110 /// lived on only as `qp_extract`'s `const_shift`. After the parse-time
2111 /// fold the bound carries it and the fast path is exact.
2112 #[test]
2113 fn a_literal_row_constant_classifies_lp_and_moves_the_bound() {
2114 let prob = lp_with_row_constant("n3");
2115 assert_eq!(classify_problem(&prob), ProblemClass::Lp);
2116 // `x0 + x1 + 3 <= 6` is `x0 + x1 <= 3`.
2117 assert!((prob.g_u[0] - 3.0).abs() < 1e-12, "g_u = {}", prob.g_u[0]);
2118 assert!(
2119 prob.con_nonlinear[0].is_trivially_zero(),
2120 "the row body should be the identity zero: {:?}",
2121 prob.con_nonlinear[0]
2122 );
2123 }
2124
2125 /// The case the polynomial walk cannot rescue: a constant it has to
2126 /// *compute*. `sqrt(9)` is not a degree-≤2 polynomial in any variable,
2127 /// so `analyze_quadratic` returns `None` and the row made the whole
2128 /// model NLP — an LP that never reached the convex route. The fold
2129 /// settles it at parse, where the value is known.
2130 #[test]
2131 fn a_computed_row_constant_does_not_make_an_lp_classify_nlp() {
2132 let prob = lp_with_row_constant("o39\nn9");
2133 assert_eq!(classify_problem(&prob), ProblemClass::Lp);
2134 assert!((prob.g_u[0] - 3.0).abs() < 1e-12, "g_u = {}", prob.g_u[0]);
2135 }
2136}