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