Skip to main content

pounce_cli/
convex_sens.rs

1//! The parametric sensitivity step on the **convex** dispatch path.
2//!
3//! [`crate::sens`] is the NLP arm's producer: it reads the filter-IPM's
4//! converged KKT factor through `PdSensBacksolver` and is hard-wired to
5//! `pounce_algorithm` types. This module is its convex counterpart, built on
6//! `pounce_convex::QpSensitivity` — the same sIPOPT computation over the
7//! active-set KKT the convex IPM's solution defines.
8//!
9//! # Why the CLI needed one at all
10//!
11//! Before this, a `.nl` carrying the sIPOPT suffixes made `auto` *decline* the
12//! convex fast path outright (issue #196) and pay the general engine's cost for
13//! a problem the specialized one solves, because only the general engine could
14//! answer the question. Under an explicit `solver_selection=qp-ipm` the request
15//! was warned about and dropped. Now an LP or convex QP whose pins the convex
16//! arm can express is served where it was solved.
17//!
18//! # The index space, which is the whole risk here
19//!
20//! The request arrives in the **`.nl`'s own** indices:
21//!
22//! * `sens_state_1` — var-int, one slot per original variable;
23//! * `sens_state_value_1` — var-real, the perturbed value;
24//! * `sens_init_constr` — con-int, which original constraint pins each
25//!   parameter.
26//!
27//! `QpSensitivity::parametric_step` takes indices into the extracted QP's
28//! **equality right-hand side `b`**, which is a different space: the extractor
29//! splits ranges, drops empty rows, and orders equalities and inequalities into
30//! separate blocks. [`qp_extract::ConRowMap`](crate::qp_extract::ConRowMap) is
31//! the single source of truth for that map, and this module reads it rather
32//! than reconstructing the correspondence — `/sens-review` entry 1, in the
33//! space that entry was written about.
34//!
35//! Two hazards the NLP arm has and this one does not, worth naming so nobody
36//! goes looking for them:
37//!
38//! * **No var-x / full-x split.** The extractor keeps variables 1:1 with the
39//!   `.nl` (`qp.n == prob.n`), including fixed ones, so there is no
40//!   `lift_x_to_full` and no gh#450 to reproduce. `the_convex_arm_has_no_var_x_split`
41//!   asserts that rather than leaving it as a reading of the extractor.
42//! * **No presolve row space** — but not for the reason it looks like. The
43//!   convex driver postsolves back to the extracted-QP space before anything
44//!   downstream runs, so the pins stay valid even with presolve on; that was
45//!   measured rather than assumed. Presolve is switched off anyway, because on
46//!   the one fixture that exercises it presolve *fixes the parameter the pin
47//!   parametrizes* and drops its row, leaving the sensitivity to read a
48//!   postsolve reconstruction instead of the converged KKT — four orders of
49//!   accuracy on the step, and an unmeasured question about whether the
50//!   reconstructed bound multipliers can move the inferred active set. See the
51//!   call site in `main.rs` for the numbers.
52
53use pounce_common::types::Number;
54use pounce_convex::qp::{QpProblem, QpSolution};
55use pounce_convex::sensitivity::QpSensitivity;
56use pounce_convex::{QpOptions, QpStatus};
57use pounce_linsol::SparseSymLinearSolverInterface;
58
59use crate::nl_reader::NlSuffixes;
60use crate::nl_writer::{SolSuffix, SolSuffixTarget, SolSuffixValues};
61use crate::qp_extract::ConRowMap;
62
63/// A parametric-step request resolved into the extracted QP's own indices.
64#[derive(Debug, Clone, PartialEq)]
65pub struct SensPins {
66    /// Row of `A` (index into `b`) pinning each parameter.
67    pub pin_rows: Vec<usize>,
68    /// The parameter's variable index — identical in the `.nl` and the QP.
69    pub param_vars: Vec<usize>,
70    /// The perturbed value the `.nl` asks for, per parameter.
71    pub target: Vec<Number>,
72}
73
74/// Why the convex arm cannot express this request. Carrying the reason (rather
75/// than an `Option`) is what lets the caller print a message a user can act on
76/// — and what keeps "the convex arm declined" distinguishable from "the convex
77/// arm answered zero".
78#[derive(Debug, Clone, PartialEq)]
79pub enum PinRefusal {
80    /// A required suffix is absent or the wrong length.
81    Suffixes(String),
82    /// A parameter has no `sens_state_1` or no `sens_init_constr` tag.
83    UntaggedParameter(usize),
84    /// The pinning constraint is an inequality (or a range). `parametric_step`
85    /// perturbs the equality right-hand side `b`; an inequality lives in
86    /// `h`/`G`, which is a different perturbation with a different meaning.
87    PinIsNotAnEquality(usize),
88    /// The pinning row is not `x_p = value` with a unit coefficient.
89    ///
90    /// The NLP arm assumes this shape without checking it — `try_compute_sens_step`
91    /// passes `signs = vec![1; n_params]` — so a row with any other coefficient
92    /// would make the two arms disagree. Refusing here hands such a model back
93    /// to the path that has always handled it, rather than introducing a second
94    /// answer.
95    PinRowIsNotUnit { con: usize, coefficient: Number },
96}
97
98impl PinRefusal {
99    /// A sentence for the "routing to the general NLP path" note.
100    pub fn describe(&self) -> String {
101        match self {
102            PinRefusal::Suffixes(m) => m.clone(),
103            PinRefusal::UntaggedParameter(k) => format!(
104                "parameter {} carries no sens_state_1 or sens_init_constr tag",
105                k + 1
106            ),
107            PinRefusal::PinIsNotAnEquality(c) => format!(
108                "constraint {} pins a parameter but is an inequality or range, and the \
109                 convex parametric step perturbs the equality right-hand side",
110                c + 1
111            ),
112            PinRefusal::PinRowIsNotUnit { con, coefficient } => format!(
113                "constraint {} pins a parameter with coefficient {coefficient} rather \
114                 than 1, which the sIPOPT suffix convention does not describe",
115                con + 1
116            ),
117        }
118    }
119}
120
121/// Resolve the `.nl`'s sIPOPT suffixes into pins on the extracted QP.
122///
123/// Runs **before** the solve, so a refusal can hand the model back to the NLP
124/// path with nothing printed and no `.sol` written.
125pub fn resolve_pins(
126    suffixes: &NlSuffixes,
127    con_map: &[ConRowMap],
128    qp: &QpProblem,
129    n_full: usize,
130) -> Result<SensPins, PinRefusal> {
131    let missing = |what: &str| PinRefusal::Suffixes(format!("the .nl declares no `{what}` suffix"));
132    let sens_state = suffixes
133        .var_int
134        .get("sens_state_1")
135        .ok_or_else(|| missing("sens_state_1"))?;
136    let sens_state_value = suffixes
137        .var_real
138        .get("sens_state_value_1")
139        .ok_or_else(|| missing("sens_state_value_1"))?;
140    let sens_init_constr = suffixes
141        .con_int
142        .get("sens_init_constr")
143        .ok_or_else(|| missing("sens_init_constr"))?;
144
145    if sens_state.len() != n_full || sens_state_value.len() != n_full {
146        return Err(PinRefusal::Suffixes(format!(
147            "sens_state_1 / sens_state_value_1 length mismatch (expected {n_full})"
148        )));
149    }
150
151    let n_params = sens_state.iter().copied().max().unwrap_or(0).max(0) as usize;
152    if n_params == 0 {
153        return Err(PinRefusal::Suffixes(
154            "sens_state_1 tags no parameters".to_string(),
155        ));
156    }
157
158    let mut param_var: Vec<Option<usize>> = vec![None; n_params];
159    for (var_idx, &slot) in sens_state.iter().enumerate() {
160        if slot > 0 && (slot as usize) <= n_params {
161            param_var[slot as usize - 1] = Some(var_idx);
162        }
163    }
164    let mut param_con: Vec<Option<usize>> = vec![None; n_params];
165    for (con_idx, &slot) in sens_init_constr.iter().enumerate() {
166        if slot > 0 && (slot as usize) <= n_params {
167            param_con[slot as usize - 1] = Some(con_idx);
168        }
169    }
170
171    let mut pins = SensPins {
172        pin_rows: Vec::with_capacity(n_params),
173        param_vars: Vec::with_capacity(n_params),
174        target: Vec::with_capacity(n_params),
175    };
176    for k in 0..n_params {
177        let (Some(vi), Some(ci)) = (param_var[k], param_con[k]) else {
178            return Err(PinRefusal::UntaggedParameter(k));
179        };
180        // The `.nl` constraint index into the extractor's provenance map. A
181        // constraint the extractor dropped has no entry, which is itself a
182        // refusal rather than an index to guess at.
183        let row = match con_map.get(ci) {
184            Some(ConRowMap::Eq { a_row }) => *a_row,
185            _ => return Err(PinRefusal::PinIsNotAnEquality(ci)),
186        };
187        // The suffix convention describes `x_p = p₀`; anything else and the
188        // delta below would be in the wrong units. Read the coefficient off the
189        // extracted row rather than trusting the shape.
190        let coefficient = row_coefficient(qp, row, vi);
191        if (coefficient - 1.0).abs() > 1e-12 {
192            return Err(PinRefusal::PinRowIsNotUnit {
193                con: ci,
194                coefficient,
195            });
196        }
197        pins.pin_rows.push(row);
198        pins.param_vars.push(vi);
199        pins.target.push(sens_state_value[vi]);
200    }
201    Ok(pins)
202}
203
204/// The coefficient of variable `var` in equality row `row` of `A`.
205fn row_coefficient(qp: &QpProblem, row: usize, var: usize) -> Number {
206    qp.a.iter()
207        .filter(|t| t.row == row && t.col == var)
208        .map(|t| t.val)
209        .sum()
210}
211
212/// Take the step and return the perturbed primal, in the `.nl`'s own variable
213/// order.
214///
215/// `None` when the sensitivity could not be built or the solve is not a point
216/// to differentiate at — the caller reports it; there is no silent zero.
217pub fn perturbed_x<F>(
218    qp: &QpProblem,
219    sol: &QpSolution,
220    opts: &QpOptions,
221    pins: &SensPins,
222    make_backend: F,
223) -> Result<Vec<Number>, String>
224where
225    F: FnMut() -> Box<dyn SparseSymLinearSolverInterface> + Copy,
226{
227    if sol.status != QpStatus::Optimal {
228        return Err(format!(
229            "the solve finished {:?}, so there is no optimum to differentiate at",
230            sol.status
231        ));
232    }
233    let mut sens = QpSensitivity::build(qp, sol, opts, 1e-7, make_backend)
234        .map_err(|e| format!("could not build the convex sensitivity: {e:?}"))?;
235    // `Δp[k] = perturbed − current`, both read off the solved primal. The QP's
236    // variables are the `.nl`'s, so `param_vars` indexes `sol.x` directly —
237    // this is the step the NLP arm needs `lift_x_to_full` for.
238    let deltas: Vec<Number> = pins
239        .param_vars
240        .iter()
241        .zip(&pins.target)
242        .map(|(&vi, &t)| t - sol.x[vi])
243        .collect();
244    let dx = sens.parametric_step(&pins.pin_rows, &deltas);
245    if sens.ill_conditioned() {
246        // Report **both** numbers, because from out here we cannot tell which
247        // clause fired: `KKT_ILL_CONDITIONED_THRESHOLD` is private to
248        // `pounce-convex`, and `ill_conditioned()` is the OR of two tests.
249        //
250        // Which matters, because the message used to print only the condition
251        // estimate — and on the population this refusal exists for, that is
252        // the reassuring one. A rank-deficient active set leaves the
253        // *regularized* KKT perfectly well conditioned (the trap gh#328 named;
254        // #889's round 4 measured `3.0e10` against a `1e14` threshold) while
255        // the residual is what rejects the step. So the old text handed the
256        // user a healthy-looking number as the stated reason for a refusal.
257        // Raised as a "minor" item in round 5 of #889; it is not cosmetic.
258        let cond = sens.kkt_cond_estimate();
259        let resid = sens
260            .last_step_residual()
261            .map(|r| format!("{r:.3e}"))
262            .unwrap_or_else(|| "n/a".to_string());
263        return Err(format!(
264            "the step is not meaningful here: condition estimate {cond:.3e}, \
265             step residual {resid}. Either alone is enough to reject it, and on \
266             a rank-deficient active set it is the residual — the regularized \
267             KKT is well conditioned there, so the condition estimate looks \
268             healthy while the answer is not"
269        ));
270    }
271    Ok(sol.x.iter().zip(&dx).map(|(a, b)| a + b).collect())
272}
273
274/// The `.sol` block the NLP path writes under the same name, so a consumer
275/// cannot tell which engine produced it — which is the point.
276pub fn sens_suffix(x_pert: Vec<Number>) -> SolSuffix {
277    SolSuffix {
278        name: "sens_sol_state_1".to_string(),
279        target: SolSuffixTarget::Var,
280        values: SolSuffixValues::Real(x_pert),
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use pounce_convex::qp::Triplet;
288    use std::collections::BTreeMap;
289
290    /// `min ½‖x‖² s.t. x₀ + x₁ = 1 (row 0), p = 1 (row 1)` with three
291    /// variables `(x₀, x₁, p)`. Row 1 is the pin.
292    fn qp() -> QpProblem {
293        QpProblem {
294            n: 3,
295            p_lower: (0..3).map(|j| Triplet::new(j, j, 1.0)).collect(),
296            c: vec![0.0; 3],
297            a: vec![
298                Triplet::new(0, 0, 1.0),
299                Triplet::new(0, 1, 1.0),
300                Triplet::new(1, 2, 1.0),
301            ],
302            b: vec![1.0, 1.0],
303            g: vec![],
304            h: vec![],
305            lb: vec![],
306            ub: vec![],
307        }
308    }
309
310    /// The `.nl` tags variable 2 as parameter 1 and constraint 1 as its pin.
311    fn suffixes(state: Vec<i32>, value: Vec<f64>, con: Vec<i32>) -> NlSuffixes {
312        let mut s = NlSuffixes::default();
313        s.var_int.insert("sens_state_1".into(), state);
314        s.var_real.insert("sens_state_value_1".into(), value);
315        s.con_int.insert("sens_init_constr".into(), con);
316        s
317    }
318
319    fn good() -> NlSuffixes {
320        suffixes(vec![0, 0, 1], vec![0.0, 0.0, 1.5], vec![0, 1])
321    }
322
323    #[test]
324    fn a_well_formed_request_resolves_to_the_equality_row() {
325        let pins = resolve_pins(
326            &good(),
327            &[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
328            &qp(),
329            3,
330        )
331        .expect("a unit equality pin is expressible");
332        assert_eq!(
333            pins,
334            SensPins {
335                pin_rows: vec![1],
336                param_vars: vec![2],
337                target: vec![1.5],
338            }
339        );
340    }
341
342    /// The whole point of reading `ConRowMap`: the `.nl`'s constraint index and
343    /// the QP's equality-row index are **different numbers**, and taking one
344    /// for the other returns a neighbouring row's answer — plausible and wrong.
345    /// This is `/sens-review` entry 1 in the convex arm's own space.
346    #[test]
347    fn the_nl_constraint_index_is_not_the_equality_row_index() {
348        // Constraint 0 is an inequality, so the pin at constraint 1 lands on
349        // equality row **0**, not row 1.
350        let con_map = [
351            ConRowMap::Ineq {
352                upper: Some(0),
353                lower: None,
354            },
355            ConRowMap::Eq { a_row: 0 },
356        ];
357        let mut q = qp();
358        // Row 0 of `A` now carries the pin.
359        q.a = vec![Triplet::new(0, 2, 1.0)];
360        q.b = vec![1.0];
361        let pins = resolve_pins(&good(), &con_map, &q, 3).expect("still expressible");
362        assert_eq!(
363            pins.pin_rows,
364            vec![0],
365            "constraint 1 pins equality row 0 here; using the .nl index would perturb a \
366             row that does not exist"
367        );
368    }
369
370    /// `parametric_step` perturbs `b`. An inequality pin lives in `h`/`G`,
371    /// which is a different perturbation with a different meaning, so the model
372    /// goes back to the path that expresses it.
373    #[test]
374    fn an_inequality_pin_is_refused() {
375        let con_map = [
376            ConRowMap::Eq { a_row: 0 },
377            ConRowMap::Ineq {
378                upper: Some(0),
379                lower: None,
380            },
381        ];
382        assert_eq!(
383            resolve_pins(&good(), &con_map, &qp(), 3),
384            Err(PinRefusal::PinIsNotAnEquality(1))
385        );
386    }
387
388    /// The sIPOPT suffix convention describes `x_p = p₀`. The NLP arm assumes
389    /// that shape without checking (`signs = vec![1; n_params]`), so a row with
390    /// another coefficient is where the two arms would disagree — and the point
391    /// of the check is that they do not.
392    #[test]
393    fn a_non_unit_pin_row_is_refused() {
394        let mut q = qp();
395        q.a = vec![
396            Triplet::new(0, 0, 1.0),
397            Triplet::new(0, 1, 1.0),
398            Triplet::new(1, 2, -1.0),
399        ];
400        assert_eq!(
401            resolve_pins(
402                &good(),
403                &[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
404                &q,
405                3
406            ),
407            Err(PinRefusal::PinRowIsNotUnit {
408                con: 1,
409                coefficient: -1.0
410            })
411        );
412    }
413
414    #[test]
415    fn a_parameter_with_no_pinning_constraint_is_refused() {
416        let s = suffixes(vec![0, 0, 1], vec![0.0, 0.0, 1.5], vec![0, 0]);
417        assert_eq!(
418            resolve_pins(
419                &s,
420                &[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
421                &qp(),
422                3
423            ),
424            Err(PinRefusal::UntaggedParameter(0))
425        );
426    }
427
428    #[test]
429    fn a_length_mismatch_is_refused_rather_than_indexed_past() {
430        let s = suffixes(vec![0, 1], vec![0.0, 1.5], vec![0, 1]);
431        assert!(matches!(
432            resolve_pins(
433                &s,
434                &[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
435                &qp(),
436                3
437            ),
438            Err(PinRefusal::Suffixes(_))
439        ));
440    }
441
442    /// The hazard the NLP arm has and this one does not, asserted rather than
443    /// left as a reading of the extractor: `resolve_pins` indexes `sol.x` by
444    /// the `.nl`'s own variable number, which is only sound because the convex
445    /// extractor keeps variables 1:1 — no `lift_x_to_full`, so no gh#450 to
446    /// reproduce here.
447    #[test]
448    fn the_convex_arm_has_no_var_x_split() {
449        let q = qp();
450        assert_eq!(
451            q.n, 3,
452            "the extracted QP keeps every .nl variable, fixed ones included"
453        );
454        let pins = resolve_pins(
455            &good(),
456            &[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
457            &q,
458            q.n,
459        )
460        .unwrap();
461        assert!(
462            pins.param_vars.iter().all(|&v| v < q.n),
463            "parameter variable indices are QP indices and .nl indices at once"
464        );
465    }
466
467    #[test]
468    fn every_refusal_describes_itself_without_panicking() {
469        let cases = [
470            PinRefusal::Suffixes("x".into()),
471            PinRefusal::UntaggedParameter(0),
472            PinRefusal::PinIsNotAnEquality(1),
473            PinRefusal::PinRowIsNotUnit {
474                con: 1,
475                coefficient: -1.0,
476            },
477        ];
478        for c in cases {
479            assert!(!c.describe().is_empty());
480        }
481        let _ = BTreeMap::<String, u8>::new();
482    }
483}