Skip to main content

pounce_cli/
qp_extract.rs

1//! Extract a `pounce_convex::QpProblem` (standard form) from a parsed
2//! `.nl` problem, for the LP/QP dispatch path (Phase 2).
3//!
4//! The classifier (`crate::dispatch`) has already decided the problem is
5//! an LP or convex QP; this module marshals the parsed `NlProblem` into
6//! the standard form the convex IPM consumes:
7//!
8//! ```text
9//! minimize    ½ xᵀP x + cᵀx
10//! subject to  A x = b          (equalities)
11//!             G x ≤ h          (inequalities)
12//!             lb ≤ x ≤ ub      (the variable box)
13//! ```
14//!
15//! Mapping from the `.nl` representation:
16//! - **Objective.** `P` is the Hessian of the (degree-≤2) objective —
17//!   recovered with the same `analyze_quadratic` the classifier uses, so
18//!   `P` here is exactly the matrix whose definiteness was tested. `c`
19//!   is the objective's linear part. A `maximize` objective is negated
20//!   into a minimization.
21//! - **Constraints.** Each row has a linear part and bounds `g_l ≤ row ≤
22//!   g_u`. An equality (`g_l == g_u`) becomes a row of `A`; a one- or
23//!   two-sided inequality becomes one or two rows of `G` (`row ≤ g_u`
24//!   and/or `−row ≤ −g_l`).
25//! - **Variable bounds.** Present `x_l`/`x_u` become the solver's explicit
26//!   box (see [`extract_box`] for why they are no longer emitted as `G`
27//!   rows). The `.nl` "infinity" sentinel is read directionally: `x_l ≤
28//!   -1e19` is no lower bound, `x_u ≥ 1e19` is no upper bound. A bound past
29//!   the *opposite* sentinel (an upper bound of `-5e20`) is an ordinary
30//!   bound and is kept.
31
32use crate::nl_reader::NlProblem;
33// Bound presence is read **directionally** — a lower bound is absent only at
34// or below `-1e19`, an upper bound only at or above `+1e19`. This file used a
35// symmetric `|v| < 1e19` test (gh #401): a real upper bound of `-5e20` failed
36// it and was dropped from `G` entirely, so the QP was solved over a strictly
37// larger box and reported `Optimal` at a point the model excludes.
38use pounce_common::types::{lower_bound_present, upper_bound_present};
39use pounce_convex::{ConeSpec, QpProblem, QpResiduals, QpSolution, Triplet};
40
41/// Ipopt's `bound_relax_factor` widening, as the convex extractors apply it.
42///
43/// The NLP path widens `x_L/x_U` and the inequality-row bounds `d_L/d_U`
44/// before the algorithm ever sees them (`OrigIpoptNlp::relax_bounds`, driven
45/// from `Application` with `bound_relax_factor` — Ipopt default `1e-8` —
46/// capped by `constr_viol_tol`, default `1e-4`). The convex path did not,
47/// so the *same binary* solved a materially different model depending on
48/// `solver_selection`.
49///
50/// That is not a hairline difference on a constraint-degenerate model. On
51/// `LISWET1` (gh #744) every one of the 10 000 monotonicity rows is active at
52/// the optimum and the multipliers sum to `1.6e9`, so a `1e-8` widening of the
53/// rows buys `9.0` of objective — the convex arm returned the exact optimum
54/// `36.1224` and the NLP arm (and Ipopt-MA57) the relaxed one, `27.1221`, and
55/// the 33% gap was read as a convex-solver bug. Both arms now relax, so both
56/// report `27.1221`, and `bound_relax_factor=0` gets `36.1224` from either.
57///
58/// Faithful to `relax_bounds` in three details that matter:
59/// * **Equality rows are not relaxed.** Upstream they live in `c(x) = 0`,
60///   which `relax_bounds` never touches; only `d_L/d_U` (inequality rows) and
61///   the variable box are widened.
62/// * **Rows use the scale-relative width** `min(factor, cap)·|b|` (with `|b|`
63///   read as `1` at a declared-zero bound), the gh #385 form. The variable box
64///   keeps the upstream absolute formula `min(factor·max(|b|,1), cap)`.
65/// * **Fixed variables (`x_l == x_u`) keep their bounds.** Under the default
66///   `fixed_variable_treatment=make_parameter` upstream removes them before
67///   `relax_bounds` runs, so they are never widened.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct BoundRelax {
70    /// `bound_relax_factor`. Non-positive disables the widening entirely.
71    pub factor: f64,
72    /// `constr_viol_tol` — the cap on the widening.
73    pub cap: f64,
74}
75
76impl BoundRelax {
77    /// No widening — the model exactly as declared. What the convex path did
78    /// unconditionally before gh #744, and what `bound_relax_factor=0` selects.
79    pub const NONE: Self = Self {
80        factor: 0.0,
81        cap: 0.0,
82    };
83
84    /// Whether any widening is actually applied. Public so a caller can tell
85    /// "the declared model and the solved model coincide" from "they differ",
86    /// which struct equality against [`Self::NONE`] cannot: `bound_relax_factor=0`
87    /// zeroes the factor but leaves `cap` at `constr_viol_tol`, so the pair is
88    /// inactive without being `NONE`.
89    pub fn active(self) -> bool {
90        self.factor > 0.0 && self.cap > 0.0
91    }
92
93    /// Widening of a variable bound `b`: `min(factor·max(|b|,1), cap)`.
94    fn var_delta(self, b: f64) -> f64 {
95        if !self.active() {
96            return 0.0;
97        }
98        (self.factor.abs() * b.abs().max(1.0)).min(self.cap)
99    }
100
101    /// Widening of an inequality-row bound `b`: `min(factor, cap)·|b|`, with a
102    /// declared-zero bound taking the absolute width (it has no scale).
103    /// The widening to apply to a row whose declared sides are `lo`/`hi`.
104    ///
105    /// A crossed pair (`lo > hi`) declares an *empty* feasible set — an
106    /// inconsistent model, which the NLP path rejects as
107    /// `Invalid_Problem_Definition` before `relax_bounds` is ever reached.
108    /// Widening both sides of one closes the gap whenever the crossing is
109    /// narrower than the relaxation, turning "this model has no feasible
110    /// point" into an optimal answer. A crossed row is therefore passed
111    /// through exactly as declared, so the emptiness screen still sees it
112    /// (gh #491).
113    fn for_row(self, lo: f64, hi: f64) -> Self {
114        if lower_bound_present(lo) && upper_bound_present(hi) && lo > hi {
115            Self::NONE
116        } else {
117            self
118        }
119    }
120
121    fn row_delta(self, b: f64) -> f64 {
122        if !self.active() {
123            return 0.0;
124        }
125        let scale = if b == 0.0 { 1.0 } else { b.abs() };
126        self.factor.abs().min(self.cap) * scale
127    }
128}
129
130/// Convert a classified LP/convex-QP `NlProblem` into `QpProblem`
131/// standard form. Returns `None` if the objective is not actually a
132/// degree-≤2 polynomial (should not happen for a problem the classifier
133/// routed here, but the conversion is total and falls back gracefully).
134pub fn extract_qp(prob: &NlProblem, relax: BoundRelax) -> Option<QpProblem> {
135    Some(extract_qp_with_map(prob, relax)?.0) // drops con_map + reporting constant
136}
137
138/// Where each `.nl` constraint's rows landed in the standard-form QP, so
139/// the QP's multipliers can be mapped back to a per-`.nl`-constraint
140/// dual for the `.sol`. One entry per original constraint, in order.
141#[derive(Debug, Clone)]
142pub enum ConRowMap {
143    /// Equality constraint → row `a_row` of `A` (multiplier `y[a_row]`).
144    Eq { a_row: usize },
145    /// Inequality / range constraint → up to two rows of `G`: the
146    /// `row ≤ g_u` upper bound and/or the `−row ≤ −g_l` lower bound
147    /// (multipliers `z[..]`, each ≥ 0).
148    Ineq {
149        upper: Option<usize>,
150        lower: Option<usize>,
151    },
152}
153
154/// The residuals of `sol` measured against the model **as declared**, before
155/// the [`BoundRelax`] widening.
156///
157/// [`QpSolution::kkt_residuals`] measures the problem the solver was handed,
158/// whose inequality rows and variable box are widened by
159/// `bound_relax_factor`. That is correct for the convergence test — the
160/// solver must converge on the model it is solving, and pounce-convex's own
161/// acceptance tests call it on exactly that model — and wrong for the number
162/// a caller reads as "how well does my model hold".
163///
164/// The gap is the widening itself, `min(factor, cap)·|b|` per row. On
165/// `afiro` the returned point sits `4.99e-06` outside the declared row
166/// `b = 500` — precisely `1e-8 · 500` — while the widened measurement reads
167/// `8.68e-13`, seven orders tighter, because the point does satisfy the
168/// widened row. `25fv47` reports `2.19e-11` against a declared `1.97e-05`.
169/// Neither is a solver defect: both are the widening working as designed and
170/// then being reported against the wrong model.
171///
172/// Returns `None` when no widening was applied (the two measurements
173/// coincide by construction, so the caller keeps the one it already has) or
174/// when re-extraction fails.
175pub fn declared_residuals_qp(
176    prob: &NlProblem,
177    sol: &QpSolution,
178    relax: BoundRelax,
179) -> Option<QpResiduals> {
180    if !relax.active() {
181        return None;
182    }
183    let (declared, _, _) = extract_qp_with_map(prob, BoundRelax::NONE)?;
184    // Structurally the re-extraction differs from the solved model only in
185    // the bound VALUES, so the shapes must agree; a mismatch would silently
186    // measure one model's point against another's rows.
187    debug_assert_eq!(declared.n, sol.x.len(), "declared re-extraction changed n");
188    // Only `primal_infeasibility` is consumed. The dual and complementarity
189    // terms this also computes are measured against a model whose multipliers
190    // are not the ones that produced them, so they are meaningless here and
191    // deliberately unused rather than reported.
192    Some(sol.kkt_residuals(&declared))
193}
194
195/// [`declared_residuals_qp`] for the conic arm: measures each block with its
196/// own cone, as [`QpSolution::kkt_residuals_conic`] does, so a converged SOC
197/// block is not read as infeasible (pounce#209). The cones come from the
198/// re-extraction rather than the caller, so the blocks and the bounds
199/// describe one model.
200pub fn declared_residuals_socp(
201    prob: &NlProblem,
202    sol: &QpSolution,
203    relax: BoundRelax,
204) -> Option<QpResiduals> {
205    if !relax.active() {
206        return None;
207    }
208    let (declared, _, _, cones) = extract_socp_with_map(prob, BoundRelax::NONE)?;
209    debug_assert_eq!(declared.n, sol.x.len(), "declared re-extraction changed n");
210    // As above: only `primal_infeasibility` is consumed.
211    Some(sol.kkt_residuals_conic(&declared, &cones))
212}
213
214/// Extract the QP, the constraint→row provenance map, and the objective
215/// constant folded into the nonlinear tree (see below), together.
216///
217/// The third return value is the **degree-0 term of the nonlinear
218/// objective** (e.g. the `+9` of `(x₀−3)²` that AMPL/Pyomo emit inside the
219/// nonlinear tree rather than in `NlProblem::obj_constant`). The QP itself
220/// ignores it — it does not move the minimizer — but the caller must add
221/// it to the *reported* objective so the convex solve agrees with the NLP
222/// path. It is returned in the problem's natural (user) sense, *not*
223/// multiplied by the maximize/minimize `sign`.
224pub fn extract_qp_with_map(
225    prob: &NlProblem,
226    relax: BoundRelax,
227) -> Option<(QpProblem, Vec<ConRowMap>, f64)> {
228    let n = prob.n;
229    let sign = if prob.minimize { 1.0 } else { -1.0 };
230
231    // --- objective Hessian P (lower triangle) + nonlinear-tree linear part
232    //     + nonlinear-tree constant (degree-0 term, for reporting only) ---
233    let (hess, obj_nl_linear, obj_nl_constant) = prob.obj_nonlinear.analyze_quadratic_full()?;
234    let mut p_lower: Vec<Triplet> = Vec::with_capacity(hess.len());
235    for ((i, j), v) in &hess {
236        // analyze_quadratic returns (i ≤ j) upper-ish keys; store as
237        // lower triangle (row ≥ col) for the solver.
238        let (row, col) = if i >= j { (*i, *j) } else { (*j, *i) };
239        p_lower.push(Triplet::new(row, col, sign * v));
240    }
241
242    // --- objective linear term c ---
243    // Two disjoint sources, exactly as the NLP path's eval_f sums them:
244    // the `.nl` linear section (`obj_linear`) and the degree-1 terms AMPL
245    // kept inside the nonlinear objective tree (e.g. the `−6·x₀` of
246    // `(x₀−3)²`). Dropping the latter silently solves the wrong objective.
247    let mut c = vec![0.0; n];
248    for (var, coef) in &prob.obj_linear {
249        c[*var] += sign * coef;
250    }
251    for (var, coef) in &obj_nl_linear {
252        c[*var] += sign * coef;
253    }
254
255    // --- constraints: equalities → A x = b, inequalities → G x ≤ h ---
256    let mut a: Vec<Triplet> = Vec::new();
257    let mut b: Vec<f64> = Vec::new();
258    let mut g: Vec<Triplet> = Vec::new();
259    let mut h: Vec<f64> = Vec::new();
260    let mut con_map: Vec<ConRowMap> = Vec::with_capacity(prob.con_linear.len());
261
262    for (row, lin) in prob.con_linear.iter().enumerate() {
263        let lo = prob.g_l[row];
264        let hi = prob.g_u[row];
265
266        // Combine the `.nl` linear section with any degree-≤1 terms AMPL
267        // folded into the (here empty-Hessian) nonlinear tree — the
268        // classifier admits constraint rows whose nonlinear expression
269        // reduces to degree ≤ 1 (`dispatch.rs`), e.g. defined variables
270        // or a quadratic the writer wrote out and cancelled exactly, and
271        // those linear/constant terms live in `con_nonlinear`, not
272        // `con_linear`. Dropping them silently solves the wrong
273        // constraint. (A row whose coefficients cancelled *in the
274        // recognizer's own arithmetic* does not reach here at all: it
275        // never classifies LP/QP — gh #685.) The folded constant
276        // shifts the bounds: `g_l ≤ row + k ≤ g_u  ⇔  g_l−k ≤ row ≤ g_u−k`.
277        // This mirrors the SOCP extractor's linear-constraint handling.
278        let (nl_lin, const_shift) = prob.con_nonlinear[row]
279            .analyze_quadratic_full()
280            .map(|(_, l, k)| (l, k))
281            .unwrap_or_default();
282        let mut coef = vec![0.0; n];
283        for (var, v) in lin {
284            coef[*var] += *v;
285        }
286        for (var, v) in &nl_lin {
287            coef[*var] += *v;
288        }
289        let nonzeros = || coef.iter().enumerate().filter(|(_, v)| **v != 0.0);
290
291        if lo == hi && lower_bound_present(lo) && upper_bound_present(hi) {
292            // Equality row.
293            let eq_row = next_row(&b);
294            for (var, v) in nonzeros() {
295                a.push(Triplet::new(eq_row, var, *v));
296            }
297            b.push(lo - const_shift);
298            con_map.push(ConRowMap::Eq { a_row: eq_row });
299        } else {
300            // Inequality row. Both sides carry the `bound_relax_factor`
301            // widening the NLP path applies to `d_L/d_U` (see [`BoundRelax`]);
302            // it is zero when the caller passed `BoundRelax::NONE`, and on a
303            // crossed row, which must stay crossed.
304            let relax = relax.for_row(lo, hi);
305            // Upper bound: row ≤ hi.
306            let upper = if upper_bound_present(hi) {
307                let gr = next_row(&h);
308                for (var, v) in nonzeros() {
309                    g.push(Triplet::new(gr, var, *v));
310                }
311                h.push(hi + relax.row_delta(hi) - const_shift);
312                Some(gr)
313            } else {
314                None
315            };
316            // Lower bound: row ≥ lo  ⇔  −row ≤ −lo.
317            let lower = if lower_bound_present(lo) {
318                let gr = next_row(&h);
319                for (var, v) in nonzeros() {
320                    g.push(Triplet::new(gr, var, -*v));
321                }
322                h.push(-(lo - relax.row_delta(lo) - const_shift));
323                Some(gr)
324            } else {
325                None
326            };
327            con_map.push(ConRowMap::Ineq { upper, lower });
328        }
329    }
330
331    // --- variable bounds as the explicit box (not as `G` rows) ---
332    let (lb, ub) = extract_box(prob, relax);
333
334    Some((
335        QpProblem {
336            n,
337            p_lower,
338            c,
339            a,
340            b,
341            g,
342            h,
343            lb,
344            ub,
345        },
346        con_map,
347        obj_nl_constant,
348    ))
349}
350
351/// The `.nl` variable bounds as `pounce-convex`'s explicit box, with an
352/// absent bound spelled `∓∞`.
353///
354/// Both extractors used to emit each finite bound as a `G` row (`x_i ≤ x_u`,
355/// `−x_i ≤ −x_l`) and leave `lb`/`ub` empty. That was never wrong — the IPM
356/// re-expands finite bounds into exactly those rows internally — but it threw
357/// away the one thing the solvers can only get from the box: **that these
358/// rows are a box**. Three consequences, all real:
359///
360/// * The empty-box screen ([`pounce_convex`]'s `screen_variable_box`, gh #491)
361///   reads `lb`/`ub`, so a model with a reversed bound arrived as a pair of
362///   contradictory rows instead — an infeasibility that has to be *certified*
363///   numerically rather than seen. The interior-point method managed that at
364///   most widths but returned `NumericalFailure` at a `NaN` iterate for
365///   crossings around `1e-8`.
366/// * The active-set engine handles a box with bound *statuses*, not with
367///   constraint rows; feeding it `2n` extra rows made every `.nl` QP that much
368///   larger in the one dimension an active-set method pays combinatorially
369///   for.
370/// * Presolve reasons about `tlb`/`tub` directly, so bounds hidden in rows had
371///   to be rediscovered by activity-based tightening before any box reduction
372///   could fire.
373///
374/// The bound *multipliers* now come back in the solution's `z_lb`/`z_ub`
375/// rather than being decoded out of `z` by row position.
376fn extract_box(prob: &NlProblem, relax: BoundRelax) -> (Vec<f64>, Vec<f64>) {
377    // Two declared boxes are passed through untouched.
378    //
379    // A variable pinned by `x_l == x_u` is fixed, and upstream's default
380    // `fixed_variable_treatment=make_parameter` lifts it out of the problem
381    // before `relax_bounds` runs — so it is never widened. Keep it pinned
382    // here too; widening it would hand the solver two decision variables'
383    // worth of slack that the NLP path does not have.
384    //
385    // A *crossed* box (`x_l > x_u`) is an empty set, and the NLP path rejects
386    // it as `Invalid_Problem_Definition` before relaxation. Widening it by
387    // more than the crossing would close the gap and return an optimal point
388    // for a model with no feasible one — gh #491's `1e-8` fixture crosses by
389    // less than the default `2 × 1e-8` widening. Leave it crossed so the
390    // empty-box screen downstream still sees it.
391    let as_declared = |i: usize| {
392        let (l, u) = (prob.x_l[i], prob.x_u[i]);
393        lower_bound_present(l) && upper_bound_present(u) && l >= u
394    };
395    let lb = (0..prob.n)
396        .map(|i| {
397            let v = prob.x_l[i];
398            if !lower_bound_present(v) {
399                f64::NEG_INFINITY
400            } else if as_declared(i) {
401                v
402            } else {
403                v - relax.var_delta(v)
404            }
405        })
406        .collect();
407    let ub = (0..prob.n)
408        .map(|i| {
409            let v = prob.x_u[i];
410            if !upper_bound_present(v) {
411                f64::INFINITY
412            } else if as_declared(i) {
413                v
414            } else {
415                v + relax.var_delta(v)
416            }
417        })
418        .collect();
419    (lb, ub)
420}
421
422/// Map the QP solver's multipliers `(y, z)` back to a per-`.nl`-
423/// constraint dual vector (length `prob.m`), in the AMPL `.sol`
424/// convention used by POUNCE's NLP path.
425///
426/// The QP solver enforces stationarity `∇f + Aᵀy + Gᵀz = 0` with
427/// `z ≥ 0`, where each inequality `.nl` row contributes a `row ≤ g_u`
428/// (`+row`) and/or `−row ≤ −g_l` (`−row`) `G` row. The per-constraint
429/// `.nl`/Ipopt multiplier `λ` is recovered as:
430/// - equality: `λ = sign · y[a_row]`;
431/// - inequality: `λ = sign · (z_upper − z_lower)` — at most one of the
432///   two bound rows is active at a solution.
433///
434/// The inequality sign (`z_upper − z_lower`, *not* `z_lower − z_upper`)
435/// is fixed to match POUNCE's NLP path, which is the reference for what
436/// a POUNCE `.sol` carries; this is verified empirically against the NLP
437/// solve in the crate tests. `sign` undoes the maximize→minimize
438/// negation so the reported dual is in the user's original sense.
439pub fn recover_duals(prob: &NlProblem, con_map: &[ConRowMap], y: &[f64], z: &[f64]) -> Vec<f64> {
440    let sign = if prob.minimize { 1.0 } else { -1.0 };
441    con_map
442        .iter()
443        .map(|m| match m {
444            ConRowMap::Eq { a_row } => sign * y[*a_row],
445            ConRowMap::Ineq { upper, lower } => {
446                let zu = upper.map(|r| z[r]).unwrap_or(0.0);
447                let zl = lower.map(|r| z[r]).unwrap_or(0.0);
448                sign * (zu - zl)
449            }
450        })
451        .collect()
452}
453
454/// The next 0-based row index for a constraint block keyed by its RHS
455/// vector's current length.
456fn next_row(rhs: &[f64]) -> usize {
457    rhs.len()
458}
459
460/// Recover the per-variable **bound multipliers** from a solved QP or SOCP.
461///
462/// Both extractors put the `.nl` variable bounds in the explicit box
463/// ([`extract_box`]), so the solver returns their multipliers directly in
464/// `z_lb`/`z_ub` and this is a length-normalizing read rather than the
465/// row-position decode it used to be. Variables are 1:1 with the `.nl`
466/// variables in both extractors, so no index remap is needed; a slot without
467/// a finite bound stays `0.0` because no bound was active there.
468///
469/// The returned `z_lb` / `z_ub` are the raw non-negative multipliers of the
470/// *internal minimize* problem (a maximize objective was negated during
471/// extraction); the caller applies the maximize `sign` and the Ipopt
472/// `ipopt_zL_out = +z_l`, `ipopt_zU_out = −z_u` output convention.
473pub fn recover_bound_mults(prob: &NlProblem, sol: &QpSolution) -> (Vec<f64>, Vec<f64>) {
474    let read = |v: &[f64]| -> Vec<f64> {
475        (0..prob.n)
476            .map(|i| v.get(i).copied().unwrap_or(0.0))
477            .collect()
478    };
479    (read(&sol.z_lb), read(&sol.z_ub))
480}
481
482// ===========================================================================
483// QCQP → SOCP extraction
484// ===========================================================================
485
486/// Where each `.nl` constraint landed in the standard-form **conic** program,
487/// so the cone multipliers can be mapped back to a per-`.nl`-constraint dual.
488/// One entry per original constraint, in order. (Analogue of [`ConRowMap`] for
489/// the SOCP path produced by [`extract_socp_with_map`].)
490#[derive(Debug, Clone)]
491pub enum ConSocpMap {
492    /// Linear equality → row `a_row` of `A` (multiplier `y[a_row]`).
493    Eq { a_row: usize },
494    /// Linear inequality / range → up to two rows of the nonnegative `G`
495    /// block (`row ≤ g_u` and/or `−row ≤ −g_l`), multipliers `z[..] ≥ 0`.
496    Ineq {
497        upper: Option<usize>,
498        lower: Option<usize>,
499    },
500    /// Convex quadratic inequality `g(x) ≤ g_u`, reformulated to one
501    /// second-order cone. The first two cone rows both carry the linear
502    /// coefficient vector `a = ∇(linear part)`, so the original constraint
503    /// multiplier is recovered as `z[r0] + z[r1]` (see
504    /// [`recover_socp_duals`]).
505    Quad { z_row0: usize, z_row1: usize },
506}
507
508/// A deferred second-order-cone block, built after the nonnegative `G` rows
509/// are known so the cones partition `G` in row order (nonneg block first,
510/// then the SOCs).
511/// Everything here is sized by the row's own **support** `k` — the variables
512/// that actually appear in it — never by the problem width `n`. A QCQP row
513/// typically touches a handful of variables out of `n` in the hundreds of
514/// thousands (`nql180`: `k = 2`, `n = 129 601`), so an `n`-sized structure per
515/// row is the difference between kilobytes and tens of gigabytes.
516struct SocBlock {
517    /// Index in `con_map` of the originating constraint, to patch with the
518    /// final cone-row indices once they are assigned.
519    con_idx: usize,
520    /// Linear coefficients of the constraint as `(variable, coefficient)`,
521    /// ascending by variable and with zeros dropped.
522    a: Vec<(usize, f64)>,
523    /// `b_eff = (nonlinear constant) − g_u`, the constraint's degree-0 term
524    /// after moving the upper bound to the right: `½xᵀQx + aᵀx + b_eff ≤ 0`.
525    b_eff: f64,
526    /// Rows of the factor `F` with `FᵀF = Q`, each a sparse
527    /// `(variable, coefficient)` list in the problem's own indexing.
528    ///
529    /// Sparse rather than length-`n` (or even length-`k`) dense: a diagonal
530    /// `Q` — the `qssp180`/`nql180` regime — has rank `k` and one nonzero per
531    /// factor row, so a dense factor would be `k²` to hold a `k`-nonzero
532    /// object.
533    f_rows: Vec<Vec<(usize, f64)>>,
534}
535
536/// Convert a classified **convex QCQP** `NlProblem` into the conic standard
537/// form the SOCP IPM consumes:
538///
539/// ```text
540/// minimize    ½ xᵀP x + cᵀx
541/// subject to  A x = b
542///             h − G x  ∈  K        (K = nonneg orthant × second-order cones)
543/// ```
544///
545/// Returns `(QpProblem, con_map, obj_nl_constant, cones)`:
546/// - the objective `P`/`c` exactly as the LP/QP path builds them;
547/// - linear equalities in `A`/`b`; linear inequalities and finite variable
548///   bounds as a leading **nonnegative** `G` block; and each convex quadratic
549///   inequality `g(x) ≤ g_u` as one **second-order cone** block appended
550///   after it (so `cones` covers the `G` rows in order);
551/// - `con_map` mapping each original constraint to its rows for dual recovery;
552/// - `obj_nl_constant`, the objective's folded degree-0 term (added back to the
553///   reported value, exactly as in [`extract_qp_with_map`]).
554///
555/// `None` if the objective is not degree-≤2 (should not happen for a problem
556/// the classifier routed here). The reformulation of a convex quadratic
557/// `½xᵀQx + aᵀx + b_eff ≤ 0` (with `Q = FᵀF ⪰ 0`) is the standard rotated→
558/// standard SOC: writing `s = −(aᵀx + b_eff)`, the cone slack
559/// `(s+1, s−1, √2·Fx)` lies in the second-order cone iff `‖Fx‖² ≤ 2s`, i.e.
560/// iff the original constraint holds.
561pub fn extract_socp_with_map(
562    prob: &NlProblem,
563    relax: BoundRelax,
564) -> Option<(QpProblem, Vec<ConSocpMap>, f64, Vec<ConeSpec>)> {
565    let n = prob.n;
566    let sign = if prob.minimize { 1.0 } else { -1.0 };
567
568    // --- objective P (lower triangle) + folded linear / constant terms ---
569    let (hess, obj_nl_linear, obj_nl_constant) = prob.obj_nonlinear.analyze_quadratic_full()?;
570    let mut p_lower: Vec<Triplet> = Vec::with_capacity(hess.len());
571    for ((i, j), v) in &hess {
572        let (row, col) = if i >= j { (*i, *j) } else { (*j, *i) };
573        p_lower.push(Triplet::new(row, col, sign * v));
574    }
575    let mut c = vec![0.0; n];
576    for (var, coef) in &prob.obj_linear {
577        c[*var] += sign * coef;
578    }
579    for (var, coef) in &obj_nl_linear {
580        c[*var] += sign * coef;
581    }
582
583    // --- constraints: equalities → A; linear ineqs → nonneg G block;
584    //     convex quadratics → deferred SOC blocks (added after the nonneg
585    //     rows so the cones partition G in row order) ---
586    let mut a: Vec<Triplet> = Vec::new();
587    let mut b: Vec<f64> = Vec::new();
588    let mut g: Vec<Triplet> = Vec::new();
589    let mut h: Vec<f64> = Vec::new();
590    let mut con_map: Vec<ConSocpMap> = Vec::with_capacity(prob.m);
591    let mut soc_blocks: Vec<SocBlock> = Vec::new();
592
593    for (row, lin) in prob.con_linear.iter().enumerate() {
594        let lo = prob.g_l[row];
595        let hi = prob.g_u[row];
596        let nl = &prob.con_nonlinear[row];
597        let quad = nl.analyze_quadratic_full();
598        let is_quadratic = matches!(&quad, Some((hmap, _, _)) if !hmap.is_empty());
599
600        if is_quadratic {
601            // Convex quadratic inequality `g(x) ≤ g_u` (the classifier
602            // guarantees an upper-only bound with PSD Hessian). Build the
603            // factor F (FᵀF = Q) and defer the SOC rows.
604            let (hmap, nl_lin, nl_const) = quad.expect("checked above");
605            // Linear coefficients a = linear-section + folded nonlinear-tree
606            // linear part, accumulated sparsely: a QCQP row's linear part is
607            // as narrow as its quadratic part, and `n` here can be six digits.
608            let mut a_map: std::collections::BTreeMap<usize, f64> =
609                std::collections::BTreeMap::new();
610            for (var, coef) in lin {
611                *a_map.entry(*var).or_insert(0.0) += *coef;
612            }
613            for (var, coef) in &nl_lin {
614                *a_map.entry(*var).or_insert(0.0) += *coef;
615            }
616            let a_vec: Vec<(usize, f64)> = a_map.into_iter().filter(|&(_, c)| c != 0.0).collect();
617
618            let f_rows = socp_factor_rows(&hmap);
619            let con_idx = con_map.len();
620            con_map.push(ConSocpMap::Quad {
621                z_row0: 0,
622                z_row1: 0,
623            }); // patched in the SOC pass below
624            soc_blocks.push(SocBlock {
625                con_idx,
626                a: a_vec,
627                b_eff: nl_const - (hi + relax.row_delta(hi)),
628                f_rows,
629            });
630            continue;
631        }
632
633        // Linear constraint. Combine the `.nl` linear section with any
634        // degree-≤1 terms AMPL folded into the (here empty-Hessian)
635        // nonlinear tree, and shift the bounds by the folded constant.
636        let (nl_lin, const_shift) = quad.map(|(_, l, k)| (l, k)).unwrap_or_default();
637        let mut coef = vec![0.0; n];
638        for (var, v) in lin {
639            coef[*var] += *v;
640        }
641        for (var, v) in &nl_lin {
642            coef[*var] += *v;
643        }
644        let nonzeros = || coef.iter().enumerate().filter(|(_, v)| **v != 0.0);
645        if lo == hi && lower_bound_present(lo) && upper_bound_present(hi) {
646            let eq_row = next_row(&b);
647            for (var, v) in nonzeros() {
648                a.push(Triplet::new(eq_row, var, *v));
649            }
650            b.push(lo - const_shift);
651            con_map.push(ConSocpMap::Eq { a_row: eq_row });
652        } else {
653            let relax = relax.for_row(lo, hi);
654            let upper = if upper_bound_present(hi) {
655                let gr = next_row(&h);
656                for (var, v) in nonzeros() {
657                    g.push(Triplet::new(gr, var, *v));
658                }
659                h.push(hi + relax.row_delta(hi) - const_shift);
660                Some(gr)
661            } else {
662                None
663            };
664            let lower = if lower_bound_present(lo) {
665                let gr = next_row(&h);
666                for (var, v) in nonzeros() {
667                    g.push(Triplet::new(gr, var, -*v));
668                }
669                h.push(-(lo - relax.row_delta(lo) - const_shift));
670                Some(gr)
671            } else {
672                None
673            };
674            con_map.push(ConSocpMap::Ineq { upper, lower });
675        }
676    }
677
678    // Variable bounds go in the explicit box, not into this orthant block —
679    // see [`extract_box`]. `solve_socp_ipm` appends them as a trailing
680    // nonnegative block of its own, *after* the cones, so they stay outside
681    // the partition `cones` has to cover.
682    let (lb, ub) = extract_box(prob, relax);
683
684    // The nonnegative block is every G row built so far. The cones list must
685    // cover G in row order: this orthant block, then one SOC per quadratic.
686    let num_nonneg = h.len();
687    let mut cones: Vec<ConeSpec> = Vec::with_capacity(1 + soc_blocks.len());
688    if num_nonneg > 0 {
689        cones.push(ConeSpec::Nonneg(num_nonneg));
690    }
691
692    // --- emit the deferred second-order cones ---
693    for blk in soc_blocks {
694        let r = blk.f_rows.len();
695        let dim = r + 2;
696        let row0 = next_row(&h);
697        // s0 = (1 − b_eff) − aᵀx  →  G row = a, h = 1 − b_eff.
698        for &(var, coef) in &blk.a {
699            g.push(Triplet::new(row0, var, coef));
700        }
701        h.push(1.0 - blk.b_eff);
702        let row1 = next_row(&h);
703        // s1 = −(1 + b_eff) − aᵀx  →  G row = a, h = −(1 + b_eff).
704        for &(var, coef) in &blk.a {
705            g.push(Triplet::new(row1, var, coef));
706        }
707        h.push(-(1.0 + blk.b_eff));
708        // s_{2+k} = √2·(Fx)_k  →  G row = −√2·F_k, h = 0. `f` is indexed by
709        // position within the row's support, so scatter back through it.
710        let sqrt2 = std::f64::consts::SQRT_2;
711        for f in &blk.f_rows {
712            let gr = next_row(&h);
713            for &(var, fv) in f {
714                g.push(Triplet::new(gr, var, -sqrt2 * fv));
715            }
716            h.push(0.0);
717        }
718        cones.push(ConeSpec::SecondOrder(dim));
719        con_map[blk.con_idx] = ConSocpMap::Quad {
720            z_row0: row0,
721            z_row1: row1,
722        };
723    }
724
725    Some((
726        QpProblem {
727            n,
728            p_lower,
729            c,
730            a,
731            b,
732            g,
733            h,
734            lb,
735            ub,
736        },
737        con_map,
738        obj_nl_constant,
739        cones,
740    ))
741}
742
743/// Map the SOCP solver's multipliers `(y, z)` back to a per-`.nl`-constraint
744/// dual vector (length `prob.m`), in POUNCE's NLP-path `.sol` convention.
745///
746/// Linear rows reuse the QP-path recovery (`y[a_row]` for an equality;
747/// `z_upper − z_lower` for an inequality). For a convex quadratic
748/// `g(x) ≤ g_u` reformulated to a second-order cone, the constraint
749/// multiplier is recovered as the sum of the two cone duals on the rows
750/// carrying the linear coefficient vector `a`: `λ = z[r0] + z[r1]`. (At a
751/// KKT point stationarity reads `λ(∇g) = (z[r0]+z[r1])·a + …`, so this sum is
752/// the original multiplier; the cone's remaining rows reconstruct the `Qx`
753/// part.) `sign` undoes the maximize→minimize negation.
754pub fn recover_socp_duals(
755    prob: &NlProblem,
756    con_map: &[ConSocpMap],
757    y: &[f64],
758    z: &[f64],
759) -> Vec<f64> {
760    let sign = if prob.minimize { 1.0 } else { -1.0 };
761    con_map
762        .iter()
763        .map(|m| match m {
764            ConSocpMap::Eq { a_row } => sign * y[*a_row],
765            ConSocpMap::Ineq { upper, lower } => {
766                let zu = upper.map(|r| z[r]).unwrap_or(0.0);
767                let zl = lower.map(|r| z[r]).unwrap_or(0.0);
768                sign * (zu - zl)
769            }
770            ConSocpMap::Quad { z_row0, z_row1 } => sign * (z[*z_row0] + z[*z_row1]),
771        })
772        .collect()
773}
774
775/// Factor one quadratic row's Hessian `Q` into sparse rows `f_k` (in the
776/// problem's variable indexing) with `Σ_k f_k f_kᵀ = Q`.
777///
778/// Two paths, and the cheap one is the common one:
779///
780/// * **Diagonal `Q`** — `f` is one row per positive diagonal entry, each with a
781///   single nonzero `√d_i`. `O(k)` time and `O(k)` space in the row's support.
782///   This is the `qssp180`/`nql180` regime, where the general path's `O(k³)`
783///   factorization and `k²` factor would both be ruinous for no benefit.
784/// * **Otherwise** — pivoted Cholesky on a dense `k×k` over the row's support,
785///   then scatter the rows back to problem indices, dropping zeros. Sized by
786///   `k`, never by `n`.
787fn socp_factor_rows(
788    hmap: &std::collections::BTreeMap<(usize, usize), f64>,
789) -> Vec<Vec<(usize, f64)>> {
790    let support = quad_support(hmap);
791    let k = support.len();
792
793    if !hmap.keys().any(|&(i, j)| i != j) {
794        // Diagonal: one factor row per variable, holding that entry's square
795        // root. Nonpositive diagonal entries are the zero eigenvalues of a PSD
796        // diagonal matrix (convexity is already established before we get
797        // here), and contribute no row.
798        //
799        // Two details make this a *shortcut* rather than an approximation, so
800        // the fast path and the general path emit bit-identical factors and a
801        // problem's trajectory cannot depend on which one ran:
802        //
803        //  * the tolerance is the same expression `psd_outer_factor` uses, so
804        //    both drop exactly the same entries and agree on the cone's
805        //    dimension. Since gh #703 that expression is **relative to the
806        //    entry's own magnitude**, not to the largest diagonal: on a
807        //    diagonal `Q` no downdate ever touches another pivot, so every
808        //    positive entry is a genuine eigenvalue whatever its size, and the
809        //    filter here is simply `v > 0`. Cutting at `1e-12 · max_diag`
810        //    instead discarded real directions on a column-scaled model — see
811        //    `psd_outer_factor`;
812        //  * the value is `d / √d`, not `√d`. They differ by an ulp — `√2` is
813        //    `0x1.6a09e667f3bcdp+0` and `2/√2` is `0x1.6a09e667f3bccp+0` — and
814        //    `d / √d` is what the general path's `a[i][p] / d_pivot` computes.
815        //    Reproducing it is free; not reproducing it moved `qcqp_ball` from
816        //    17 conic iterations to 12 on a 2-ulp perturbation of one `G`
817        //    entry, which is precisely the kind of invisible trajectory change
818        //    `scripts/sweep-fixtures.sh` exists to catch.
819        //  * the rows come out in **pivot order**, largest diagonal first. On a
820        //    diagonal matrix the rank-1 downdate only zeros the pivot, so the
821        //    general path's complete pivoting visits entries in descending
822        //    order with ties going to the lower index — which is what a
823        //    *stable* sort of the (ascending-by-index) map entries gives.
824        //    `‖Fx‖` does not care about row order, but `G` does: the rows land
825        //    in the KKT matrix and the ordering feeds the fill-reducing
826        //    permutation.
827        let mut diag: Vec<(usize, f64)> = hmap
828            .iter()
829            .filter(|&(_, &v)| v > 0.0)
830            .map(|(&(i, _), &v)| (i, v))
831            .collect();
832        diag.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("PSD diagonal is finite"));
833        return diag
834            .into_iter()
835            .map(|(i, v)| vec![(i, v / v.sqrt())])
836            .collect();
837    }
838
839    let dense = dense_symmetric_on_support(hmap, &support);
840    psd_outer_factor(dense, k)
841        .into_iter()
842        .map(|f| {
843            f.into_iter()
844                .enumerate()
845                .filter(|&(_, fv)| fv != 0.0)
846                .map(|(loc, fv)| (support[loc], fv))
847                .collect()
848        })
849        .collect()
850}
851
852/// The variables a quadratic row touches, ascending and deduplicated.
853///
854/// This is the row's *support*, and it is what every downstream structure is
855/// sized by. `hmap` stores only `i ≤ j`, so both coordinates must be collected.
856fn quad_support(hmap: &std::collections::BTreeMap<(usize, usize), f64>) -> Vec<usize> {
857    let mut s: Vec<usize> = hmap.keys().flat_map(|&(i, j)| [i, j]).collect();
858    s.sort_unstable();
859    s.dedup();
860    s
861}
862
863/// Build a dense symmetric `k×k` matrix over a row's `support` from a
864/// [`QuadHessian`]-style map of `(i ≤ j) → Hessian entry` (diagonal entries are
865/// the full `∂²/∂xᵢ²`, so `½xᵀHx` reproduces the quadratic form). Off-diagonals
866/// are mirrored.
867///
868/// Sized by `k`, never by `n`: the previous `n×n` version asked for 134 GB on a
869/// two-variable row of `nql180`.
870fn dense_symmetric_on_support(
871    hmap: &std::collections::BTreeMap<(usize, usize), f64>,
872    support: &[usize],
873) -> Vec<f64> {
874    let k = support.len();
875    // `support` is sorted, so a binary search is the local index.
876    let loc = |v: usize| support.binary_search(&v).expect("key came from support");
877    let mut dense = vec![0.0; k * k];
878    for (&(i, j), &v) in hmap {
879        let (li, lj) = (loc(i), loc(j));
880        dense[li * k + lj] = v;
881        dense[lj * k + li] = v;
882    }
883    dense
884}
885
886/// Symmetric **pivoted (rank-revealing) Cholesky** of a PSD matrix `H`
887/// (row-major `n×n`, consumed as scratch), returning the factor rows `f_k`
888/// (each length `n`) such that `Σ_k f_k f_kᵀ = H` — equivalently `FᵀF = H`
889/// with `F` the matrix whose rows are the `f_k`.
890///
891/// Callers pass a **row's support size** `k` here, not the problem width: this
892/// is `O(n³)` in whatever it is handed, so the distinction is what makes a wide
893/// QCQP extractable at all.
894///
895/// The number of rows is the
896/// numerical rank, so a rank-deficient `Q` (e.g. `Q = vvᵀ`) yields the
897/// minimal cone. Complete diagonal pivoting keeps the factorization stable
898/// on the indefinite-looking-but-PSD matrices finite precision can produce.
899///
900/// # The rank test is relative to each pivot's own starting magnitude (gh #703)
901///
902/// This used to cut at `1e-12 · max_diag` — a *global* threshold — and that
903/// is not a rank test, it is a units test. Rank deficiency is what the
904/// rank-1 downdate reveals: a direction already spanned by the pivots taken
905/// so far has its residual diagonal driven from `Q_pp` to (numerically)
906/// zero. A direction that is merely *small in the coordinates the model was
907/// written in* has its residual diagonal stay a healthy fraction of `Q_pp`,
908/// and is a genuine eigenvalue however far below `max_diag` it sits.
909///
910/// The global cut confused the two, and silently. On
911/// `qcqp_columns_illcond.nl` — the well-conditioned fixture under the exact
912/// substitution `x_j → x_j / c_j`, so a matrix of provably identical rank —
913/// the diagonal spans `[1.5e-7, 4.3e9]`, `1e-12 · max_diag ≈ 4.3e-3`
914/// discarded **7 of 24** directions, and the cone `‖Fx‖ ≤ t` stopped
915/// constraining them. The conic solver then satisfied *its* cone to
916/// `2.66e-15` and reported `SolveSucceeded` at an objective 10% away from
917/// the true optimum, on a point that violates the original quadratic row by
918/// `4.948e+01` — 38% of its right-hand side. A relative residual check
919/// would not have caught it either: measured against `‖Q‖ = 4.3e9` the
920/// reconstruction error is `5.4e-13`. The dropped rank is the only signal.
921///
922/// `a[p][p] > 1e-12 · Q_pp` is that test, and it is invariant under the
923/// diagonal congruence `Q → CQC` that provoked the bug, since both sides
924/// scale by `c_p²`. It changes nothing about the pivot *order* (still the
925/// largest remaining diagonal), so a well-scaled matrix factors bit for bit
926/// as before, and it keeps the diagonal shortcut above interchangeable with
927/// this path: on a diagonal `Q` no downdate touches a pivot, so both keep
928/// exactly the positive entries.
929fn psd_outer_factor(mut a: Vec<f64>, n: usize) -> Vec<Vec<f64>> {
930    let mut rows: Vec<Vec<f64>> = Vec::new();
931    // Each pivot's *initial* diagonal, so the rank test below can ask how far
932    // the downdate has moved it rather than how it compares to the model's
933    // units. Clamped at zero: a PSD matrix has `Q_ii ≥ 0`, and an entry that
934    // finite precision has pushed slightly negative must not produce a
935    // negative threshold that admits it.
936    let d0: Vec<f64> = (0..n).map(|i| a[i * n + i].max(0.0)).collect();
937    // Columns already decided — either factored out, or ruled a zero
938    // eigenvalue. A pivoted Cholesky never revisits a pivot, and the residual
939    // it leaves on that diagonal is roundoff, not a candidate.
940    let mut settled = vec![false; n];
941    for _ in 0..n {
942        // Largest undecided diagonal pivot.
943        let mut p = usize::MAX;
944        let mut best = f64::NEG_INFINITY;
945        for i in 0..n {
946            if settled[i] {
947                continue;
948            }
949            let d = a[i * n + i];
950            if d > best {
951                best = d;
952                p = i;
953            }
954        }
955        if p == usize::MAX {
956            break;
957        }
958        // Rule it a zero eigenvalue when the downdate has reduced it to a
959        // negligible fraction of where it started — that is the residual
960        // saying the direction is already spanned. `best <= 0` (including a
961        // pivot whose `d0` is zero, where the threshold is zero) rules it out
962        // too.
963        //
964        // `continue`, not `break`: with an *absolute* threshold the pivot
965        // order and the rank order were the same order, so the first failure
966        // ended it. A relative threshold decouples them — a column whose `d0`
967        // is `1e-20` is still live at a residual of `1e-20`, while a spent
968        // column with `d0 = 2` is dead at the `4e-16` of roundoff it carries,
969        // and the dead one sorts first. Breaking there would drop the live
970        // column and make the rank depend on the model's units again, which
971        // is the whole defect this test was rewritten to fix.
972        if best <= 1e-12 * d0[p] || best <= 0.0 {
973            settled[p] = true;
974            continue;
975        }
976        settled[p] = true;
977        let d = best.sqrt();
978        // f = column p of the residual, scaled by 1/d.
979        let mut f = vec![0.0; n];
980        for i in 0..n {
981            f[i] = a[i * n + p] / d;
982        }
983        // Rank-1 downdate: A ← A − f fᵀ.
984        for i in 0..n {
985            let fi = f[i];
986            if fi == 0.0 {
987                continue;
988            }
989            for j in 0..n {
990                a[i * n + j] -= fi * f[j];
991            }
992        }
993        rows.push(f);
994    }
995    rows
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001    use crate::nl_reader::NlBody;
1002    use crate::nl_reader::{BinOp, Expr};
1003    use pounce_convex::{QpOptions, QpStatus, solve_qp_ipm, solve_socp_ipm};
1004    use pounce_feral::FeralSolverInterface;
1005    use pounce_linsol::SparseSymLinearSolverInterface;
1006
1007    fn backend() -> Box<dyn SparseSymLinearSolverInterface> {
1008        Box::new(FeralSolverInterface::new())
1009    }
1010
1011    fn pow2(var: usize) -> Expr {
1012        Expr::Binary(
1013            BinOp::Pow,
1014            Box::new(Expr::Var(var)),
1015            Box::new(Expr::Const(2.0)),
1016        )
1017    }
1018
1019    /// min −x0 − x1  s.t.  x0² + x1² ≤ 1  → x* = (1/√2, 1/√2), f* = −√2.
1020    /// Exercises the QCQP→SOCP reformulation end-to-end: a rank-2 ball
1021    /// constraint becomes one second-order cone, no nonnegative block.
1022    #[test]
1023    fn extract_and_solve_socp_ball() {
1024        let prob = NlProblem {
1025            src: None,
1026            cse_bodies: Vec::new(),
1027            n: 2,
1028            m: 1,
1029            num_obj: 1,
1030            minimize: true,
1031            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1032            obj_linear: vec![(0, -1.0), (1, -1.0)],
1033            obj_constant: 0.0,
1034            con_nonlinear: vec![NlBody::Tree(Expr::Binary(
1035                BinOp::Add,
1036                Box::new(pow2(0)),
1037                Box::new(pow2(1)),
1038            ))],
1039            con_linear: vec![vec![]],
1040            x_l: vec![-2e19, -2e19],
1041            x_u: vec![2e19, 2e19],
1042            g_l: vec![-2e19],
1043            g_u: vec![1.0],
1044            x0: vec![0.0, 0.0],
1045            lambda0: vec![0.0],
1046            suffixes: Default::default(),
1047            imported_funcs: Vec::new(),
1048            ampl_options: Vec::new(),
1049            nl_counts: None,
1050            n_discrete: None,
1051            var_names: Vec::new(),
1052            con_names: Vec::new(),
1053        };
1054        let (qp, con_map, obj_const, cones) =
1055            extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1056        assert_eq!(obj_const, 0.0);
1057        // No linear inequalities / bounds → no nonneg block; one SOC of
1058        // dimension rank(Q)+2 = 2+2 = 4.
1059        assert_eq!(cones, vec![ConeSpec::SecondOrder(4)]);
1060        assert_eq!(qp.m_ineq(), 4);
1061
1062        let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
1063        assert_eq!(sol.status, QpStatus::Optimal);
1064        let inv_sqrt2 = 1.0 / 2.0_f64.sqrt();
1065        assert!((sol.x[0] - inv_sqrt2).abs() < 1e-5, "x0={}", sol.x[0]);
1066        assert!((sol.x[1] - inv_sqrt2).abs() < 1e-5, "x1={}", sol.x[1]);
1067        assert!(
1068            (sol.obj - (-2.0_f64.sqrt())).abs() < 1e-5,
1069            "obj={}",
1070            sol.obj
1071        );
1072
1073        // Analytic multiplier: c + λ·2x = 0 ⇒ λ = 1/(2x0) = √2/2 ≈ 0.7071,
1074        // positive (active upper bound), matching the `.sol` sign convention.
1075        let lambda = recover_socp_duals(&prob, &con_map, &sol.y, &sol.z);
1076        assert_eq!(lambda.len(), 1);
1077        assert!(
1078            (lambda[0] - 0.5 * 2.0_f64.sqrt()).abs() < 1e-3,
1079            "ball constraint dual={}",
1080            lambda[0]
1081        );
1082    }
1083
1084    /// min x0  s.t.  (x0−3)² ≤ 1  → feasible x0 ∈ [2, 4], optimum x0 = 2.
1085    /// The constraint's linear (`−6x0`) and constant (`+9`) terms are folded
1086    /// into the nonlinear tree; the reformulation must recover `b_eff = 9 − 1`
1087    /// so the cone encodes `x0² − 6x0 + 8 ≤ 0`, not `x0² ≤ 1`.
1088    #[test]
1089    fn extract_and_solve_socp_folds_constraint_constant() {
1090        let con = Expr::Binary(
1091            BinOp::Pow,
1092            Box::new(Expr::Binary(
1093                BinOp::Sub,
1094                Box::new(Expr::Var(0)),
1095                Box::new(Expr::Const(3.0)),
1096            )),
1097            Box::new(Expr::Const(2.0)),
1098        );
1099        let prob = NlProblem {
1100            src: None,
1101            cse_bodies: Vec::new(),
1102            n: 1,
1103            m: 1,
1104            num_obj: 1,
1105            minimize: true,
1106            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1107            obj_linear: vec![(0, 1.0)],
1108            obj_constant: 0.0,
1109            con_nonlinear: vec![NlBody::Tree(con)],
1110            con_linear: vec![vec![]],
1111            x_l: vec![-2e19],
1112            x_u: vec![2e19],
1113            g_l: vec![-2e19],
1114            g_u: vec![1.0],
1115            x0: vec![0.0],
1116            lambda0: vec![0.0],
1117            suffixes: Default::default(),
1118            imported_funcs: Vec::new(),
1119            ampl_options: Vec::new(),
1120            nl_counts: None,
1121            n_discrete: None,
1122            var_names: Vec::new(),
1123            con_names: Vec::new(),
1124        };
1125        let (qp, _con_map, obj_const, cones) =
1126            extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1127        assert_eq!(obj_const, 0.0);
1128        assert_eq!(cones, vec![ConeSpec::SecondOrder(3)]); // rank 1 + 2.
1129
1130        let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
1131        assert_eq!(sol.status, QpStatus::Optimal);
1132        assert!((sol.x[0] - 2.0).abs() < 1e-5, "x0={}", sol.x[0]);
1133    }
1134
1135    /// Build `min −x_i − x_j  s.t.  x_i² + x_j² ≤ 1` over `n` variables, where
1136    /// `i` and `j` are neither low-numbered nor adjacent.
1137    fn wide_ball(n: usize, i: usize, j: usize) -> NlProblem {
1138        let sq = |v: usize| {
1139            Expr::Binary(
1140                BinOp::Pow,
1141                Box::new(Expr::Var(v)),
1142                Box::new(Expr::Const(2.0)),
1143            )
1144        };
1145        NlProblem {
1146            src: None,
1147            cse_bodies: Vec::new(),
1148            n,
1149            m: 1,
1150            num_obj: 1,
1151            minimize: true,
1152            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1153            obj_linear: vec![(i, -1.0), (j, -1.0)],
1154            obj_constant: 0.0,
1155            con_nonlinear: vec![NlBody::Tree(Expr::Binary(
1156                BinOp::Add,
1157                Box::new(sq(i)),
1158                Box::new(sq(j)),
1159            ))],
1160            con_linear: vec![vec![]],
1161            x_l: vec![-10.0; n],
1162            x_u: vec![10.0; n],
1163            g_l: vec![-2e19],
1164            g_u: vec![1.0],
1165            x0: vec![0.0; n],
1166            lambda0: vec![0.0],
1167            suffixes: Default::default(),
1168            imported_funcs: Vec::new(),
1169            ampl_options: Vec::new(),
1170            nl_counts: None,
1171            n_discrete: None,
1172            var_names: Vec::new(),
1173            con_names: Vec::new(),
1174        }
1175    }
1176
1177    /// The cone factor is built on the row's **support**, so its columns are
1178    /// support-local and must be scattered back through `support` on emission.
1179    /// If that scatter is dropped, a row touching `x11`/`x37` silently
1180    /// constrains `x0`/`x1` instead — a wrong answer, not a crash.
1181    #[test]
1182    fn socp_factor_columns_scatter_back_to_original_variables() {
1183        let prob = wide_ball(40, 11, 37);
1184        let (qp, _con_map, _obj_const, cones) =
1185            extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1186        assert_eq!(cones, vec![ConeSpec::SecondOrder(4)]); // rank 2 + 2.
1187
1188        // Every G entry in the two factor rows must sit in column 11 or 37.
1189        // Rows 0 and 1 are the `a`-rows (here empty: no linear part).
1190        let factor_cols: std::collections::BTreeSet<usize> =
1191            qp.g.iter().filter(|t| t.row >= 2).map(|t| t.col).collect();
1192        assert_eq!(
1193            factor_cols,
1194            [11usize, 37].into_iter().collect(),
1195            "factor rows must reference the row's own variables, got {factor_cols:?}"
1196        );
1197    }
1198
1199    /// The extractor must be sized by a row's support, not by the problem
1200    /// width. A two-variable quadratic row in a 50 000-variable problem needs
1201    /// kilobytes; sizing it `n×n` would ask for 20 GB and abort the process.
1202    #[test]
1203    fn socp_extraction_is_sized_by_support_not_problem_width() {
1204        let n = 50_000;
1205        let prob = wide_ball(n, 7, n - 3);
1206        let (qp, _con_map, _obj_const, cones) =
1207            extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1208        assert_eq!(cones, vec![ConeSpec::SecondOrder(4)]);
1209        // The cone contributes exactly two nonzeros per factor row.
1210        assert_eq!(qp.g.iter().filter(|t| t.row >= 2).count(), 2);
1211    }
1212
1213    /// The same scatter, but through the **dense** path: a cross term makes `Q`
1214    /// non-diagonal, so the row goes through the pivoted Cholesky and its
1215    /// support-local columns must be mapped back. Rank 1, so one factor row.
1216    #[test]
1217    fn socp_dense_factor_columns_scatter_back_to_original_variables() {
1218        // (x11 + x37)² ≤ 1 — off-diagonal Q, rank 1.
1219        let con = Expr::Binary(
1220            BinOp::Pow,
1221            Box::new(Expr::Binary(
1222                BinOp::Add,
1223                Box::new(Expr::Var(11)),
1224                Box::new(Expr::Var(37)),
1225            )),
1226            Box::new(Expr::Const(2.0)),
1227        );
1228        let mut prob = wide_ball(40, 11, 37);
1229        prob.con_nonlinear = vec![NlBody::Tree(con)];
1230
1231        let (qp, _con_map, _obj_const, cones) =
1232            extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1233        assert_eq!(cones, vec![ConeSpec::SecondOrder(3)], "rank 1 + 2");
1234        let factor_cols: std::collections::BTreeSet<usize> =
1235            qp.g.iter().filter(|t| t.row >= 2).map(|t| t.col).collect();
1236        assert_eq!(factor_cols, [11usize, 37].into_iter().collect());
1237    }
1238
1239    /// A diagonal `Q` must not be densified. `socp_factor_rows` returns one
1240    /// single-nonzero row per positive diagonal entry, so the factor is `O(k)`
1241    /// — this is what makes the very large diagonal QCQPs (`qssp180`,
1242    /// `nql180`) representable at all.
1243    #[test]
1244    fn diagonal_hessian_factors_in_linear_space() {
1245        let mut h = std::collections::BTreeMap::new();
1246        for i in 0..1000usize {
1247            h.insert((i, i), 4.0);
1248        }
1249        let rows = socp_factor_rows(&h);
1250        assert_eq!(rows.len(), 1000);
1251        assert!(
1252            rows.iter().all(|r| r.len() == 1),
1253            "a diagonal Q must give one nonzero per factor row"
1254        );
1255        for (k, r) in rows.iter().enumerate() {
1256            assert_eq!(r[0].0, k);
1257            assert!((r[0].1 - 2.0).abs() < 1e-12, "√4 = 2, got {}", r[0].1);
1258        }
1259    }
1260
1261    /// Both factor paths must satisfy the same contract, `Σ_k f_k f_kᵀ = Q`,
1262    /// and must agree on the rank. There are now two of them — an `O(k)`
1263    /// diagonal shortcut and the general pivoted Cholesky — so the contract is
1264    /// asserted directly rather than inferred from the shortcut's derivation.
1265    ///
1266    /// The near-zero diagonal entry is the rank agreement. Since gh #703 the
1267    /// rank test is relative to each pivot's *own* starting magnitude rather
1268    /// than to `max_diag`, so a diagonal matrix has no zero eigenvalues at
1269    /// all: `1e-20` on its own row is a genuine, tiny eigenvalue and both
1270    /// paths must **keep** it. What must not differ is which of them thinks
1271    /// so — a disagreement would build cones of different dimension for the
1272    /// same constraint.
1273    #[test]
1274    fn both_factor_paths_reconstruct_q_and_agree_on_rank() {
1275        let recon = |rows: &[Vec<(usize, f64)>]| {
1276            let mut q: std::collections::BTreeMap<(usize, usize), f64> = Default::default();
1277            for r in rows {
1278                for &(i, fi) in r {
1279                    for &(j, fj) in r {
1280                        *q.entry((i, j)).or_insert(0.0) += fi * fj;
1281                    }
1282                }
1283            }
1284            q.retain(|_, v| v.abs() > 1e-12);
1285            q
1286        };
1287
1288        // Diagonal path, with one entry twenty orders of magnitude below the
1289        // largest. Deliberately *not* descending by index: the largest entry
1290        // sits on the highest variable, so a shortcut that emitted in index
1291        // order would produce the right cone with the rows in the wrong order.
1292        let diag: std::collections::BTreeMap<(usize, usize), f64> =
1293            [((3, 3), 2.0), ((8, 8), 9.0), ((9, 9), 1e-20)]
1294                .into_iter()
1295                .collect();
1296        let drows = socp_factor_rows(&diag);
1297        assert_eq!(
1298            drows.len(),
1299            3,
1300            "1e-20 on its own row is a small eigenvalue, not a missing one"
1301        );
1302
1303        // The shortcut must be *bit*-identical to the general path, not merely
1304        // close: a 2-ulp difference in one `G` entry visibly moved `qcqp_ball`'s
1305        // conic trajectory (17 → 12 iterations). Run the same matrix through
1306        // `psd_outer_factor` and compare the raw bits.
1307        let support = quad_support(&diag);
1308        let general = psd_outer_factor(dense_symmetric_on_support(&diag, &support), support.len());
1309        let general_vals: Vec<f64> = general
1310            .iter()
1311            .map(|f| f.iter().copied().find(|v| *v != 0.0).expect("one nonzero"))
1312            .collect();
1313        let short_vals: Vec<f64> = drows.iter().map(|r| r[0].1).collect();
1314        assert_eq!(
1315            short_vals.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1316            general_vals.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1317            "diagonal shortcut must reproduce psd_outer_factor bit for bit \
1318             (got {short_vals:?} vs {general_vals:?})"
1319        );
1320        assert_eq!(drows[0][0].0, 8, "largest diagonal pivots first");
1321        assert_eq!(drows[1][0].0, 3);
1322        assert_eq!(drows[2][0].0, 9, "then the smallest, still emitted");
1323        assert!(
1324            (drows[2][0].1 - 1e-10).abs() < 1e-22,
1325            "√1e-20 = 1e-10, got {}",
1326            drows[2][0].1
1327        );
1328        let dq = recon(&drows);
1329        // Two entries, not three: `recon` drops anything under `1e-12`, and
1330        // `(1e-10)² = 1e-20` is under it. The row is in the factor; its
1331        // contribution to `Q` is genuinely below what a reconstruction can
1332        // see, which is the whole reason the *pivot* test cannot be absolute.
1333        assert_eq!(dq.len(), 2);
1334        assert!((dq[&(3, 3)] - 2.0).abs() < 1e-12);
1335        assert!((dq[&(8, 8)] - 9.0).abs() < 1e-12);
1336
1337        // General path: same matrix plus a cross term, so the diagonal
1338        // shortcut no longer applies. Q = [[2,1],[1,9]] on {3,8} is positive
1339        // definite, so full rank 2.
1340        let mut coupled = diag.clone();
1341        coupled.insert((3, 8), 1.0);
1342        let grows = socp_factor_rows(&coupled);
1343        assert_eq!(grows.len(), 3, "2 from the coupled block, 1 from `1e-20`");
1344        let gq = recon(&grows);
1345        assert!((gq[&(3, 3)] - 2.0).abs() < 1e-10);
1346        assert!((gq[&(8, 8)] - 9.0).abs() < 1e-10);
1347        assert!((gq[&(3, 8)] - 1.0).abs() < 1e-10);
1348        assert!((gq[&(8, 3)] - 1.0).abs() < 1e-10);
1349        assert!(
1350            !gq.contains_key(&(9, 9)),
1351            "1e-20 is below what the reconstruction resolves"
1352        );
1353    }
1354
1355    /// `psd_outer_factor` recovers a rank-1 `Q = vvᵀ` with a single factor row
1356    /// (minimal cone), and reconstructs `Q` exactly.
1357    #[test]
1358    fn psd_outer_factor_is_rank_revealing() {
1359        // Q = [[1,2],[2,4]] = v vᵀ with v = (1,2): rank 1.
1360        let q = vec![1.0, 2.0, 2.0, 4.0];
1361        let rows = psd_outer_factor(q.clone(), 2);
1362        assert_eq!(rows.len(), 1, "rank-1 Q must give one factor row");
1363        // Reconstruct Σ f fᵀ and compare to Q.
1364        let mut recon = vec![0.0; 4];
1365        for f in &rows {
1366            for i in 0..2 {
1367                for j in 0..2 {
1368                    recon[i * 2 + j] += f[i] * f[j];
1369                }
1370            }
1371        }
1372        for k in 0..4 {
1373            assert!((recon[k] - q[k]).abs() < 1e-9, "recon[{k}]={}", recon[k]);
1374        }
1375    }
1376
1377    /// **The rank of `Q` is a property of `Q`, not of the units its variables
1378    /// are measured in.** `psd_outer_factor` decides the dimension of the cone
1379    /// a QCQP row becomes, so if a change of units can change that number, the
1380    /// solver builds a different — smaller — feasible set for the same model
1381    /// and reports success on the answer to a different problem.
1382    ///
1383    /// That is exactly what gh #703 hit. The rank test used to be
1384    /// `1e-12 · max_diag`, one absolute cut for the whole matrix, which asks
1385    /// how a pivot compares to the *largest* entry rather than how far its own
1386    /// downdate has moved it. Rescaling the columns of `qcqp_columns` by
1387    /// `10^{-4}…10^{4}` spread `max_diag` over nineteen orders of magnitude and
1388    /// took the rank of a full-rank 24×24 row from 24 to **17** — seven real
1389    /// directions dropped, `SolveSucceeded`, a self-reported violation of
1390    /// `2.66e-15` against an actual one of `4.948e+01`, and an objective 10%
1391    /// off its well-conditioned twin.
1392    ///
1393    /// Diagonal congruence `Q → C Q C` with `C ≻ 0` diagonal is precisely a
1394    /// change of units, and it preserves rank exactly (Sylvester). So the test
1395    /// is: factor the same `Q` under a spread of column scalings and require
1396    /// the row count never to move.
1397    #[test]
1398    fn rank_does_not_depend_on_the_units_the_columns_are_measured_in() {
1399        // A 4×4 PSD matrix of exact rank 3: `Q = Σ_{k<3} v_k v_kᵀ` over three
1400        // independent vectors, so one direction is genuinely absent and the
1401        // factorization must find that too — an invariance test that only ever
1402        // returned `n` would be satisfied by a rank test that never fires.
1403        let vs = [
1404            [1.0, 2.0, 0.0, -1.0],
1405            [0.0, 1.0, 3.0, 1.0],
1406            [2.0, 0.0, 1.0, 4.0],
1407        ];
1408        let n = 4;
1409        let mut q = vec![0.0; n * n];
1410        for v in &vs {
1411            for i in 0..n {
1412                for j in 0..n {
1413                    q[i * n + j] += v[i] * v[j];
1414                }
1415            }
1416        }
1417        assert_eq!(psd_outer_factor(q.clone(), n).len(), 3, "unscaled rank");
1418
1419        // `C = diag(10^e)`. The exponents run over the same range the
1420        // `qcqp_columns` fixtures use, and are deliberately *not* uniform: a
1421        // uniform scaling is a scalar multiple, which even an absolute
1422        // threshold survives.
1423        for spread in [1i32, 2, 3, 4, 6] {
1424            for sign in [1i32, -1] {
1425                let c: Vec<f64> = (0..n)
1426                    .map(|i| 10f64.powi(sign * spread * (i as i32 - 1)))
1427                    .collect();
1428                let mut scaled = vec![0.0; n * n];
1429                for i in 0..n {
1430                    for j in 0..n {
1431                        scaled[i * n + j] = c[i] * q[i * n + j] * c[j];
1432                    }
1433                }
1434                let rank = psd_outer_factor(scaled, n).len();
1435                assert_eq!(
1436                    rank, 3,
1437                    "C Q C with C = diag(10^({sign}·{spread}·(i−1))) has the \
1438                     same rank as Q; got {rank}"
1439                );
1440            }
1441        }
1442    }
1443
1444    /// The companion property, on the other side of the same threshold: a
1445    /// direction that *is* spanned must still be dropped, however the columns
1446    /// are scaled. Rank invariance alone would be satisfied by never cutting
1447    /// anything, which would hand every row a full-dimensional cone and cost
1448    /// the `qssp180`-class models their whole reason for taking the conic
1449    /// route.
1450    #[test]
1451    fn a_spanned_direction_is_dropped_at_every_column_scaling() {
1452        // Exactly rank 1: `Q = v vᵀ`, so three of four directions are spanned.
1453        let v = [1.0, 2.0, -3.0, 0.5];
1454        let n = 4;
1455        let mut q = vec![0.0; n * n];
1456        for i in 0..n {
1457            for j in 0..n {
1458                q[i * n + j] = v[i] * v[j];
1459            }
1460        }
1461        for e in [-8i32, -4, 0, 4, 8] {
1462            let c: Vec<f64> = (0..n).map(|i| 10f64.powi(e * (i as i32 - 1))).collect();
1463            let mut scaled = vec![0.0; n * n];
1464            for i in 0..n {
1465                for j in 0..n {
1466                    scaled[i * n + j] = c[i] * q[i * n + j] * c[j];
1467                }
1468            }
1469            assert_eq!(
1470                psd_outer_factor(scaled, n).len(),
1471                1,
1472                "rank-1 Q stays rank 1 under diag(10^({e}·(i−1)))"
1473            );
1474        }
1475    }
1476
1477    /// A pivot the downdate has *not* spent must survive even when a spent one
1478    /// sorts above it. This is the case that made the fix more than a change of
1479    /// threshold: `√2 · √2 ≠ 2` in binary, so a factored-out column of size 2
1480    /// carries `4.4e-16` of roundoff on its diagonal afterwards, which is
1481    /// larger than a genuine `1e-20` eigenvalue sitting untouched on another
1482    /// column. Complete pivoting picks the roundoff first. With an absolute
1483    /// threshold that did not matter — both failed the same cut. With a
1484    /// relative one they disagree, so the loop has to *settle* the failing
1485    /// pivot and keep looking rather than stop at the first failure.
1486    #[test]
1487    fn a_live_pivot_below_a_spent_ones_roundoff_is_still_found() {
1488        let n = 2;
1489        // diag(2, 1e-20), the smaller entry twenty orders down.
1490        let rows = psd_outer_factor(vec![2.0, 0.0, 0.0, 1e-20], n);
1491        assert_eq!(
1492            rows.len(),
1493            2,
1494            "both diagonal entries are eigenvalues; got {rows:?}"
1495        );
1496        assert!(
1497            (rows[1][1] - 1e-10).abs() < 1e-22,
1498            "the surviving row is √1e-20, got {}",
1499            rows[1][1]
1500        );
1501    }
1502
1503    /// min (x0)^2 + (x1)^2 s.t. x0 + x1 = 2, no var bounds → (1,1), f*=2.
1504    #[test]
1505    fn extract_and_solve_equality_qp() {
1506        let prob = NlProblem {
1507            src: None,
1508            cse_bodies: Vec::new(),
1509            n: 2,
1510            m: 1,
1511            num_obj: 1,
1512            minimize: true,
1513            obj_nonlinear: NlBody::Tree(Expr::Binary(
1514                BinOp::Add,
1515                Box::new(pow2(0)),
1516                Box::new(pow2(1)),
1517            )),
1518            obj_linear: vec![],
1519            obj_constant: 0.0,
1520            con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
1521            con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
1522            x_l: vec![-2e19, -2e19],
1523            x_u: vec![2e19, 2e19],
1524            g_l: vec![2.0],
1525            g_u: vec![2.0],
1526            x0: vec![0.0, 0.0],
1527            lambda0: vec![0.0],
1528            suffixes: Default::default(),
1529            imported_funcs: Vec::new(),
1530            ampl_options: Vec::new(),
1531            nl_counts: None,
1532            n_discrete: None,
1533            var_names: Vec::new(),
1534            con_names: Vec::new(),
1535        };
1536        let (qp, con_map, obj_const) =
1537            extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
1538        // No constant anywhere in this objective.
1539        assert_eq!(obj_const, 0.0);
1540        // P = 2I → two diagonal entries.
1541        assert_eq!(qp.p_lower.len(), 2);
1542        assert_eq!(qp.m_eq(), 1);
1543        assert_eq!(qp.m_ineq(), 0);
1544
1545        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1546        assert_eq!(sol.status, QpStatus::Optimal);
1547        assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
1548        assert!((sol.x[1] - 1.0).abs() < 1e-6, "x1={}", sol.x[1]);
1549        assert!((sol.obj - 2.0).abs() < 1e-6, "obj={}", sol.obj);
1550
1551        // KKT for the equality: ∇f + y·∇g = 0 → 2x_i + y = 0 at x=1 → y=−2.
1552        let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
1553        assert_eq!(lambda.len(), 1);
1554        assert!(
1555            (lambda[0] - (-2.0)).abs() < 1e-5,
1556            "equality dual={}",
1557            lambda[0]
1558        );
1559    }
1560
1561    /// Regression for the dropped-linear-term bug: the objective `(x0-3)²`
1562    /// lives entirely in the nonlinear tree, so its linear part (`−6·x0`)
1563    /// must be folded into `c`. Without it the solve minimizes `x0²`
1564    /// (optimum 0) instead of `(x0-3)²` (optimum 3).
1565    #[test]
1566    fn extract_keeps_linear_term_from_nonlinear_tree() {
1567        // (x0 - 3)^2 = x0^2 - 6 x0 + 9, all in obj_nonlinear.
1568        let obj = Expr::Binary(
1569            BinOp::Pow,
1570            Box::new(Expr::Binary(
1571                BinOp::Sub,
1572                Box::new(Expr::Var(0)),
1573                Box::new(Expr::Const(3.0)),
1574            )),
1575            Box::new(Expr::Const(2.0)),
1576        );
1577        let prob = NlProblem {
1578            src: None,
1579            cse_bodies: Vec::new(),
1580            n: 1,
1581            m: 0,
1582            num_obj: 1,
1583            minimize: true,
1584            obj_nonlinear: NlBody::Tree(obj),
1585            obj_linear: vec![],
1586            obj_constant: 0.0,
1587            con_nonlinear: vec![],
1588            con_linear: vec![],
1589            x_l: vec![-2e19],
1590            x_u: vec![2e19],
1591            g_l: vec![],
1592            g_u: vec![],
1593            x0: vec![0.0],
1594            lambda0: vec![],
1595            suffixes: Default::default(),
1596            imported_funcs: Vec::new(),
1597            ampl_options: Vec::new(),
1598            nl_counts: None,
1599            n_discrete: None,
1600            var_names: Vec::new(),
1601            con_names: Vec::new(),
1602        };
1603        let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
1604        assert_eq!(qp.c.len(), 1);
1605        assert!(
1606            (qp.c[0] - (-6.0)).abs() < 1e-12,
1607            "c[0]={} — linear term from the nonlinear tree was dropped",
1608            qp.c[0]
1609        );
1610        // P = 2 (one diagonal entry).
1611        assert_eq!(qp.p_lower.len(), 1);
1612
1613        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1614        assert_eq!(sol.status, QpStatus::Optimal);
1615        assert!(
1616            (sol.x[0] - 3.0).abs() < 1e-6,
1617            "x0={} (expected 3)",
1618            sol.x[0]
1619        );
1620    }
1621
1622    /// Inequality dual sign/magnitude. min x0² s.t. x0 ≥ 1 (a one-sided
1623    /// inequality g_l=1, g_u=+inf). Optimum x0=1, active. The expected
1624    /// dual −2.0 is the value POUNCE's *NLP* path writes for this exact
1625    /// problem (verified by running `solver_selection=nlp` on the same
1626    /// `.nl`); recover_duals must match that reference convention.
1627    #[test]
1628    fn inequality_dual_recovered() {
1629        let prob = NlProblem {
1630            src: None,
1631            cse_bodies: Vec::new(),
1632            n: 1,
1633            m: 1,
1634            num_obj: 1,
1635            minimize: true,
1636            obj_nonlinear: NlBody::Tree(pow2(0)),
1637            obj_linear: vec![],
1638            obj_constant: 0.0,
1639            con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
1640            con_linear: vec![vec![(0, 1.0)]], // g(x) = x0
1641            x_l: vec![-2e19],
1642            x_u: vec![2e19],
1643            g_l: vec![1.0], // x0 ≥ 1
1644            g_u: vec![2e19],
1645            x0: vec![0.0],
1646            lambda0: vec![0.0],
1647            suffixes: Default::default(),
1648            imported_funcs: Vec::new(),
1649            ampl_options: Vec::new(),
1650            nl_counts: None,
1651            n_discrete: None,
1652            var_names: Vec::new(),
1653            con_names: Vec::new(),
1654        };
1655        let (qp, con_map, obj_const) =
1656            extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
1657        // This model puts its constant in the `obj_constant` field, not the
1658        // nonlinear tree, so the tree constant is 0 here.
1659        assert_eq!(obj_const, 0.0);
1660        // One inequality row (the lower bound row −x0 ≤ −1); no upper.
1661        assert_eq!(qp.m_ineq(), 1);
1662        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1663        assert_eq!(sol.status, QpStatus::Optimal);
1664        assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
1665        let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
1666        assert!((lambda[0] - (-2.0)).abs() < 1e-5, "ineq dual={}", lambda[0]);
1667    }
1668
1669    /// Regression (M11): a *constraint* whose linear and constant
1670    /// terms are folded into the nonlinear tree (not the `con_linear`
1671    /// section) must still reach `A`/`G`. AMPL/Pyomo emit this shape for
1672    /// rows the classifier admits as degree-≤1 (cancelled quadratics,
1673    /// defined variables): the whole `x0 − 3` lives in `con_nonlinear`
1674    /// and `con_linear[0]` is empty.
1675    ///
1676    ///     min x0   s.t.   x0 − 3 ≥ 0     (body in the nonlinear tree)
1677    ///
1678    /// True optimum: x0 = 3. The QP extractor used to build `A`/`G` from
1679    /// `con_linear` only — dropping the folded `+x0` *and* the `−3`
1680    /// shift, leaving a vacuous `0 ≤ 0` row, so `min x0` came out
1681    /// unbounded (or otherwise wrong) on the convex path.
1682    #[test]
1683    fn constraint_linear_terms_folded_in_tree_are_recovered() {
1684        // con body = x0 − 3, entirely in the nonlinear tree.
1685        let con = Expr::Binary(
1686            BinOp::Sub,
1687            Box::new(Expr::Var(0)),
1688            Box::new(Expr::Const(3.0)),
1689        );
1690        let prob = NlProblem {
1691            src: None,
1692            cse_bodies: Vec::new(),
1693            n: 1,
1694            m: 1,
1695            num_obj: 1,
1696            minimize: true,
1697            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1698            obj_linear: vec![(0, 1.0)],
1699            obj_constant: 0.0,
1700            con_nonlinear: vec![NlBody::Tree(con)],
1701            con_linear: vec![vec![]], // the `+x0` lives in the TREE
1702            x_l: vec![-2e19],
1703            x_u: vec![2e19],
1704            g_l: vec![0.0], // x0 − 3 ≥ 0
1705            g_u: vec![2e19],
1706            x0: vec![0.0],
1707            lambda0: vec![0.0],
1708            suffixes: Default::default(),
1709            imported_funcs: Vec::new(),
1710            ampl_options: Vec::new(),
1711            nl_counts: None,
1712            n_discrete: None,
1713            var_names: Vec::new(),
1714            con_names: Vec::new(),
1715        };
1716        let (qp, con_map, _obj_const) =
1717            extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
1718        // One inequality row: −x0 ≤ −3 (the lower bound, constant-shifted).
1719        assert_eq!(qp.m_ineq(), 1);
1720        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1721        assert_eq!(sol.status, QpStatus::Optimal);
1722        assert!((sol.x[0] - 3.0).abs() < 1e-5, "x0={}", sol.x[0]);
1723        // Dual is recoverable and finite (the row carries a real coef now).
1724        let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
1725        assert_eq!(lambda.len(), 1);
1726        assert!(lambda[0].is_finite(), "dual={}", lambda[0]);
1727    }
1728
1729    /// Regression: a constant folded into the *nonlinear objective tree*
1730    /// (not the `obj_constant` field) must still reach the reported
1731    /// objective. This is the real `.nl` shape AMPL/Pyomo emit for
1732    /// `min (x0-3)^2` — the whole `x0^2 - 6 x0 + 9` lives in the nonlinear
1733    /// tree and `obj_constant == 0`. The convex path used to drop the `+9`
1734    /// and report an objective 9 too small (cf. HS35 in the benchmark
1735    /// comparison). The minimizer is x0 = 1 (upper bound binds), where the
1736    /// true objective is (1-3)^2 = 4.
1737    #[test]
1738    fn tree_embedded_objective_constant_is_recovered() {
1739        // (x0 - 3)^2 as a single nonlinear tree: Pow(Sub(x0, 3), 2).
1740        let obj = Expr::Binary(
1741            BinOp::Pow,
1742            Box::new(Expr::Binary(
1743                BinOp::Sub,
1744                Box::new(Expr::Var(0)),
1745                Box::new(Expr::Const(3.0)),
1746            )),
1747            Box::new(Expr::Const(2.0)),
1748        );
1749        let prob = NlProblem {
1750            src: None,
1751            cse_bodies: Vec::new(),
1752            n: 1,
1753            m: 0,
1754            num_obj: 1,
1755            minimize: true,
1756            obj_nonlinear: NlBody::Tree(obj),
1757            obj_linear: vec![],
1758            obj_constant: 0.0, // the +9 is in the TREE, not here
1759            con_nonlinear: vec![],
1760            con_linear: vec![],
1761            x_l: vec![0.0],
1762            x_u: vec![1.0],
1763            g_l: vec![],
1764            g_u: vec![],
1765            x0: vec![0.0],
1766            lambda0: vec![],
1767            suffixes: Default::default(),
1768            imported_funcs: Vec::new(),
1769            ampl_options: Vec::new(),
1770            nl_counts: None,
1771            n_discrete: None,
1772            var_names: Vec::new(),
1773            con_names: Vec::new(),
1774        };
1775        let (qp, _con_map, obj_const) =
1776            extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
1777        // The degree-0 term of (x0-3)^2 is +9, recovered from the tree.
1778        assert!((obj_const - 9.0).abs() < 1e-12, "tree constant={obj_const}");
1779        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1780        assert_eq!(sol.status, QpStatus::Optimal);
1781        assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
1782        // Reported objective = (½xᵀPx + cᵀx) + obj_const must equal the true
1783        // (1-3)^2 = 4, not the constant-dropped −5.
1784        let reported = sol.obj + obj_const;
1785        assert!((reported - 4.0).abs() < 1e-5, "reported obj={reported}");
1786    }
1787
1788    /// Bound-constrained: min (x0-3)^2 = x0^2 - 6 x0 + 9, 0 ≤ x0 ≤ 1.
1789    /// Optimum x0 = 1 (upper bound binds). Here the constant 9 is carried
1790    /// in the `obj_constant` field (not the tree), so the extracted tree
1791    /// constant is 0 (asserted inside).
1792    #[test]
1793    fn extract_and_solve_bounded_qp() {
1794        let prob = NlProblem {
1795            src: None,
1796            cse_bodies: Vec::new(),
1797            n: 1,
1798            m: 0,
1799            num_obj: 1,
1800            minimize: true,
1801            obj_nonlinear: NlBody::Tree(pow2(0)),
1802            obj_linear: vec![(0, -6.0)],
1803            obj_constant: 9.0,
1804            con_nonlinear: vec![],
1805            con_linear: vec![],
1806            x_l: vec![0.0],
1807            x_u: vec![1.0],
1808            g_l: vec![],
1809            g_u: vec![],
1810            x0: vec![0.0],
1811            lambda0: vec![],
1812            suffixes: Default::default(),
1813            imported_funcs: Vec::new(),
1814            ampl_options: Vec::new(),
1815            nl_counts: None,
1816            n_discrete: None,
1817            var_names: Vec::new(),
1818            con_names: Vec::new(),
1819        };
1820        let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
1821        // The bounds are the box, not `G` rows.
1822        assert_eq!(qp.m_ineq(), 0);
1823        assert_eq!((qp.lb[0], qp.ub[0]), (0.0, 1.0));
1824        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1825        assert_eq!(sol.status, QpStatus::Optimal);
1826        assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
1827    }
1828
1829    /// LP: min −x0 − x1, 0 ≤ x ≤ 1 → (1,1).
1830    #[test]
1831    fn extract_and_solve_lp() {
1832        let prob = NlProblem {
1833            src: None,
1834            cse_bodies: Vec::new(),
1835            n: 2,
1836            m: 0,
1837            num_obj: 1,
1838            minimize: true,
1839            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1840            obj_linear: vec![(0, -1.0), (1, -1.0)],
1841            obj_constant: 0.0,
1842            con_nonlinear: vec![],
1843            con_linear: vec![],
1844            x_l: vec![0.0, 0.0],
1845            x_u: vec![1.0, 1.0],
1846            g_l: vec![],
1847            g_u: vec![],
1848            x0: vec![0.0, 0.0],
1849            lambda0: vec![],
1850            suffixes: Default::default(),
1851            imported_funcs: Vec::new(),
1852            ampl_options: Vec::new(),
1853            nl_counts: None,
1854            n_discrete: None,
1855            var_names: Vec::new(),
1856            con_names: Vec::new(),
1857        };
1858        let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
1859        assert!(qp.p_lower.is_empty(), "LP has no Hessian");
1860        assert_eq!(qp.m_ineq(), 0, "bounds are the box, not `G` rows");
1861        assert_eq!(qp.lb, vec![0.0, 0.0]);
1862        assert_eq!(qp.ub, vec![1.0, 1.0]);
1863        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1864        assert_eq!(sol.status, QpStatus::Optimal);
1865        assert!((sol.x[0] - 1.0).abs() < 1e-6);
1866        assert!((sol.x[1] - 1.0).abs() < 1e-6);
1867    }
1868
1869    /// maximize x0 s.t. 0 ≤ x0 ≤ 5 → x0 = 5. Tests sign flip on a
1870    /// maximize objective.
1871    #[test]
1872    fn extract_maximize_negates() {
1873        let prob = NlProblem {
1874            src: None,
1875            cse_bodies: Vec::new(),
1876            n: 1,
1877            m: 0,
1878            num_obj: 1,
1879            minimize: false,
1880            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1881            obj_linear: vec![(0, 1.0)],
1882            obj_constant: 0.0,
1883            con_nonlinear: vec![],
1884            con_linear: vec![],
1885            x_l: vec![0.0],
1886            x_u: vec![5.0],
1887            g_l: vec![],
1888            g_u: vec![],
1889            x0: vec![0.0],
1890            lambda0: vec![],
1891            suffixes: Default::default(),
1892            imported_funcs: Vec::new(),
1893            ampl_options: Vec::new(),
1894            nl_counts: None,
1895            n_discrete: None,
1896            var_names: Vec::new(),
1897            con_names: Vec::new(),
1898        };
1899        let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
1900        // minimize −x0.
1901        assert_eq!(qp.c[0], -1.0);
1902        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1903        assert_eq!(sol.status, QpStatus::Optimal);
1904        assert!((sol.x[0] - 5.0).abs() < 1e-6, "x0={}", sol.x[0]);
1905    }
1906
1907    /// **gh #401.** A real bound past the *opposite* absent-bound sentinel is
1908    /// an ordinary bound, and must survive into `G`.
1909    ///
1910    /// `is_finite_bound` was `|v| < 1e19`, a symmetric magnitude test. An upper
1911    /// bound of `-5e20` failed it and the row `x_0 <= -5e20` never entered `G`,
1912    /// so the QP was solved over a strictly larger box — `min x_0` subject to
1913    /// nothing, which the IPM answers `Optimal` at a point the model excludes.
1914    #[test]
1915    fn variable_bound_past_the_opposite_sentinel_is_kept() {
1916        let prob = NlProblem {
1917            src: None,
1918            cse_bodies: Vec::new(),
1919            n: 1,
1920            m: 0,
1921            num_obj: 1,
1922            minimize: false, // maximize x0, so the -5e20 upper bound binds
1923            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1924            obj_linear: vec![(0, 1.0)],
1925            obj_constant: 0.0,
1926            con_nonlinear: vec![],
1927            con_linear: vec![],
1928            // No lower bound (`-1e21` is past the lower sentinel, so absent);
1929            // a real upper bound of `-5e20`, which is *not*.
1930            x_l: vec![-1e21],
1931            x_u: vec![-5e20],
1932            g_l: vec![],
1933            g_u: vec![],
1934            x0: vec![-7e20],
1935            lambda0: vec![],
1936            suffixes: Default::default(),
1937            imported_funcs: Vec::new(),
1938            ampl_options: Vec::new(),
1939            nl_counts: None,
1940            n_discrete: None,
1941            var_names: Vec::new(),
1942            con_names: Vec::new(),
1943        };
1944        let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
1945        assert_eq!(
1946            qp.ub[0], -5e20,
1947            "`x0 <= -5e20` is a real bound and must reach the box; the \
1948             symmetric |v| < 1e19 test dropped it, leaving an \
1949             unbounded-above box the model does not declare"
1950        );
1951    }
1952
1953    /// **gh #401.** A row with equal bounds past the sentinel used to vanish
1954    /// from the problem *entirely* — contributing nothing to `A` and nothing
1955    /// to `G`.
1956    ///
1957    /// Directionally, `g_l = g_u = -5e20` is not an equality at all: the lower
1958    /// bound is absent (it is past `-1e19`) and the upper bound is real, so the
1959    /// row is the one-sided `x0 + x1 <= -5e20`. The old code got there by a
1960    /// different route and lost it: `lo == hi && is_finite_bound(lo)` failed, so
1961    /// the row fell into the inequality branch — where `is_finite_bound(hi)` and
1962    /// `is_finite_bound(lo)` were false too, leaving `upper` and `lower` both
1963    /// `None`. Silently deleted.
1964    ///
1965    /// (Note there is no such thing as an equality row outside `±1e19` under
1966    /// this convention: an equality needs both bounds present, and the two
1967    /// presence tests only overlap inside the band.)
1968    #[test]
1969    fn a_row_with_equal_bounds_past_the_sentinel_does_not_vanish() {
1970        let prob = NlProblem {
1971            src: None,
1972            cse_bodies: Vec::new(),
1973            n: 2,
1974            m: 1,
1975            num_obj: 1,
1976            minimize: true,
1977            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1978            obj_linear: vec![(0, 1.0)],
1979            obj_constant: 0.0,
1980            con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
1981            con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
1982            x_l: vec![-2e19, -2e19],
1983            x_u: vec![2e19, 2e19],
1984            g_l: vec![-5e20],
1985            g_u: vec![-5e20],
1986            x0: vec![0.0, 0.0],
1987            lambda0: vec![0.0],
1988            suffixes: Default::default(),
1989            imported_funcs: Vec::new(),
1990            ampl_options: Vec::new(),
1991            nl_counts: None,
1992            n_discrete: None,
1993            var_names: Vec::new(),
1994            con_names: Vec::new(),
1995        };
1996        let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
1997        assert_eq!(
1998            qp.m_eq(),
1999            0,
2000            "the lower bound is absent, so this is no equality"
2001        );
2002        assert_eq!(
2003            qp.m_ineq(),
2004            1,
2005            "`x0 + x1 <= -5e20` is a real constraint and must reach G; it used \
2006             to disappear from the problem entirely"
2007        );
2008        assert_eq!(qp.h[0], -5e20);
2009    }
2010
2011    /// **gh #401.** The box is built with the *directional* presence test, so
2012    /// a bound past the opposite sentinel survives while a genuinely absent
2013    /// one becomes `∓∞`. Pins the case only the directional reading admits.
2014    ///
2015    /// This used to assert instead that `recover_bound_mults` walked the same
2016    /// `G`-row layout the builder emitted — a real hazard when the two agreed
2017    /// only by construction, and one that no longer exists: bounds are the
2018    /// box, and their multipliers come back in `z_lb`/`z_ub` with no layout
2019    /// to keep in step.
2020    #[test]
2021    fn the_box_is_built_with_the_directional_bound_test() {
2022        let prob = NlProblem {
2023            src: None,
2024            cse_bodies: Vec::new(),
2025            n: 2,
2026            m: 0,
2027            num_obj: 1,
2028            minimize: true,
2029            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2030            obj_linear: vec![(0, 1.0), (1, 1.0)],
2031            obj_constant: 0.0,
2032            con_nonlinear: vec![],
2033            con_linear: vec![],
2034            // x0: upper bound past the *lower* sentinel, no lower bound.
2035            // x1: an ordinary two-sided box.
2036            x_l: vec![-2e19, 0.0],
2037            x_u: vec![-5e20, 1.0],
2038            g_l: vec![],
2039            g_u: vec![],
2040            x0: vec![-6e20, 0.5],
2041            lambda0: vec![],
2042            suffixes: Default::default(),
2043            imported_funcs: Vec::new(),
2044            ampl_options: Vec::new(),
2045            nl_counts: None,
2046            n_discrete: None,
2047            var_names: Vec::new(),
2048            con_names: Vec::new(),
2049        };
2050        let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
2051        assert_eq!(qp.m_ineq(), 0, "bounds are the box, not `G` rows");
2052        // x0: no lower bound; a real upper bound past the *lower* sentinel.
2053        assert_eq!(qp.lb[0], f64::NEG_INFINITY);
2054        assert_eq!(qp.ub[0], -5e20);
2055        // x1: an ordinary two-sided box, carried through unchanged.
2056        assert_eq!(qp.lb[1], 0.0);
2057        assert_eq!(qp.ub[1], 1.0);
2058    }
2059
2060    /// The bound multipliers a solve produces are handed back per variable,
2061    /// not decoded from a row layout. A short `z_lb`/`z_ub` (a driver that
2062    /// returned early without them) reads as "no bound active" rather than
2063    /// panicking on the index.
2064    #[test]
2065    fn bound_multipliers_come_back_per_variable() {
2066        let prob = NlProblem {
2067            src: None,
2068            cse_bodies: Vec::new(),
2069            n: 2,
2070            m: 0,
2071            num_obj: 1,
2072            minimize: true,
2073            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2074            obj_linear: vec![(0, 1.0), (1, 1.0)],
2075            obj_constant: 0.0,
2076            con_nonlinear: vec![],
2077            con_linear: vec![],
2078            x_l: vec![0.0, 0.0],
2079            x_u: vec![1.0, 1.0],
2080            g_l: vec![],
2081            g_u: vec![],
2082            x0: vec![0.5, 0.5],
2083            lambda0: vec![],
2084            suffixes: Default::default(),
2085            imported_funcs: Vec::new(),
2086            ampl_options: Vec::new(),
2087            nl_counts: None,
2088            n_discrete: None,
2089            var_names: Vec::new(),
2090            con_names: Vec::new(),
2091        };
2092        let sol = QpSolution {
2093            status: pounce_convex::QpStatus::Optimal,
2094            x: vec![0.0, 1.0],
2095            y: vec![],
2096            z: vec![],
2097            z_lb: vec![7.0, 0.0],
2098            z_ub: vec![0.0, 9.0],
2099            obj: 0.0,
2100            iters: 0,
2101            iterates: Vec::new(),
2102        };
2103        let (z_lb, z_ub) = recover_bound_mults(&prob, &sol);
2104        assert_eq!(z_lb, vec![7.0, 0.0]);
2105        assert_eq!(z_ub, vec![0.0, 9.0]);
2106
2107        let empty = QpSolution {
2108            z_lb: Vec::new(),
2109            z_ub: Vec::new(),
2110            ..sol
2111        };
2112        let (z_lb, z_ub) = recover_bound_mults(&prob, &empty);
2113        assert_eq!(z_lb, vec![0.0, 0.0]);
2114        assert_eq!(z_ub, vec![0.0, 0.0]);
2115    }
2116
2117    /// gh #744/#745: `bound_relax_factor` reaches the extracted model.
2118    ///
2119    /// One inequality row (`x0 + x1 >= 2`), one two-sided range row, one
2120    /// equality row, a bounded variable, a fixed variable, and a free
2121    /// variable — so every case the widening treats differently is present.
2122    fn relax_fixture() -> NlProblem {
2123        NlProblem {
2124            src: None,
2125            cse_bodies: Vec::new(),
2126            n: 3,
2127            m: 3,
2128            num_obj: 1,
2129            minimize: true,
2130            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2131            obj_linear: vec![(0, 1.0)],
2132            obj_constant: 0.0,
2133            con_nonlinear: vec![
2134                NlBody::Tree(Expr::Const(0.0)),
2135                NlBody::Tree(Expr::Const(0.0)),
2136                NlBody::Tree(Expr::Const(0.0)),
2137            ],
2138            con_linear: vec![
2139                vec![(0, 1.0), (1, 1.0)],
2140                vec![(1, 1.0), (2, 1.0)],
2141                vec![(0, 1.0), (2, 1.0)],
2142            ],
2143            // x0 bounded above and below, x1 fixed, x2 free.
2144            x_l: vec![-4.0, 5.0, -2e19],
2145            x_u: vec![8.0, 5.0, 2e19],
2146            // row 0: >= 2 (lower only); row 1: -3 <= . <= 6 (range);
2147            // row 2: == 7 (equality).
2148            g_l: vec![2.0, -3.0, 7.0],
2149            g_u: vec![2e19, 6.0, 7.0],
2150            x0: vec![0.0, 0.0, 0.0],
2151            lambda0: vec![0.0, 0.0, 0.0],
2152            suffixes: Default::default(),
2153            imported_funcs: Vec::new(),
2154            ampl_options: Vec::new(),
2155            nl_counts: None,
2156            n_discrete: None,
2157            var_names: Vec::new(),
2158            con_names: Vec::new(),
2159        }
2160    }
2161
2162    #[test]
2163    fn bound_relax_none_leaves_the_declared_model_alone() {
2164        let prob = relax_fixture();
2165        let (qp, _, _) = extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
2166        assert_eq!(qp.lb, vec![-4.0, 5.0, f64::NEG_INFINITY]);
2167        assert_eq!(qp.ub, vec![8.0, 5.0, f64::INFINITY]);
2168        assert_eq!(qp.b, vec![7.0]);
2169        // Rows, in emission order: row0's `>= 2` as `-x0-x1 <= -2`;
2170        // row1's `<= 6` then its `>= -3` as `<= 3`.
2171        assert_eq!(qp.h, vec![-2.0, 6.0, 3.0]);
2172    }
2173
2174    #[test]
2175    fn bound_relax_widens_inequality_rows_and_the_free_box_only() {
2176        let prob = relax_fixture();
2177        let relax = BoundRelax {
2178            factor: 1e-8,
2179            cap: 1e-4,
2180        };
2181        let (qp, _, _) = extract_qp_with_map(&prob, relax).expect("extract");
2182
2183        // Variable box: upstream's absolute formula `min(f*max(|b|,1), cap)`.
2184        // x0's bounds widen outward; x1 is *fixed* and must not move (upstream
2185        // removes fixed variables before `relax_bounds` runs); x2 is free.
2186        assert!((qp.lb[0] - (-4.0 - 4e-8)).abs() < 1e-18);
2187        assert!((qp.ub[0] - (8.0 + 8e-8)).abs() < 1e-18);
2188        assert_eq!(qp.lb[1], 5.0);
2189        assert_eq!(qp.ub[1], 5.0);
2190        assert_eq!(qp.lb[2], f64::NEG_INFINITY);
2191        assert_eq!(qp.ub[2], f64::INFINITY);
2192
2193        // Equality rows are never relaxed — upstream keeps them in `c(x) = 0`,
2194        // which `relax_bounds` does not touch.
2195        assert_eq!(qp.b, vec![7.0]);
2196
2197        // Inequality rows use the scale-relative width `min(f, cap)*|b|`.
2198        // `x0+x1 >= 2` → `-x0-x1 <= -(2 - 2e-8)`.
2199        assert!((qp.h[0] - -(2.0 - 2e-8)).abs() < 1e-18);
2200        // `. <= 6` → `<= 6 + 6e-8`; `. >= -3` → `<= 3 + 3e-8`.
2201        assert!((qp.h[1] - (6.0 + 6e-8)).abs() < 1e-18);
2202        assert!((qp.h[2] - (3.0 + 3e-8)).abs() < 1e-18);
2203    }
2204
2205    #[test]
2206    fn bound_relax_caps_the_widening_and_floors_a_zero_row_bound() {
2207        let mut prob = relax_fixture();
2208        // A huge row bound: the relative width `min(f, cap)*|b|` would be
2209        // enormous without the `min` against `cap` in the *factor*.
2210        prob.g_l[0] = 0.0; // declared-zero bound: no scale, absolute width.
2211        let relax = BoundRelax {
2212            factor: 1e-2,
2213            cap: 1e-4,
2214        };
2215        let (qp, _, _) = extract_qp_with_map(&prob, relax).expect("extract");
2216        // Zero bound → width is `min(1e-2, 1e-4) * 1 = 1e-4`.
2217        assert!((qp.h[0] - 1e-4).abs() < 1e-18, "{}", qp.h[0]);
2218        // Variable box is capped by `cap` outright: `1e-2*max(4,1) = 4e-2`,
2219        // capped to `1e-4`.
2220        assert!((qp.lb[0] - (-4.0 - 1e-4)).abs() < 1e-18);
2221    }
2222
2223    /// An empty declared set must survive extraction empty. Relaxation runs
2224    /// *after* upstream's consistency check, and the emptiness screens on the
2225    /// convex side read the extracted `lb`/`ub` and row pairs — so widening a
2226    /// crossing narrower than the relaxation would silently make an
2227    /// inconsistent model solvable (gh #491, gh #744).
2228    #[test]
2229    fn bound_relax_does_not_close_a_crossed_box_or_a_crossed_row() {
2230        let mut prob = relax_fixture();
2231        // x0's box crossed by 1e-8, narrower than the 2*4e-8 it would widen by.
2232        prob.x_l[0] = 0.0;
2233        prob.x_u[0] = -1e-8;
2234        // Row 1 crossed by 1e-8 too: `1e-8 <= x1 + x2 <= 0`.
2235        prob.g_l[1] = 1e-8;
2236        prob.g_u[1] = 0.0;
2237        let relax = BoundRelax {
2238            factor: 1e-8,
2239            cap: 1e-4,
2240        };
2241        let (qp, _, _) = extract_qp_with_map(&prob, relax).expect("extract");
2242        assert_eq!(qp.lb[0], 0.0);
2243        assert_eq!(qp.ub[0], -1e-8);
2244        // Row 1's pair: `<= 0` and `>= 1e-8` (as `<= -1e-8`), both verbatim.
2245        assert_eq!(qp.h[1], 0.0);
2246        assert_eq!(qp.h[2], -1e-8);
2247        // The uncrossed row 0 is still widened.
2248        assert!((qp.h[0] - -(2.0 - 2e-8)).abs() < 1e-18);
2249    }
2250}