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, incl. finite var bounds)
12//! ```
13//!
14//! Mapping from the `.nl` representation:
15//! - **Objective.** `P` is the Hessian of the (degree-≤2) objective —
16//!   recovered with the same `analyze_quadratic` the classifier uses, so
17//!   `P` here is exactly the matrix whose definiteness was tested. `c`
18//!   is the objective's linear part. A `maximize` objective is negated
19//!   into a minimization.
20//! - **Constraints.** Each row has a linear part and bounds `g_l ≤ row ≤
21//!   g_u`. An equality (`g_l == g_u`) becomes a row of `A`; a one- or
22//!   two-sided inequality becomes one or two rows of `G` (`row ≤ g_u`
23//!   and/or `−row ≤ −g_l`).
24//! - **Variable bounds.** Finite `x_l`/`x_u` become `G` rows
25//!   (`−x_i ≤ −x_l`, `x_i ≤ x_u`); the `.nl` "infinity" sentinel
26//!   (`|v| ≥ 1e19`) is treated as no bound.
27
28use crate::dispatch::analyze_quadratic_full;
29use crate::nl_reader::NlProblem;
30use pounce_convex::{ConeSpec, QpProblem, Triplet};
31
32/// The `.nl` infinity sentinel: AMPL writes ±1e20-ish for "no bound";
33/// upstream Ipopt treats anything with magnitude ≥ 1e19 as infinite.
34const NL_INF: f64 = 1e19;
35
36fn is_finite_bound(v: f64) -> bool {
37    v.abs() < NL_INF
38}
39
40/// Convert a classified LP/convex-QP `NlProblem` into `QpProblem`
41/// standard form. Returns `None` if the objective is not actually a
42/// degree-≤2 polynomial (should not happen for a problem the classifier
43/// routed here, but the conversion is total and falls back gracefully).
44pub fn extract_qp(prob: &NlProblem) -> Option<QpProblem> {
45    Some(extract_qp_with_map(prob)?.0) // drops con_map + reporting constant
46}
47
48/// Where each `.nl` constraint's rows landed in the standard-form QP, so
49/// the QP's multipliers can be mapped back to a per-`.nl`-constraint
50/// dual for the `.sol`. One entry per original constraint, in order.
51#[derive(Debug, Clone)]
52pub enum ConRowMap {
53    /// Equality constraint → row `a_row` of `A` (multiplier `y[a_row]`).
54    Eq { a_row: usize },
55    /// Inequality / range constraint → up to two rows of `G`: the
56    /// `row ≤ g_u` upper bound and/or the `−row ≤ −g_l` lower bound
57    /// (multipliers `z[..]`, each ≥ 0).
58    Ineq {
59        upper: Option<usize>,
60        lower: Option<usize>,
61    },
62}
63
64/// Extract the QP, the constraint→row provenance map, and the objective
65/// constant folded into the nonlinear tree (see below), together.
66///
67/// The third return value is the **degree-0 term of the nonlinear
68/// objective** (e.g. the `+9` of `(x₀−3)²` that AMPL/Pyomo emit inside the
69/// nonlinear tree rather than in `NlProblem::obj_constant`). The QP itself
70/// ignores it — it does not move the minimizer — but the caller must add
71/// it to the *reported* objective so the convex solve agrees with the NLP
72/// path. It is returned in the problem's natural (user) sense, *not*
73/// multiplied by the maximize/minimize `sign`.
74pub fn extract_qp_with_map(prob: &NlProblem) -> Option<(QpProblem, Vec<ConRowMap>, f64)> {
75    let n = prob.n;
76    let sign = if prob.minimize { 1.0 } else { -1.0 };
77
78    // --- objective Hessian P (lower triangle) + nonlinear-tree linear part
79    //     + nonlinear-tree constant (degree-0 term, for reporting only) ---
80    let (hess, obj_nl_linear, obj_nl_constant) = analyze_quadratic_full(&prob.obj_nonlinear, n)?;
81    let mut p_lower: Vec<Triplet> = Vec::with_capacity(hess.len());
82    for ((i, j), v) in &hess {
83        // analyze_quadratic returns (i ≤ j) upper-ish keys; store as
84        // lower triangle (row ≥ col) for the solver.
85        let (row, col) = if i >= j { (*i, *j) } else { (*j, *i) };
86        p_lower.push(Triplet::new(row, col, sign * v));
87    }
88
89    // --- objective linear term c ---
90    // Two disjoint sources, exactly as the NLP path's eval_f sums them:
91    // the `.nl` linear section (`obj_linear`) and the degree-1 terms AMPL
92    // kept inside the nonlinear objective tree (e.g. the `−6·x₀` of
93    // `(x₀−3)²`). Dropping the latter silently solves the wrong objective.
94    let mut c = vec![0.0; n];
95    for (var, coef) in &prob.obj_linear {
96        c[*var] += sign * coef;
97    }
98    for (var, coef) in &obj_nl_linear {
99        c[*var] += sign * coef;
100    }
101
102    // --- constraints: equalities → A x = b, inequalities → G x ≤ h ---
103    let mut a: Vec<Triplet> = Vec::new();
104    let mut b: Vec<f64> = Vec::new();
105    let mut g: Vec<Triplet> = Vec::new();
106    let mut h: Vec<f64> = Vec::new();
107    let mut con_map: Vec<ConRowMap> = Vec::with_capacity(prob.con_linear.len());
108
109    for (row, lin) in prob.con_linear.iter().enumerate() {
110        let lo = prob.g_l[row];
111        let hi = prob.g_u[row];
112
113        // Combine the `.nl` linear section with any degree-≤1 terms AMPL
114        // folded into the (here empty-Hessian) nonlinear tree — the
115        // classifier admits constraint rows whose nonlinear expression
116        // reduces to degree ≤ 1 (`dispatch.rs`), e.g. cancelled
117        // quadratics or defined variables, and those linear/constant
118        // terms live in `con_nonlinear`, not `con_linear`. Dropping them
119        // silently solves the wrong constraint. The folded constant
120        // shifts the bounds: `g_l ≤ row + k ≤ g_u  ⇔  g_l−k ≤ row ≤ g_u−k`.
121        // This mirrors the SOCP extractor's linear-constraint handling.
122        let (nl_lin, const_shift) = analyze_quadratic_full(&prob.con_nonlinear[row], n)
123            .map(|(_, l, k)| (l, k))
124            .unwrap_or_default();
125        let mut coef = vec![0.0; n];
126        for (var, v) in lin {
127            coef[*var] += *v;
128        }
129        for (var, v) in &nl_lin {
130            coef[*var] += *v;
131        }
132        let nonzeros = || coef.iter().enumerate().filter(|(_, v)| **v != 0.0);
133
134        if lo == hi && is_finite_bound(lo) {
135            // Equality row.
136            let eq_row = next_row(&b);
137            for (var, v) in nonzeros() {
138                a.push(Triplet::new(eq_row, var, *v));
139            }
140            b.push(lo - const_shift);
141            con_map.push(ConRowMap::Eq { a_row: eq_row });
142        } else {
143            // Upper bound: row ≤ hi.
144            let upper = if is_finite_bound(hi) {
145                let gr = next_row(&h);
146                for (var, v) in nonzeros() {
147                    g.push(Triplet::new(gr, var, *v));
148                }
149                h.push(hi - const_shift);
150                Some(gr)
151            } else {
152                None
153            };
154            // Lower bound: row ≥ lo  ⇔  −row ≤ −lo.
155            let lower = if is_finite_bound(lo) {
156                let gr = next_row(&h);
157                for (var, v) in nonzeros() {
158                    g.push(Triplet::new(gr, var, -*v));
159                }
160                h.push(-(lo - const_shift));
161                Some(gr)
162            } else {
163                None
164            };
165            con_map.push(ConRowMap::Ineq { upper, lower });
166        }
167    }
168
169    // --- variable bounds as G rows (not part of the constraint map) ---
170    for i in 0..n {
171        let xl = prob.x_l[i];
172        let xu = prob.x_u[i];
173        if is_finite_bound(xu) {
174            let gr = next_row(&h);
175            g.push(Triplet::new(gr, i, 1.0)); // x_i ≤ xu
176            h.push(xu);
177        }
178        if is_finite_bound(xl) {
179            let gr = next_row(&h);
180            g.push(Triplet::new(gr, i, -1.0)); // −x_i ≤ −xl
181            h.push(-xl);
182        }
183    }
184
185    Some((
186        QpProblem {
187            n,
188            p_lower,
189            c,
190            a,
191            b,
192            g,
193            h,
194            // Variable bounds are currently emitted as `G` rows (see the
195            // bound-handling above), so the explicit box is left empty.
196            lb: Vec::new(),
197            ub: Vec::new(),
198        },
199        con_map,
200        obj_nl_constant,
201    ))
202}
203
204/// Map the QP solver's multipliers `(y, z)` back to a per-`.nl`-
205/// constraint dual vector (length `prob.m`), in the AMPL `.sol`
206/// convention used by POUNCE's NLP path.
207///
208/// The QP solver enforces stationarity `∇f + Aᵀy + Gᵀz = 0` with
209/// `z ≥ 0`, where each inequality `.nl` row contributes a `row ≤ g_u`
210/// (`+row`) and/or `−row ≤ −g_l` (`−row`) `G` row. The per-constraint
211/// `.nl`/Ipopt multiplier `λ` is recovered as:
212/// - equality: `λ = sign · y[a_row]`;
213/// - inequality: `λ = sign · (z_upper − z_lower)` — at most one of the
214///   two bound rows is active at a solution.
215///
216/// The inequality sign (`z_upper − z_lower`, *not* `z_lower − z_upper`)
217/// is fixed to match POUNCE's NLP path, which is the reference for what
218/// a POUNCE `.sol` carries; this is verified empirically against the NLP
219/// solve in the crate tests. `sign` undoes the maximize→minimize
220/// negation so the reported dual is in the user's original sense.
221pub fn recover_duals(prob: &NlProblem, con_map: &[ConRowMap], y: &[f64], z: &[f64]) -> Vec<f64> {
222    let sign = if prob.minimize { 1.0 } else { -1.0 };
223    con_map
224        .iter()
225        .map(|m| match m {
226            ConRowMap::Eq { a_row } => sign * y[*a_row],
227            ConRowMap::Ineq { upper, lower } => {
228                let zu = upper.map(|r| z[r]).unwrap_or(0.0);
229                let zl = lower.map(|r| z[r]).unwrap_or(0.0);
230                sign * (zu - zl)
231            }
232        })
233        .collect()
234}
235
236/// The next 0-based row index for a constraint block keyed by its RHS
237/// vector's current length.
238fn next_row(rhs: &[f64]) -> usize {
239    rhs.len()
240}
241
242// ===========================================================================
243// QCQP → SOCP extraction
244// ===========================================================================
245
246/// Where each `.nl` constraint landed in the standard-form **conic** program,
247/// so the cone multipliers can be mapped back to a per-`.nl`-constraint dual.
248/// One entry per original constraint, in order. (Analogue of [`ConRowMap`] for
249/// the SOCP path produced by [`extract_socp_with_map`].)
250#[derive(Debug, Clone)]
251pub enum ConSocpMap {
252    /// Linear equality → row `a_row` of `A` (multiplier `y[a_row]`).
253    Eq { a_row: usize },
254    /// Linear inequality / range → up to two rows of the nonnegative `G`
255    /// block (`row ≤ g_u` and/or `−row ≤ −g_l`), multipliers `z[..] ≥ 0`.
256    Ineq {
257        upper: Option<usize>,
258        lower: Option<usize>,
259    },
260    /// Convex quadratic inequality `g(x) ≤ g_u`, reformulated to one
261    /// second-order cone. The first two cone rows both carry the linear
262    /// coefficient vector `a = ∇(linear part)`, so the original constraint
263    /// multiplier is recovered as `z[r0] + z[r1]` (see
264    /// [`recover_socp_duals`]).
265    Quad { z_row0: usize, z_row1: usize },
266}
267
268/// A deferred second-order-cone block, built after the nonnegative `G` rows
269/// are known so the cones partition `G` in row order (nonneg block first,
270/// then the SOCs).
271struct SocBlock {
272    /// Index in `con_map` of the originating constraint, to patch with the
273    /// final cone-row indices once they are assigned.
274    con_idx: usize,
275    /// Linear coefficient vector `a` of the constraint (length `n`).
276    a: Vec<f64>,
277    /// `b_eff = (nonlinear constant) − g_u`, the constraint's degree-0 term
278    /// after moving the upper bound to the right: `½xᵀQx + aᵀx + b_eff ≤ 0`.
279    b_eff: f64,
280    /// Rows of the factor `F` (each length `n`) with `FᵀF = Q`; `‖Fx‖² = xᵀQx`.
281    f_rows: Vec<Vec<f64>>,
282}
283
284/// Convert a classified **convex QCQP** `NlProblem` into the conic standard
285/// form the SOCP IPM consumes:
286///
287/// ```text
288/// minimize    ½ xᵀP x + cᵀx
289/// subject to  A x = b
290///             h − G x  ∈  K        (K = nonneg orthant × second-order cones)
291/// ```
292///
293/// Returns `(QpProblem, con_map, obj_nl_constant, cones)`:
294/// - the objective `P`/`c` exactly as the LP/QP path builds them;
295/// - linear equalities in `A`/`b`; linear inequalities and finite variable
296///   bounds as a leading **nonnegative** `G` block; and each convex quadratic
297///   inequality `g(x) ≤ g_u` as one **second-order cone** block appended
298///   after it (so `cones` covers the `G` rows in order);
299/// - `con_map` mapping each original constraint to its rows for dual recovery;
300/// - `obj_nl_constant`, the objective's folded degree-0 term (added back to the
301///   reported value, exactly as in [`extract_qp_with_map`]).
302///
303/// `None` if the objective is not degree-≤2 (should not happen for a problem
304/// the classifier routed here). The reformulation of a convex quadratic
305/// `½xᵀQx + aᵀx + b_eff ≤ 0` (with `Q = FᵀF ⪰ 0`) is the standard rotated→
306/// standard SOC: writing `s = −(aᵀx + b_eff)`, the cone slack
307/// `(s+1, s−1, √2·Fx)` lies in the second-order cone iff `‖Fx‖² ≤ 2s`, i.e.
308/// iff the original constraint holds.
309pub fn extract_socp_with_map(
310    prob: &NlProblem,
311) -> Option<(QpProblem, Vec<ConSocpMap>, f64, Vec<ConeSpec>)> {
312    let n = prob.n;
313    let sign = if prob.minimize { 1.0 } else { -1.0 };
314
315    // --- objective P (lower triangle) + folded linear / constant terms ---
316    let (hess, obj_nl_linear, obj_nl_constant) = analyze_quadratic_full(&prob.obj_nonlinear, n)?;
317    let mut p_lower: Vec<Triplet> = Vec::with_capacity(hess.len());
318    for ((i, j), v) in &hess {
319        let (row, col) = if i >= j { (*i, *j) } else { (*j, *i) };
320        p_lower.push(Triplet::new(row, col, sign * v));
321    }
322    let mut c = vec![0.0; n];
323    for (var, coef) in &prob.obj_linear {
324        c[*var] += sign * coef;
325    }
326    for (var, coef) in &obj_nl_linear {
327        c[*var] += sign * coef;
328    }
329
330    // --- constraints: equalities → A; linear ineqs → nonneg G block;
331    //     convex quadratics → deferred SOC blocks (added after the nonneg
332    //     rows so the cones partition G in row order) ---
333    let mut a: Vec<Triplet> = Vec::new();
334    let mut b: Vec<f64> = Vec::new();
335    let mut g: Vec<Triplet> = Vec::new();
336    let mut h: Vec<f64> = Vec::new();
337    let mut con_map: Vec<ConSocpMap> = Vec::with_capacity(prob.m);
338    let mut soc_blocks: Vec<SocBlock> = Vec::new();
339
340    for (row, lin) in prob.con_linear.iter().enumerate() {
341        let lo = prob.g_l[row];
342        let hi = prob.g_u[row];
343        let nl = &prob.con_nonlinear[row];
344        let quad = analyze_quadratic_full(nl, n);
345        let is_quadratic = matches!(&quad, Some((hmap, _, _)) if !hmap.is_empty());
346
347        if is_quadratic {
348            // Convex quadratic inequality `g(x) ≤ g_u` (the classifier
349            // guarantees an upper-only bound with PSD Hessian). Build the
350            // factor F (FᵀF = Q) and defer the SOC rows.
351            let (hmap, nl_lin, nl_const) = quad.expect("checked above");
352            // Full linear coefficient vector a = linear-section + folded
353            // nonlinear-tree linear part.
354            let mut a_vec = vec![0.0; n];
355            for (var, coef) in lin {
356                a_vec[*var] += *coef;
357            }
358            for (var, coef) in &nl_lin {
359                a_vec[*var] += *coef;
360            }
361            let dense = dense_symmetric(&hmap, n);
362            let f_rows = psd_outer_factor(dense, n);
363            let con_idx = con_map.len();
364            con_map.push(ConSocpMap::Quad {
365                z_row0: 0,
366                z_row1: 0,
367            }); // patched in the SOC pass below
368            soc_blocks.push(SocBlock {
369                con_idx,
370                a: a_vec,
371                b_eff: nl_const - hi,
372                f_rows,
373            });
374            continue;
375        }
376
377        // Linear constraint. Combine the `.nl` linear section with any
378        // degree-≤1 terms AMPL folded into the (here empty-Hessian)
379        // nonlinear tree, and shift the bounds by the folded constant.
380        let (nl_lin, const_shift) = quad.map(|(_, l, k)| (l, k)).unwrap_or_default();
381        let mut coef = vec![0.0; n];
382        for (var, v) in lin {
383            coef[*var] += *v;
384        }
385        for (var, v) in &nl_lin {
386            coef[*var] += *v;
387        }
388        let nonzeros = || coef.iter().enumerate().filter(|(_, v)| **v != 0.0);
389        if lo == hi && is_finite_bound(lo) {
390            let eq_row = next_row(&b);
391            for (var, v) in nonzeros() {
392                a.push(Triplet::new(eq_row, var, *v));
393            }
394            b.push(lo - const_shift);
395            con_map.push(ConSocpMap::Eq { a_row: eq_row });
396        } else {
397            let upper = if is_finite_bound(hi) {
398                let gr = next_row(&h);
399                for (var, v) in nonzeros() {
400                    g.push(Triplet::new(gr, var, *v));
401                }
402                h.push(hi - const_shift);
403                Some(gr)
404            } else {
405                None
406            };
407            let lower = if is_finite_bound(lo) {
408                let gr = next_row(&h);
409                for (var, v) in nonzeros() {
410                    g.push(Triplet::new(gr, var, -*v));
411                }
412                h.push(-(lo - const_shift));
413                Some(gr)
414            } else {
415                None
416            };
417            con_map.push(ConSocpMap::Ineq { upper, lower });
418        }
419    }
420
421    // --- variable bounds as nonnegative G rows (not in the constraint map) ---
422    for i in 0..n {
423        let xl = prob.x_l[i];
424        let xu = prob.x_u[i];
425        if is_finite_bound(xu) {
426            let gr = next_row(&h);
427            g.push(Triplet::new(gr, i, 1.0));
428            h.push(xu);
429        }
430        if is_finite_bound(xl) {
431            let gr = next_row(&h);
432            g.push(Triplet::new(gr, i, -1.0));
433            h.push(-xl);
434        }
435    }
436
437    // The nonnegative block is every G row built so far. The cones list must
438    // cover G in row order: this orthant block, then one SOC per quadratic.
439    let num_nonneg = h.len();
440    let mut cones: Vec<ConeSpec> = Vec::with_capacity(1 + soc_blocks.len());
441    if num_nonneg > 0 {
442        cones.push(ConeSpec::Nonneg(num_nonneg));
443    }
444
445    // --- emit the deferred second-order cones ---
446    for blk in soc_blocks {
447        let r = blk.f_rows.len();
448        let dim = r + 2;
449        let row0 = next_row(&h);
450        // s0 = (1 − b_eff) − aᵀx  →  G row = a, h = 1 − b_eff.
451        for (var, &coef) in blk.a.iter().enumerate() {
452            if coef != 0.0 {
453                g.push(Triplet::new(row0, var, coef));
454            }
455        }
456        h.push(1.0 - blk.b_eff);
457        let row1 = next_row(&h);
458        // s1 = −(1 + b_eff) − aᵀx  →  G row = a, h = −(1 + b_eff).
459        for (var, &coef) in blk.a.iter().enumerate() {
460            if coef != 0.0 {
461                g.push(Triplet::new(row1, var, coef));
462            }
463        }
464        h.push(-(1.0 + blk.b_eff));
465        // s_{2+k} = √2·(Fx)_k  →  G row = −√2·F_k, h = 0.
466        let sqrt2 = std::f64::consts::SQRT_2;
467        for f in &blk.f_rows {
468            let gr = next_row(&h);
469            for (var, &fv) in f.iter().enumerate() {
470                if fv != 0.0 {
471                    g.push(Triplet::new(gr, var, -sqrt2 * fv));
472                }
473            }
474            h.push(0.0);
475        }
476        cones.push(ConeSpec::SecondOrder(dim));
477        con_map[blk.con_idx] = ConSocpMap::Quad {
478            z_row0: row0,
479            z_row1: row1,
480        };
481    }
482
483    Some((
484        QpProblem {
485            n,
486            p_lower,
487            c,
488            a,
489            b,
490            g,
491            h,
492            lb: Vec::new(),
493            ub: Vec::new(),
494        },
495        con_map,
496        obj_nl_constant,
497        cones,
498    ))
499}
500
501/// Map the SOCP solver's multipliers `(y, z)` back to a per-`.nl`-constraint
502/// dual vector (length `prob.m`), in POUNCE's NLP-path `.sol` convention.
503///
504/// Linear rows reuse the QP-path recovery (`y[a_row]` for an equality;
505/// `z_upper − z_lower` for an inequality). For a convex quadratic
506/// `g(x) ≤ g_u` reformulated to a second-order cone, the constraint
507/// multiplier is recovered as the sum of the two cone duals on the rows
508/// carrying the linear coefficient vector `a`: `λ = z[r0] + z[r1]`. (At a
509/// KKT point stationarity reads `λ(∇g) = (z[r0]+z[r1])·a + …`, so this sum is
510/// the original multiplier; the cone's remaining rows reconstruct the `Qx`
511/// part.) `sign` undoes the maximize→minimize negation.
512pub fn recover_socp_duals(
513    prob: &NlProblem,
514    con_map: &[ConSocpMap],
515    y: &[f64],
516    z: &[f64],
517) -> Vec<f64> {
518    let sign = if prob.minimize { 1.0 } else { -1.0 };
519    con_map
520        .iter()
521        .map(|m| match m {
522            ConSocpMap::Eq { a_row } => sign * y[*a_row],
523            ConSocpMap::Ineq { upper, lower } => {
524                let zu = upper.map(|r| z[r]).unwrap_or(0.0);
525                let zl = lower.map(|r| z[r]).unwrap_or(0.0);
526                sign * (zu - zl)
527            }
528            ConSocpMap::Quad { z_row0, z_row1 } => sign * (z[*z_row0] + z[*z_row1]),
529        })
530        .collect()
531}
532
533/// Build a dense symmetric `n×n` matrix from a [`QuadHessian`]-style map of
534/// `(i ≤ j) → Hessian entry` (diagonal entries are the full `∂²/∂xᵢ²`, so
535/// `½xᵀHx` reproduces the quadratic form). Off-diagonals are mirrored.
536fn dense_symmetric(hmap: &std::collections::BTreeMap<(usize, usize), f64>, n: usize) -> Vec<f64> {
537    let mut dense = vec![0.0; n * n];
538    for (&(i, j), &v) in hmap {
539        dense[i * n + j] = v;
540        dense[j * n + i] = v;
541    }
542    dense
543}
544
545/// Symmetric **pivoted (rank-revealing) Cholesky** of a PSD matrix `H`
546/// (row-major `n×n`, consumed as scratch), returning the factor rows `f_k`
547/// (each length `n`) such that `Σ_k f_k f_kᵀ = H` — equivalently `FᵀF = H`
548/// with `F` the matrix whose rows are the `f_k`. The number of rows is the
549/// numerical rank, so a rank-deficient `Q` (e.g. `Q = vvᵀ`) yields the
550/// minimal cone. Complete diagonal pivoting keeps the factorization stable
551/// on the indefinite-looking-but-PSD matrices finite precision can produce.
552fn psd_outer_factor(mut a: Vec<f64>, n: usize) -> Vec<Vec<f64>> {
553    let mut rows: Vec<Vec<f64>> = Vec::new();
554    // Tolerance relative to the largest initial diagonal: pivots at or below
555    // this are treated as the zero eigenvalues of the PSD matrix.
556    let max_diag = (0..n).map(|i| a[i * n + i]).fold(0.0_f64, f64::max);
557    let tol = 1e-12 * max_diag.max(1.0);
558    for _ in 0..n {
559        // Largest remaining diagonal pivot.
560        let mut p = 0usize;
561        let mut best = f64::NEG_INFINITY;
562        for i in 0..n {
563            let d = a[i * n + i];
564            if d > best {
565                best = d;
566                p = i;
567            }
568        }
569        if best <= tol {
570            break;
571        }
572        let d = best.sqrt();
573        // f = column p of the residual, scaled by 1/d.
574        let mut f = vec![0.0; n];
575        for i in 0..n {
576            f[i] = a[i * n + p] / d;
577        }
578        // Rank-1 downdate: A ← A − f fᵀ.
579        for i in 0..n {
580            let fi = f[i];
581            if fi == 0.0 {
582                continue;
583            }
584            for j in 0..n {
585                a[i * n + j] -= fi * f[j];
586            }
587        }
588        rows.push(f);
589    }
590    rows
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::nl_reader::{BinOp, Expr};
597    use pounce_convex::{solve_qp_ipm, solve_socp_ipm, QpOptions, QpStatus};
598    use pounce_feral::FeralSolverInterface;
599    use pounce_linsol::SparseSymLinearSolverInterface;
600
601    fn backend() -> Box<dyn SparseSymLinearSolverInterface> {
602        Box::new(FeralSolverInterface::new())
603    }
604
605    fn pow2(var: usize) -> Expr {
606        Expr::Binary(
607            BinOp::Pow,
608            Box::new(Expr::Var(var)),
609            Box::new(Expr::Const(2.0)),
610        )
611    }
612
613    /// min −x0 − x1  s.t.  x0² + x1² ≤ 1  → x* = (1/√2, 1/√2), f* = −√2.
614    /// Exercises the QCQP→SOCP reformulation end-to-end: a rank-2 ball
615    /// constraint becomes one second-order cone, no nonnegative block.
616    #[test]
617    fn extract_and_solve_socp_ball() {
618        let prob = NlProblem {
619            n: 2,
620            m: 1,
621            num_obj: 1,
622            minimize: true,
623            obj_nonlinear: Expr::Const(0.0),
624            obj_linear: vec![(0, -1.0), (1, -1.0)],
625            obj_constant: 0.0,
626            con_nonlinear: vec![Expr::Binary(
627                BinOp::Add,
628                Box::new(pow2(0)),
629                Box::new(pow2(1)),
630            )],
631            con_linear: vec![vec![]],
632            x_l: vec![-2e19, -2e19],
633            x_u: vec![2e19, 2e19],
634            g_l: vec![-2e19],
635            g_u: vec![1.0],
636            x0: vec![0.0, 0.0],
637            lambda0: vec![0.0],
638            suffixes: Default::default(),
639            imported_funcs: Vec::new(),
640            var_names: Vec::new(),
641            con_names: Vec::new(),
642        };
643        let (qp, con_map, obj_const, cones) = extract_socp_with_map(&prob).expect("extract");
644        assert_eq!(obj_const, 0.0);
645        // No linear inequalities / bounds → no nonneg block; one SOC of
646        // dimension rank(Q)+2 = 2+2 = 4.
647        assert_eq!(cones, vec![ConeSpec::SecondOrder(4)]);
648        assert_eq!(qp.m_ineq(), 4);
649
650        let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
651        assert_eq!(sol.status, QpStatus::Optimal);
652        let inv_sqrt2 = 1.0 / 2.0_f64.sqrt();
653        assert!((sol.x[0] - inv_sqrt2).abs() < 1e-5, "x0={}", sol.x[0]);
654        assert!((sol.x[1] - inv_sqrt2).abs() < 1e-5, "x1={}", sol.x[1]);
655        assert!(
656            (sol.obj - (-2.0_f64.sqrt())).abs() < 1e-5,
657            "obj={}",
658            sol.obj
659        );
660
661        // Analytic multiplier: c + λ·2x = 0 ⇒ λ = 1/(2x0) = √2/2 ≈ 0.7071,
662        // positive (active upper bound), matching the `.sol` sign convention.
663        let lambda = recover_socp_duals(&prob, &con_map, &sol.y, &sol.z);
664        assert_eq!(lambda.len(), 1);
665        assert!(
666            (lambda[0] - 0.5 * 2.0_f64.sqrt()).abs() < 1e-3,
667            "ball constraint dual={}",
668            lambda[0]
669        );
670    }
671
672    /// min x0  s.t.  (x0−3)² ≤ 1  → feasible x0 ∈ [2, 4], optimum x0 = 2.
673    /// The constraint's linear (`−6x0`) and constant (`+9`) terms are folded
674    /// into the nonlinear tree; the reformulation must recover `b_eff = 9 − 1`
675    /// so the cone encodes `x0² − 6x0 + 8 ≤ 0`, not `x0² ≤ 1`.
676    #[test]
677    fn extract_and_solve_socp_folds_constraint_constant() {
678        let con = Expr::Binary(
679            BinOp::Pow,
680            Box::new(Expr::Binary(
681                BinOp::Sub,
682                Box::new(Expr::Var(0)),
683                Box::new(Expr::Const(3.0)),
684            )),
685            Box::new(Expr::Const(2.0)),
686        );
687        let prob = NlProblem {
688            n: 1,
689            m: 1,
690            num_obj: 1,
691            minimize: true,
692            obj_nonlinear: Expr::Const(0.0),
693            obj_linear: vec![(0, 1.0)],
694            obj_constant: 0.0,
695            con_nonlinear: vec![con],
696            con_linear: vec![vec![]],
697            x_l: vec![-2e19],
698            x_u: vec![2e19],
699            g_l: vec![-2e19],
700            g_u: vec![1.0],
701            x0: vec![0.0],
702            lambda0: vec![0.0],
703            suffixes: Default::default(),
704            imported_funcs: Vec::new(),
705            var_names: Vec::new(),
706            con_names: Vec::new(),
707        };
708        let (qp, _con_map, obj_const, cones) = extract_socp_with_map(&prob).expect("extract");
709        assert_eq!(obj_const, 0.0);
710        assert_eq!(cones, vec![ConeSpec::SecondOrder(3)]); // rank 1 + 2.
711
712        let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
713        assert_eq!(sol.status, QpStatus::Optimal);
714        assert!((sol.x[0] - 2.0).abs() < 1e-5, "x0={}", sol.x[0]);
715    }
716
717    /// `psd_outer_factor` recovers a rank-1 `Q = vvᵀ` with a single factor row
718    /// (minimal cone), and reconstructs `Q` exactly.
719    #[test]
720    fn psd_outer_factor_is_rank_revealing() {
721        // Q = [[1,2],[2,4]] = v vᵀ with v = (1,2): rank 1.
722        let q = vec![1.0, 2.0, 2.0, 4.0];
723        let rows = psd_outer_factor(q.clone(), 2);
724        assert_eq!(rows.len(), 1, "rank-1 Q must give one factor row");
725        // Reconstruct Σ f fᵀ and compare to Q.
726        let mut recon = vec![0.0; 4];
727        for f in &rows {
728            for i in 0..2 {
729                for j in 0..2 {
730                    recon[i * 2 + j] += f[i] * f[j];
731                }
732            }
733        }
734        for k in 0..4 {
735            assert!((recon[k] - q[k]).abs() < 1e-9, "recon[{k}]={}", recon[k]);
736        }
737    }
738
739    /// min (x0)^2 + (x1)^2 s.t. x0 + x1 = 2, no var bounds → (1,1), f*=2.
740    #[test]
741    fn extract_and_solve_equality_qp() {
742        let prob = NlProblem {
743            n: 2,
744            m: 1,
745            num_obj: 1,
746            minimize: true,
747            obj_nonlinear: Expr::Binary(BinOp::Add, Box::new(pow2(0)), Box::new(pow2(1))),
748            obj_linear: vec![],
749            obj_constant: 0.0,
750            con_nonlinear: vec![Expr::Const(0.0)],
751            con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
752            x_l: vec![-2e19, -2e19],
753            x_u: vec![2e19, 2e19],
754            g_l: vec![2.0],
755            g_u: vec![2.0],
756            x0: vec![0.0, 0.0],
757            lambda0: vec![0.0],
758            suffixes: Default::default(),
759            imported_funcs: Vec::new(),
760            var_names: Vec::new(),
761            con_names: Vec::new(),
762        };
763        let (qp, con_map, obj_const) = extract_qp_with_map(&prob).expect("extract");
764        // No constant anywhere in this objective.
765        assert_eq!(obj_const, 0.0);
766        // P = 2I → two diagonal entries.
767        assert_eq!(qp.p_lower.len(), 2);
768        assert_eq!(qp.m_eq(), 1);
769        assert_eq!(qp.m_ineq(), 0);
770
771        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
772        assert_eq!(sol.status, QpStatus::Optimal);
773        assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
774        assert!((sol.x[1] - 1.0).abs() < 1e-6, "x1={}", sol.x[1]);
775        assert!((sol.obj - 2.0).abs() < 1e-6, "obj={}", sol.obj);
776
777        // KKT for the equality: ∇f + y·∇g = 0 → 2x_i + y = 0 at x=1 → y=−2.
778        let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
779        assert_eq!(lambda.len(), 1);
780        assert!(
781            (lambda[0] - (-2.0)).abs() < 1e-5,
782            "equality dual={}",
783            lambda[0]
784        );
785    }
786
787    /// Regression for the dropped-linear-term bug: the objective `(x0-3)²`
788    /// lives entirely in the nonlinear tree, so its linear part (`−6·x0`)
789    /// must be folded into `c`. Without it the solve minimizes `x0²`
790    /// (optimum 0) instead of `(x0-3)²` (optimum 3).
791    #[test]
792    fn extract_keeps_linear_term_from_nonlinear_tree() {
793        // (x0 - 3)^2 = x0^2 - 6 x0 + 9, all in obj_nonlinear.
794        let obj = Expr::Binary(
795            BinOp::Pow,
796            Box::new(Expr::Binary(
797                BinOp::Sub,
798                Box::new(Expr::Var(0)),
799                Box::new(Expr::Const(3.0)),
800            )),
801            Box::new(Expr::Const(2.0)),
802        );
803        let prob = NlProblem {
804            n: 1,
805            m: 0,
806            num_obj: 1,
807            minimize: true,
808            obj_nonlinear: obj,
809            obj_linear: vec![],
810            obj_constant: 0.0,
811            con_nonlinear: vec![],
812            con_linear: vec![],
813            x_l: vec![-2e19],
814            x_u: vec![2e19],
815            g_l: vec![],
816            g_u: vec![],
817            x0: vec![0.0],
818            lambda0: vec![],
819            suffixes: Default::default(),
820            imported_funcs: Vec::new(),
821            var_names: Vec::new(),
822            con_names: Vec::new(),
823        };
824        let qp = extract_qp(&prob).expect("extract");
825        assert_eq!(qp.c.len(), 1);
826        assert!(
827            (qp.c[0] - (-6.0)).abs() < 1e-12,
828            "c[0]={} — linear term from the nonlinear tree was dropped",
829            qp.c[0]
830        );
831        // P = 2 (one diagonal entry).
832        assert_eq!(qp.p_lower.len(), 1);
833
834        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
835        assert_eq!(sol.status, QpStatus::Optimal);
836        assert!(
837            (sol.x[0] - 3.0).abs() < 1e-6,
838            "x0={} (expected 3)",
839            sol.x[0]
840        );
841    }
842
843    /// Inequality dual sign/magnitude. min x0² s.t. x0 ≥ 1 (a one-sided
844    /// inequality g_l=1, g_u=+inf). Optimum x0=1, active. The expected
845    /// dual −2.0 is the value POUNCE's *NLP* path writes for this exact
846    /// problem (verified by running `solver_selection=nlp` on the same
847    /// `.nl`); recover_duals must match that reference convention.
848    #[test]
849    fn inequality_dual_recovered() {
850        let prob = NlProblem {
851            n: 1,
852            m: 1,
853            num_obj: 1,
854            minimize: true,
855            obj_nonlinear: pow2(0),
856            obj_linear: vec![],
857            obj_constant: 0.0,
858            con_nonlinear: vec![Expr::Const(0.0)],
859            con_linear: vec![vec![(0, 1.0)]], // g(x) = x0
860            x_l: vec![-2e19],
861            x_u: vec![2e19],
862            g_l: vec![1.0], // x0 ≥ 1
863            g_u: vec![2e19],
864            x0: vec![0.0],
865            lambda0: vec![0.0],
866            suffixes: Default::default(),
867            imported_funcs: Vec::new(),
868            var_names: Vec::new(),
869            con_names: Vec::new(),
870        };
871        let (qp, con_map, obj_const) = extract_qp_with_map(&prob).expect("extract");
872        // This model puts its constant in the `obj_constant` field, not the
873        // nonlinear tree, so the tree constant is 0 here.
874        assert_eq!(obj_const, 0.0);
875        // One inequality row (the lower bound row −x0 ≤ −1); no upper.
876        assert_eq!(qp.m_ineq(), 1);
877        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
878        assert_eq!(sol.status, QpStatus::Optimal);
879        assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
880        let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
881        assert!((lambda[0] - (-2.0)).abs() < 1e-5, "ineq dual={}", lambda[0]);
882    }
883
884    /// Regression (M11): a *constraint* whose linear and constant
885    /// terms are folded into the nonlinear tree (not the `con_linear`
886    /// section) must still reach `A`/`G`. AMPL/Pyomo emit this shape for
887    /// rows the classifier admits as degree-≤1 (cancelled quadratics,
888    /// defined variables): the whole `x0 − 3` lives in `con_nonlinear`
889    /// and `con_linear[0]` is empty.
890    ///
891    ///     min x0   s.t.   x0 − 3 ≥ 0     (body in the nonlinear tree)
892    ///
893    /// True optimum: x0 = 3. The QP extractor used to build `A`/`G` from
894    /// `con_linear` only — dropping the folded `+x0` *and* the `−3`
895    /// shift, leaving a vacuous `0 ≤ 0` row, so `min x0` came out
896    /// unbounded (or otherwise wrong) on the convex path.
897    #[test]
898    fn constraint_linear_terms_folded_in_tree_are_recovered() {
899        // con body = x0 − 3, entirely in the nonlinear tree.
900        let con = Expr::Binary(
901            BinOp::Sub,
902            Box::new(Expr::Var(0)),
903            Box::new(Expr::Const(3.0)),
904        );
905        let prob = NlProblem {
906            n: 1,
907            m: 1,
908            num_obj: 1,
909            minimize: true,
910            obj_nonlinear: Expr::Const(0.0),
911            obj_linear: vec![(0, 1.0)],
912            obj_constant: 0.0,
913            con_nonlinear: vec![con],
914            con_linear: vec![vec![]], // the `+x0` lives in the TREE
915            x_l: vec![-2e19],
916            x_u: vec![2e19],
917            g_l: vec![0.0], // x0 − 3 ≥ 0
918            g_u: vec![2e19],
919            x0: vec![0.0],
920            lambda0: vec![0.0],
921            suffixes: Default::default(),
922            imported_funcs: Vec::new(),
923            var_names: Vec::new(),
924            con_names: Vec::new(),
925        };
926        let (qp, con_map, _obj_const) = extract_qp_with_map(&prob).expect("extract");
927        // One inequality row: −x0 ≤ −3 (the lower bound, constant-shifted).
928        assert_eq!(qp.m_ineq(), 1);
929        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
930        assert_eq!(sol.status, QpStatus::Optimal);
931        assert!((sol.x[0] - 3.0).abs() < 1e-5, "x0={}", sol.x[0]);
932        // Dual is recoverable and finite (the row carries a real coef now).
933        let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
934        assert_eq!(lambda.len(), 1);
935        assert!(lambda[0].is_finite(), "dual={}", lambda[0]);
936    }
937
938    /// Regression: a constant folded into the *nonlinear objective tree*
939    /// (not the `obj_constant` field) must still reach the reported
940    /// objective. This is the real `.nl` shape AMPL/Pyomo emit for
941    /// `min (x0-3)^2` — the whole `x0^2 - 6 x0 + 9` lives in the nonlinear
942    /// tree and `obj_constant == 0`. The convex path used to drop the `+9`
943    /// and report an objective 9 too small (cf. HS35 in the benchmark
944    /// comparison). The minimizer is x0 = 1 (upper bound binds), where the
945    /// true objective is (1-3)^2 = 4.
946    #[test]
947    fn tree_embedded_objective_constant_is_recovered() {
948        // (x0 - 3)^2 as a single nonlinear tree: Pow(Sub(x0, 3), 2).
949        let obj = Expr::Binary(
950            BinOp::Pow,
951            Box::new(Expr::Binary(
952                BinOp::Sub,
953                Box::new(Expr::Var(0)),
954                Box::new(Expr::Const(3.0)),
955            )),
956            Box::new(Expr::Const(2.0)),
957        );
958        let prob = NlProblem {
959            n: 1,
960            m: 0,
961            num_obj: 1,
962            minimize: true,
963            obj_nonlinear: obj,
964            obj_linear: vec![],
965            obj_constant: 0.0, // the +9 is in the TREE, not here
966            con_nonlinear: vec![],
967            con_linear: vec![],
968            x_l: vec![0.0],
969            x_u: vec![1.0],
970            g_l: vec![],
971            g_u: vec![],
972            x0: vec![0.0],
973            lambda0: vec![],
974            suffixes: Default::default(),
975            imported_funcs: Vec::new(),
976            var_names: Vec::new(),
977            con_names: Vec::new(),
978        };
979        let (qp, _con_map, obj_const) = extract_qp_with_map(&prob).expect("extract");
980        // The degree-0 term of (x0-3)^2 is +9, recovered from the tree.
981        assert!((obj_const - 9.0).abs() < 1e-12, "tree constant={obj_const}");
982        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
983        assert_eq!(sol.status, QpStatus::Optimal);
984        assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
985        // Reported objective = (½xᵀPx + cᵀx) + obj_const must equal the true
986        // (1-3)^2 = 4, not the constant-dropped −5.
987        let reported = sol.obj + obj_const;
988        assert!((reported - 4.0).abs() < 1e-5, "reported obj={reported}");
989    }
990
991    /// Bound-constrained: min (x0-3)^2 = x0^2 - 6 x0 + 9, 0 ≤ x0 ≤ 1.
992    /// Optimum x0 = 1 (upper bound binds). Here the constant 9 is carried
993    /// in the `obj_constant` field (not the tree), so the extracted tree
994    /// constant is 0 (asserted inside).
995    #[test]
996    fn extract_and_solve_bounded_qp() {
997        let prob = NlProblem {
998            n: 1,
999            m: 0,
1000            num_obj: 1,
1001            minimize: true,
1002            obj_nonlinear: pow2(0),
1003            obj_linear: vec![(0, -6.0)],
1004            obj_constant: 9.0,
1005            con_nonlinear: vec![],
1006            con_linear: vec![],
1007            x_l: vec![0.0],
1008            x_u: vec![1.0],
1009            g_l: vec![],
1010            g_u: vec![],
1011            x0: vec![0.0],
1012            lambda0: vec![],
1013            suffixes: Default::default(),
1014            imported_funcs: Vec::new(),
1015            var_names: Vec::new(),
1016            con_names: Vec::new(),
1017        };
1018        let qp = extract_qp(&prob).expect("extract");
1019        // Two var-bound rows (x0 ≤ 1, −x0 ≤ 0).
1020        assert_eq!(qp.m_ineq(), 2);
1021        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1022        assert_eq!(sol.status, QpStatus::Optimal);
1023        assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
1024    }
1025
1026    /// LP: min −x0 − x1, 0 ≤ x ≤ 1 → (1,1).
1027    #[test]
1028    fn extract_and_solve_lp() {
1029        let prob = NlProblem {
1030            n: 2,
1031            m: 0,
1032            num_obj: 1,
1033            minimize: true,
1034            obj_nonlinear: Expr::Const(0.0),
1035            obj_linear: vec![(0, -1.0), (1, -1.0)],
1036            obj_constant: 0.0,
1037            con_nonlinear: vec![],
1038            con_linear: vec![],
1039            x_l: vec![0.0, 0.0],
1040            x_u: vec![1.0, 1.0],
1041            g_l: vec![],
1042            g_u: vec![],
1043            x0: vec![0.0, 0.0],
1044            lambda0: vec![],
1045            suffixes: Default::default(),
1046            imported_funcs: Vec::new(),
1047            var_names: Vec::new(),
1048            con_names: Vec::new(),
1049        };
1050        let qp = extract_qp(&prob).expect("extract");
1051        assert!(qp.p_lower.is_empty(), "LP has no Hessian");
1052        assert_eq!(qp.m_ineq(), 4); // 2 vars × (upper + lower)
1053        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1054        assert_eq!(sol.status, QpStatus::Optimal);
1055        assert!((sol.x[0] - 1.0).abs() < 1e-6);
1056        assert!((sol.x[1] - 1.0).abs() < 1e-6);
1057    }
1058
1059    /// maximize x0 s.t. 0 ≤ x0 ≤ 5 → x0 = 5. Tests sign flip on a
1060    /// maximize objective.
1061    #[test]
1062    fn extract_maximize_negates() {
1063        let prob = NlProblem {
1064            n: 1,
1065            m: 0,
1066            num_obj: 1,
1067            minimize: false,
1068            obj_nonlinear: Expr::Const(0.0),
1069            obj_linear: vec![(0, 1.0)],
1070            obj_constant: 0.0,
1071            con_nonlinear: vec![],
1072            con_linear: vec![],
1073            x_l: vec![0.0],
1074            x_u: vec![5.0],
1075            g_l: vec![],
1076            g_u: vec![],
1077            x0: vec![0.0],
1078            lambda0: vec![],
1079            suffixes: Default::default(),
1080            imported_funcs: Vec::new(),
1081            var_names: Vec::new(),
1082            con_names: Vec::new(),
1083        };
1084        let qp = extract_qp(&prob).expect("extract");
1085        // minimize −x0.
1086        assert_eq!(qp.c[0], -1.0);
1087        let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1088        assert_eq!(sol.status, QpStatus::Optimal);
1089        assert!((sol.x[0] - 5.0).abs() < 1e-6, "x0={}", sol.x[0]);
1090    }
1091}