Skip to main content

pounce_cli/
sens.rs

1//! Parametric-sensitivity and reduced-Hessian post-processing for the
2//! `pounce` driver.
3//!
4//! This is the suffix-driven sIPOPT path: when an AMPL `.nl` declares
5//! the sIPOPT-style suffixes (`sens_state_1`, `sens_state_value_1`,
6//! `sens_init_constr`), `pounce` runs a normal solve and then performs
7//! the post-optimal sensitivity step via `pounce-sensitivity`, writing
8//! the perturbed primal back into the `.sol` as a `sens_sol_state_1`
9//! suffix. The `--compute-red-hessian` flag additionally computes the
10//! reduced Hessian over the variables tagged by the `red_hessian`
11//! integer var-suffix.
12//!
13//! Mirror of upstream sIPOPT's `ipopt_sens` AMPL binary
14//! ([`ref/Ipopt/contrib/sIPOPT/src/AmplTNLP.cpp` etc.](../../../ref/Ipopt/contrib/sIPOPT/)),
15//! limited to the metadata-measurement path that the
16//! `parametric_ampl` example exercises.
17//!
18//! The required suffixes (otherwise the solve is a plain nominal solve):
19//!
20//! * `sens_state_1` — integer var-suffix tagging each parameter
21//!   (value = 1..n_params, 0 for non-parameters).
22//! * `sens_state_value_1` — real var-suffix carrying the perturbed
23//!   parameter values.
24//! * `sens_init_constr` — integer con-suffix tagging which
25//!   constraint pins each parameter to its nominal value (value =
26//!   1..n_params, 0 otherwise).
27//!
28//! See [`ref/Ipopt/contrib/sIPOPT/examples/parametric_cpp/parametricTNLP.cpp`](../../../ref/Ipopt/contrib/sIPOPT/examples/parametric_cpp/parametricTNLP.cpp)
29//! `get_var_con_metadata` for the canonical suffix shape upstream
30//! emits, and pounce#16's `parametric_cpp.rs` for an end-to-end
31//! cross-check against upstream's golden output.
32
33use std::cell::RefCell;
34use std::rc::Rc;
35
36use pounce_common::types::{Index, Number};
37use pounce_linalg::dense_vector::DenseVector;
38use pounce_sensitivity::{
39    IndexSchurData, PdSensBacksolver, SchurData, SensApplication, SensBacksolver,
40    SensOptionOverrides, SensOptions,
41};
42
43use crate::nl_reader::NlSuffixes;
44use crate::nl_writer::{SolSuffix, SolSuffixTarget, SolSuffixValues};
45use crate::solve_report::SolutionSuffix;
46
47/// True when the `.nl` declares the three sIPOPT-style suffixes that
48/// drive the parametric sensitivity step. When this returns `false`,
49/// `pounce` runs a plain nominal solve.
50pub fn is_sensitivity_input(suffixes: &NlSuffixes) -> bool {
51    suffixes.var_int.contains_key("sens_state_1")
52        && suffixes.var_real.contains_key("sens_state_value_1")
53        && suffixes.con_int.contains_key("sens_init_constr")
54}
55
56/// Outputs of [`try_compute_red_hessian`]: the column-major `n × n`
57/// reduced Hessian (`hr`), the variable indices `var_indices` that
58/// label its rows/cols (so a downstream JSON consumer can map back to
59/// AMPL var names), and the optional eigendecomposition.
60pub struct RedHessianResult {
61    /// var-x indices (algorithm-side, length `n`) that label the
62    /// rows/cols of `hr`, ordered by the 1..n slot from the AMPL
63    /// `red_hessian` suffix. Fixed variables are skipped (they cannot
64    /// participate in the reduced Hessian).
65    pub var_indices: Vec<usize>,
66    /// Column-major `n × n` reduced Hessian.
67    pub hr: Vec<Number>,
68    /// Optional ascending eigenvalues (length `n`).
69    pub eigenvalues: Option<Vec<Number>>,
70    /// Optional column-major eigenvectors (length `n²`).
71    pub eigenvectors: Option<Vec<Number>>,
72}
73
74/// Run the post-optimal sensitivity step and return the perturbed
75/// primal lifted onto the full-x grid (length `n_full`). Returns `None`
76/// (quietly) when the required suffixes are missing — the caller then
77/// writes just the nominal solution.
78///
79/// `boundcheck_eps` enables the bound refinement of `x* + Δx` onto the
80/// declared `[x_l, x_u]` box (mirrors sIPOPT's `sens_boundcheck`); pass
81/// `None` to skip it. `release_eps` is the refinement's own margin for
82/// releasing a bound whose multiplier the step drives negative, which
83/// is the solve's `bound_relax_factor` floored and not the primal
84/// margin above.
85#[allow(clippy::too_many_arguments)]
86pub fn compute_sens_perturbed_x(
87    data: &pounce_algorithm::ipopt_data::IpoptDataHandle,
88    cq: &pounce_algorithm::ipopt_cq::IpoptCqHandle,
89    nlp: &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
90    pd: Rc<RefCell<pounce_algorithm::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
91    suffixes: &NlSuffixes,
92    n_full: usize,
93    m_full: usize,
94    x_full: &[Number],
95    boundcheck_eps: Option<Number>,
96    release_eps: Number,
97    sens_options: &SensOptionOverrides,
98) -> Option<Vec<Number>> {
99    let dx = try_compute_sens_step(
100        data,
101        cq,
102        nlp,
103        pd,
104        suffixes,
105        n_full,
106        m_full,
107        x_full,
108        boundcheck_eps,
109        release_eps,
110        sens_options,
111    )?;
112    let curr = data.borrow().curr.clone()?;
113    let n_x = curr.x.dim() as usize;
114
115    // Scatter the compressed primal step `dx[0..n_x_var]` back onto the
116    // full-x grid; fixed variables stay at their nominal values.
117    let mut x_pert = x_full.to_vec();
118    let nlp_ref = nlp.borrow();
119    for var_idx in 0..n_x {
120        let full_idx = nlp_ref.var_x_to_full_x(var_idx as Index) as usize;
121        x_pert[full_idx] += dx[var_idx];
122    }
123    Some(x_pert)
124}
125
126/// Convert a `.sol`-shaped suffix block into the JSON report's flat
127/// representation.
128pub fn sol_suffix_to_report(s: &SolSuffix) -> SolutionSuffix {
129    let target = match s.target {
130        SolSuffixTarget::Var => "var",
131        SolSuffixTarget::Con => "con",
132        SolSuffixTarget::Obj => "obj",
133        SolSuffixTarget::Problem => "problem",
134    }
135    .to_string();
136    let (kind, values, int_values) = match &s.values {
137        SolSuffixValues::Real(v) => ("real".to_string(), v.clone(), Vec::new()),
138        SolSuffixValues::Int(v) => ("int".to_string(), Vec::new(), v.clone()),
139        SolSuffixValues::ProblemReal(v) => ("real".to_string(), vec![*v], Vec::new()),
140        SolSuffixValues::ProblemInt(v) => ("int".to_string(), Vec::new(), vec![*v]),
141    };
142    SolutionSuffix {
143        name: s.name.clone(),
144        target,
145        kind,
146        values,
147        int_values,
148    }
149}
150
151/// Format a reduced Hessian (and optional eigendecomp) onto stderr.
152/// Matches the style of upstream sIPOPT's
153/// `SensReducedHessianCalculator.cpp` `S->Print(...)` /
154/// `eigenvalues->Print(...)` calls — informational, not parsed.
155pub fn print_red_hessian_to_stderr(rh: &RedHessianResult) {
156    let n = rh.var_indices.len();
157    eprintln!("\n=== Reduced Hessian (n={n}) ===");
158    eprintln!("var indices: {:?}", rh.var_indices);
159    for i in 0..n {
160        let mut row = String::new();
161        for j in 0..n {
162            // column-major: hr[i + n*j]
163            row.push_str(&format!(" {:>14.6e}", rh.hr[i + n * j]));
164        }
165        eprintln!(" [{i:>3}]{row}");
166    }
167    if let Some(w) = &rh.eigenvalues {
168        eprintln!("\n=== Reduced-Hessian eigenvalues (ascending) ===");
169        for (k, v) in w.iter().enumerate() {
170            eprintln!(" [{k:>3}] {v:>14.6e}");
171        }
172    }
173    eprintln!();
174}
175
176/// Read the AMPL `red_hessian` integer var-suffix from `.nl`, select
177/// the tagged free variables, and compute the reduced Hessian via
178/// [`SensApplication::compute_reduced_hessian`] (optionally also the
179/// eigendecomposition). Returns `None` (quietly) when the suffix is
180/// missing or empty.
181///
182/// Mirrors the `compute_red_hessian=yes` branch of upstream
183/// [`SensBuilder::BuildRedHessCalc`](../../../ref/Ipopt/contrib/sIPOPT/src/SensBuilder.cpp).
184pub fn try_compute_red_hessian(
185    data: &pounce_algorithm::ipopt_data::IpoptDataHandle,
186    cq: &pounce_algorithm::ipopt_cq::IpoptCqHandle,
187    nlp: &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
188    pd: Rc<RefCell<pounce_algorithm::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
189    suffixes: &NlSuffixes,
190    compute_eigen: bool,
191    sens_options: &SensOptionOverrides,
192) -> Option<RedHessianResult> {
193    let red_hessian_tags = suffixes.var_int.get("red_hessian")?;
194    let max_slot = red_hessian_tags.iter().copied().max().unwrap_or(0);
195    if max_slot <= 0 {
196        return None;
197    }
198    let n_slots = max_slot as usize;
199
200    // For each slot 1..n_slots, look up the full-x index, then map to
201    // the var-x index via the IpoptNlp trait. Fixed variables (no
202    // var-x mapping) are skipped with a warning.
203    let nlp_ref = nlp.borrow();
204    let mut full_for_slot: Vec<Option<usize>> = vec![None; n_slots];
205    for (full_idx, &slot) in red_hessian_tags.iter().enumerate() {
206        if slot > 0 {
207            let s = slot as usize;
208            if s <= n_slots {
209                full_for_slot[s - 1] = Some(full_idx);
210            }
211        }
212    }
213    let mut var_indices: Vec<usize> = Vec::with_capacity(n_slots);
214    for (k, slot) in full_for_slot.iter().enumerate() {
215        let full_idx = match slot {
216            Some(i) => *i,
217            None => {
218                eprintln!("pounce: red_hessian slot {} has no tagged variable", k + 1);
219                return None;
220            }
221        };
222        match nlp_ref.full_x_to_var_x(full_idx as Index) {
223            Some(v) => var_indices.push(v as usize),
224            None => {
225                eprintln!(
226                    "pounce: red_hessian slot {} tags fixed variable {} (skipping)",
227                    k + 1,
228                    full_idx
229                );
230                return None;
231            }
232        }
233    }
234    drop(nlp_ref);
235
236    // Build the row-selector SchurData over the var-x rows directly
237    // (the x block starts at compound-vector index 0).
238    let rows: Vec<Index> = var_indices.iter().map(|&v| v as Index).collect();
239    let signs: Vec<Index> = vec![1; var_indices.len()];
240    let a_data = IndexSchurData::from_parts(rows, signs).ok()?;
241
242    let backsolver = PdSensBacksolver::new(data, cq, nlp, pd).ok()?;
243    // `sens_max_pdpert`: the reduced Hessian is a block of the inverse
244    // of the converged factor, so a factor the inertia correction had
245    // to perturb describes a nearby problem, not this one.
246    if let Some(msg) = sens_options.pdpert_refusal(&backsolver.kkt_perturbations()) {
247        eprintln!("pounce: reduced Hessian: {msg}");
248        return None;
249    }
250    let opts = SensOptions {
251        compute_red_hessian: true,
252        rh_eigendecomp: compute_eigen,
253        ..SensOptions::default()
254    };
255    let mut app = SensApplication::new(a_data, backsolver, opts);
256    let n = var_indices.len();
257    let mut hr = vec![0.0; n * n];
258    let (eigenvalues, eigenvectors) = if compute_eigen {
259        let mut w = vec![0.0; n];
260        let mut v = vec![0.0; n * n];
261        if !app.compute_reduced_hessian_eigen(&mut hr, &mut w, &mut v) {
262            eprintln!("pounce: reduced-Hessian eigendecomp failed");
263            return None;
264        }
265        (Some(w), Some(v))
266    } else {
267        if !app.compute_reduced_hessian(&mut hr) {
268            eprintln!("pounce: reduced-Hessian computation failed");
269            return None;
270        }
271        (None, None)
272    };
273    let _ = cq;
274    Some(RedHessianResult {
275        var_indices,
276        hr,
277        eigenvalues,
278        eigenvectors,
279    })
280}
281
282/// Try to compute the parametric sensitivity step from the suffixes
283/// declared in the input `.nl`. Returns `None` (quietly) when any
284/// required suffix is missing — typical for `.nl` files that aren't
285/// sensitivity inputs.
286#[allow(clippy::too_many_arguments)]
287fn try_compute_sens_step(
288    data: &pounce_algorithm::ipopt_data::IpoptDataHandle,
289    cq: &pounce_algorithm::ipopt_cq::IpoptCqHandle,
290    nlp: &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
291    pd: Rc<RefCell<pounce_algorithm::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
292    suffixes: &NlSuffixes,
293    n_full: usize,
294    _m_full: usize,
295    x_nominal: &[Number],
296    boundcheck_eps: Option<Number>,
297    release_eps: Number,
298    sens_options: &SensOptionOverrides,
299) -> Option<Vec<Number>> {
300    // Required suffixes. The "_1" suffix tier corresponds to upstream
301    // sIPOPT's `n_sens_steps=1` mode. Higher tiers (sens_state_2 etc.)
302    // are a Phase-2 follow-up.
303    let sens_state = suffixes.var_int.get("sens_state_1")?;
304    let sens_state_value = suffixes.var_real.get("sens_state_value_1")?;
305    let sens_init_constr = suffixes.con_int.get("sens_init_constr")?;
306
307    if sens_state.len() != n_full || sens_state_value.len() != n_full {
308        eprintln!("pounce: sens_state_1 / sens_state_value_1 length mismatch (expected {n_full})");
309        return None;
310    }
311
312    // Number of parameters and per-parameter (var_idx, constraint_idx)
313    // pairs. The integer suffix value is 1..n_params, indexing which
314    // parameter slot each variable / constraint maps to.
315    let n_params = sens_state.iter().copied().max().unwrap_or(0).max(0) as usize;
316    if n_params == 0 {
317        return None;
318    }
319
320    // For each parameter slot, find its variable index (from
321    // sens_state_1) and its pinning-constraint index (from
322    // sens_init_constr).
323    let mut param_var_idx: Vec<Option<usize>> = vec![None; n_params];
324    for (var_idx, &slot) in sens_state.iter().enumerate() {
325        if slot > 0 {
326            let s = slot as usize;
327            if s <= n_params {
328                param_var_idx[s - 1] = Some(var_idx);
329            }
330        }
331    }
332    let mut param_con_idx: Vec<Option<usize>> = vec![None; n_params];
333    for (con_idx, &slot) in sens_init_constr.iter().enumerate() {
334        if slot > 0 {
335            let s = slot as usize;
336            if s <= n_params {
337                param_con_idx[s - 1] = Some(con_idx);
338            }
339        }
340    }
341    for k in 0..n_params {
342        if param_var_idx[k].is_none() || param_con_idx[k].is_none() {
343            eprintln!(
344                "pounce: parameter {} missing sens_state_1 or sens_init_constr tag",
345                k + 1
346            );
347            return None;
348        }
349    }
350
351    // Build the SchurData rows: flat compound-vector index for each
352    // pinning constraint = n_x + n_s + c_block_idx (i.e. y_c[…] slot).
353    // Pounce's compound layout matches upstream's
354    // `MetadataMeasurement::GetInitialEqConstraints`
355    // (`ref/Ipopt/contrib/sIPOPT/src/SensMetadataMeasurement.cpp:69-83`).
356    // The full-g → c-block transform (needed when the c/d split
357    // reorders constraints) is the backsolver's canonical
358    // `map_pin_g_to_kkt_rows` (pounce#128 single source of truth).
359    let backsolver = PdSensBacksolver::new(data, cq, nlp, pd)
360        .map_err(|e| eprintln!("pounce: could not capture the KKT factor: {e}"))
361        .ok()?;
362    // `sens_max_pdpert`: the step inverts the converged factor, so
363    // refuse to report one taken through a factor the caller declared
364    // too heavily perturbed to be this problem's KKT matrix.
365    if let Some(msg) = sens_options.pdpert_refusal(&backsolver.kkt_perturbations()) {
366        eprintln!("pounce: {msg}");
367        return None;
368    }
369    let pin_g: Vec<Index> = param_con_idx
370        .iter()
371        .map(|ci| ci.unwrap() as Index)
372        .collect();
373    let rows = match backsolver.map_pin_g_to_kkt_rows(&pin_g) {
374        Ok(r) => r,
375        Err(e) => {
376            eprintln!("pounce: {e}");
377            return None;
378        }
379    };
380    let signs: Vec<Index> = vec![1; n_params];
381    let a_data = IndexSchurData::from_parts(rows, signs).ok()?;
382
383    // Δp[k] = perturbed_value - current_value for parameter k. Both
384    // sides are read from the user's full-x array (length `n_full`); the
385    // caller passes `x_nominal` already lifted via
386    // `IpoptNlp::lift_x_to_full`, so indexing by the full-x var index
387    // works whether or not other variables were eliminated.
388    let mut delta_p: Vec<Number> = Vec::with_capacity(n_params);
389    for k in 0..n_params {
390        let vi = param_var_idx[k].unwrap();
391        delta_p.push(sens_state_value[vi] - x_nominal[vi]);
392    }
393    let n_full_pd = backsolver.dim();
394    let mut rhs_full = vec![0.0; n_full_pd];
395    a_data
396        .trans_multiply(&delta_p, &mut rhs_full)
397        .map_err(|e| eprintln!("pounce: trans_multiply error: {e:?}"))
398        .ok()?;
399    let mut dx_full = vec![0.0; n_full_pd];
400    if !backsolver.solve(&rhs_full, &mut dx_full) {
401        eprintln!("pounce: KKT backsolve failed");
402        return None;
403    }
404
405    // `sens_boundcheck`: hold every coordinate the step takes past a
406    // bound AT that bound by pinning and re-solving, so the others move
407    // with it. This runs here rather than in the caller because it
408    // re-solves against the factor, which only exists in this scope.
409    if let Some(eps) = boundcheck_eps {
410        let n_x = backsolver.block_dims()[0];
411        let x_curr = {
412            let d = data.borrow();
413            let curr = d.curr.as_ref()?;
414            curr.x
415                .as_any()
416                .downcast_ref::<DenseVector>()
417                .map(|v| v.expanded_values())
418                .unwrap_or_default()
419        };
420        let (mut lo, mut hi) = {
421            let nl = nlp.borrow();
422            pounce_sensitivity::boundcheck::expand_bounds(
423                n_x,
424                &nl.px_l(),
425                &nl.px_u(),
426                nl.x_l(),
427                nl.x_u(),
428            )
429        };
430        // the iterate and the bounds are in the solve's coordinates
431        // while the step is in the model's own units (gh#486 stage 3),
432        // so both are brought into the step's units
433        let mut x_nat = x_curr.clone();
434        {
435            let nlp_ref = nlp.borrow();
436            if let Some(d) = nlp_ref.variable_scaling() {
437                for i in 0..n_x.min(x_nat.len()) {
438                    let di = d[nlp_ref.var_x_to_full_x(i as Index) as usize];
439                    if di == 0.0 || di == 1.0 {
440                        continue;
441                    }
442                    x_nat[i] /= di;
443                    let (a, b) = (lo[i] / di, hi[i] / di);
444                    lo[i] = a.min(b);
445                    hi[i] = a.max(b);
446                }
447            }
448        }
449        // base bound multipliers with their compound rows, so a bound
450        // the step wants to release can be released
451        let mults = {
452            let dims = backsolver.block_dims();
453            let base = dims[0] + dims[1] + dims[2] + dims[3];
454            let d = data.borrow();
455            let curr = d.curr.as_ref()?;
456            let mut out = Vec::new();
457            for (off, v) in [(base, &curr.z_l), (base + dims[4], &curr.z_u)] {
458                let vals = v
459                    .as_any()
460                    .downcast_ref::<DenseVector>()
461                    .map(|d| d.expanded_values())
462                    .unwrap_or_default();
463                for (k, &b) in vals.iter().enumerate() {
464                    out.push(pounce_sensitivity::boundcheck::BoundMultiplier {
465                        row: off + k,
466                        base: b,
467                    });
468                }
469            }
470            out
471        };
472        match pounce_sensitivity::boundcheck::refine_step_onto_bounds(
473            &backsolver,
474            &dx_full,
475            &x_nat[..n_x.min(x_nat.len())],
476            &lo,
477            &hi,
478            &mults,
479            // the right-hand side this path's own step came from, so a
480            // release re-solves the system it started in
481            &rhs_full,
482            eps,
483            release_eps,
484            16,
485        ) {
486            Ok((refined, rows, stop)) => {
487                if !rows.is_empty() {
488                    eprintln!(
489                        "pounce: --sens-boundcheck pinned or released {} bound(s) \
490                         and re-solved",
491                        rows.len()
492                    );
493                }
494                // A safety limit that fired, or a refinement that gave
495                // up, is the caller's business: the step returned is
496                // not the one the refinement was asked for.
497                match stop {
498                    pounce_sensitivity::boundcheck::RefineStop::Settled => {}
499                    pounce_sensitivity::boundcheck::RefineStop::IterationLimit => eprintln!(
500                        "pounce: --sens-boundcheck stopped at its pass limit with \
501                         bounds still violated"
502                    ),
503                    pounce_sensitivity::boundcheck::RefineStop::DegreesOfFreedom => eprintln!(
504                        "pounce: --sens-boundcheck could not hold every bound at \
505                         once; the problem's degrees of freedom are spent"
506                    ),
507                    pounce_sensitivity::boundcheck::RefineStop::WorseThanPlain => eprintln!(
508                        "pounce: --sens-boundcheck ended further outside the bounds \
509                         than the unrefined step, which was returned instead"
510                    ),
511                }
512                dx_full = refined;
513            }
514            Err(e) => {
515                eprintln!("pounce: --sens-boundcheck failed: {e}");
516                return None;
517            }
518        }
519    }
520    Some(dx_full)
521}