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}