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, SensOptions,
40};
41
42use crate::nl_reader::NlSuffixes;
43use crate::nl_writer::{SolSuffix, SolSuffixTarget, SolSuffixValues};
44use crate::solve_report::SolutionSuffix;
45
46/// True when the `.nl` declares the three sIPOPT-style suffixes that
47/// drive the parametric sensitivity step. When this returns `false`,
48/// `pounce` runs a plain nominal solve.
49pub fn is_sensitivity_input(suffixes: &NlSuffixes) -> bool {
50    suffixes.var_int.contains_key("sens_state_1")
51        && suffixes.var_real.contains_key("sens_state_value_1")
52        && suffixes.con_int.contains_key("sens_init_constr")
53}
54
55/// Outputs of [`try_compute_red_hessian`]: the column-major `n × n`
56/// reduced Hessian (`hr`), the variable indices `var_indices` that
57/// label its rows/cols (so a downstream JSON consumer can map back to
58/// AMPL var names), and the optional eigendecomposition.
59pub struct RedHessianResult {
60    /// var-x indices (algorithm-side, length `n`) that label the
61    /// rows/cols of `hr`, ordered by the 1..n slot from the AMPL
62    /// `red_hessian` suffix. Fixed variables are skipped (they cannot
63    /// participate in the reduced Hessian).
64    pub var_indices: Vec<usize>,
65    /// Column-major `n × n` reduced Hessian.
66    pub hr: Vec<Number>,
67    /// Optional ascending eigenvalues (length `n`).
68    pub eigenvalues: Option<Vec<Number>>,
69    /// Optional column-major eigenvectors (length `n²`).
70    pub eigenvectors: Option<Vec<Number>>,
71}
72
73/// Run the post-optimal sensitivity step and return the perturbed
74/// primal lifted onto the full-x grid (length `n_full`). Returns `None`
75/// (quietly) when the required suffixes are missing — the caller then
76/// writes just the nominal solution.
77///
78/// `boundcheck_eps` enables the single-pass clamp of `x* + Δx` onto the
79/// declared `[x_l, x_u]` box (mirrors sIPOPT's `sens_boundcheck`); pass
80/// `None` to skip it.
81#[allow(clippy::too_many_arguments)]
82pub fn compute_sens_perturbed_x(
83    data: &pounce_algorithm::ipopt_data::IpoptDataHandle,
84    cq: &pounce_algorithm::ipopt_cq::IpoptCqHandle,
85    nlp: &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
86    pd: Rc<RefCell<pounce_algorithm::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
87    suffixes: &NlSuffixes,
88    n_full: usize,
89    m_full: usize,
90    x_full: &[Number],
91    boundcheck_eps: Option<Number>,
92) -> Option<Vec<Number>> {
93    let mut dx = try_compute_sens_step(data, cq, nlp, pd, suffixes, n_full, m_full, x_full)?;
94    let curr = data.borrow().curr.clone()?;
95    let n_x = curr.x.dim() as usize;
96
97    // Per-variable `user-scaling` factors in var-x order, or all-ones
98    // (gh#486 stage 3). The step below comes back from the
99    // natural-units back-solve, but the iterate and the NLP's bounds
100    // are in the coordinates the solve ran in — so the projection has
101    // to move between them.
102    let d_var: Vec<Number> = {
103        let nlp_ref = nlp.borrow();
104        match nlp_ref.variable_scaling() {
105            Some(d) => (0..n_x)
106                .map(|v| d[nlp_ref.var_x_to_full_x(v as Index) as usize])
107                .collect(),
108            None => vec![1.0; n_x],
109        }
110    };
111
112    if let Some(eps) = boundcheck_eps {
113        // Single-pass clamp of the primal step before scattering onto
114        // the full-x grid; see pounce_sensitivity::boundcheck for the
115        // algorithm.
116        let x_curr_compressed: Vec<Number> = curr
117            .x
118            .as_any()
119            .downcast_ref::<DenseVector>()
120            .map(|d| d.values().to_vec())
121            .unwrap_or_default();
122        let mut dx_primal = dx[..n_x].to_vec();
123        // Into the solve's coordinates for the projection, and back
124        // out of them after: clamping a natural-units step against
125        // scaled bounds would project onto the wrong box.
126        for (s, &di) in dx_primal.iter_mut().zip(d_var.iter()) {
127            *s *= di;
128        }
129        let n_clamped = pounce_sensitivity::boundcheck::clamp_with_nlp(
130            &*nlp.borrow(),
131            &x_curr_compressed,
132            &mut dx_primal,
133            eps,
134        );
135        for (s, &di) in dx_primal.iter_mut().zip(d_var.iter()) {
136            *s /= di;
137        }
138        if n_clamped > 0 {
139            eprintln!("pounce: --sens-boundcheck clamped {n_clamped} primal coordinate(s)");
140            dx[..n_x].copy_from_slice(&dx_primal);
141        }
142    }
143
144    // Scatter the compressed primal step `dx[0..n_x_var]` back onto the
145    // full-x grid; fixed variables stay at their nominal values.
146    let mut x_pert = x_full.to_vec();
147    let nlp_ref = nlp.borrow();
148    for var_idx in 0..n_x {
149        let full_idx = nlp_ref.var_x_to_full_x(var_idx as Index) as usize;
150        x_pert[full_idx] += dx[var_idx];
151    }
152    Some(x_pert)
153}
154
155/// Convert a `.sol`-shaped suffix block into the JSON report's flat
156/// representation.
157pub fn sol_suffix_to_report(s: &SolSuffix) -> SolutionSuffix {
158    let target = match s.target {
159        SolSuffixTarget::Var => "var",
160        SolSuffixTarget::Con => "con",
161        SolSuffixTarget::Obj => "obj",
162        SolSuffixTarget::Problem => "problem",
163    }
164    .to_string();
165    let (kind, values, int_values) = match &s.values {
166        SolSuffixValues::Real(v) => ("real".to_string(), v.clone(), Vec::new()),
167        SolSuffixValues::Int(v) => ("int".to_string(), Vec::new(), v.clone()),
168        SolSuffixValues::ProblemReal(v) => ("real".to_string(), vec![*v], Vec::new()),
169        SolSuffixValues::ProblemInt(v) => ("int".to_string(), Vec::new(), vec![*v]),
170    };
171    SolutionSuffix {
172        name: s.name.clone(),
173        target,
174        kind,
175        values,
176        int_values,
177    }
178}
179
180/// Format a reduced Hessian (and optional eigendecomp) onto stderr.
181/// Matches the style of upstream sIPOPT's
182/// `SensReducedHessianCalculator.cpp` `S->Print(...)` /
183/// `eigenvalues->Print(...)` calls — informational, not parsed.
184pub fn print_red_hessian_to_stderr(rh: &RedHessianResult) {
185    let n = rh.var_indices.len();
186    eprintln!("\n=== Reduced Hessian (n={n}) ===");
187    eprintln!("var indices: {:?}", rh.var_indices);
188    for i in 0..n {
189        let mut row = String::new();
190        for j in 0..n {
191            // column-major: hr[i + n*j]
192            row.push_str(&format!(" {:>14.6e}", rh.hr[i + n * j]));
193        }
194        eprintln!(" [{i:>3}]{row}");
195    }
196    if let Some(w) = &rh.eigenvalues {
197        eprintln!("\n=== Reduced-Hessian eigenvalues (ascending) ===");
198        for (k, v) in w.iter().enumerate() {
199            eprintln!(" [{k:>3}] {v:>14.6e}");
200        }
201    }
202    eprintln!();
203}
204
205/// Read the AMPL `red_hessian` integer var-suffix from `.nl`, select
206/// the tagged free variables, and compute the reduced Hessian via
207/// [`SensApplication::compute_reduced_hessian`] (optionally also the
208/// eigendecomposition). Returns `None` (quietly) when the suffix is
209/// missing or empty.
210///
211/// Mirrors the `compute_red_hessian=yes` branch of upstream
212/// [`SensBuilder::BuildRedHessCalc`](../../../ref/Ipopt/contrib/sIPOPT/src/SensBuilder.cpp).
213pub fn try_compute_red_hessian(
214    data: &pounce_algorithm::ipopt_data::IpoptDataHandle,
215    cq: &pounce_algorithm::ipopt_cq::IpoptCqHandle,
216    nlp: &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
217    pd: Rc<RefCell<pounce_algorithm::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
218    suffixes: &NlSuffixes,
219    compute_eigen: bool,
220) -> Option<RedHessianResult> {
221    let red_hessian_tags = suffixes.var_int.get("red_hessian")?;
222    let max_slot = red_hessian_tags.iter().copied().max().unwrap_or(0);
223    if max_slot <= 0 {
224        return None;
225    }
226    let n_slots = max_slot as usize;
227
228    // For each slot 1..n_slots, look up the full-x index, then map to
229    // the var-x index via the IpoptNlp trait. Fixed variables (no
230    // var-x mapping) are skipped with a warning.
231    let nlp_ref = nlp.borrow();
232    let mut full_for_slot: Vec<Option<usize>> = vec![None; n_slots];
233    for (full_idx, &slot) in red_hessian_tags.iter().enumerate() {
234        if slot > 0 {
235            let s = slot as usize;
236            if s <= n_slots {
237                full_for_slot[s - 1] = Some(full_idx);
238            }
239        }
240    }
241    let mut var_indices: Vec<usize> = Vec::with_capacity(n_slots);
242    for (k, slot) in full_for_slot.iter().enumerate() {
243        let full_idx = match slot {
244            Some(i) => *i,
245            None => {
246                eprintln!("pounce: red_hessian slot {} has no tagged variable", k + 1);
247                return None;
248            }
249        };
250        match nlp_ref.full_x_to_var_x(full_idx as Index) {
251            Some(v) => var_indices.push(v as usize),
252            None => {
253                eprintln!(
254                    "pounce: red_hessian slot {} tags fixed variable {} (skipping)",
255                    k + 1,
256                    full_idx
257                );
258                return None;
259            }
260        }
261    }
262    drop(nlp_ref);
263
264    // Build the row-selector SchurData over the var-x rows directly
265    // (the x block starts at compound-vector index 0).
266    let rows: Vec<Index> = var_indices.iter().map(|&v| v as Index).collect();
267    let signs: Vec<Index> = vec![1; var_indices.len()];
268    let a_data = IndexSchurData::from_parts(rows, signs).ok()?;
269
270    let backsolver = PdSensBacksolver::new(data, cq, nlp, pd).ok()?;
271    let opts = SensOptions {
272        compute_red_hessian: true,
273        rh_eigendecomp: compute_eigen,
274        ..SensOptions::default()
275    };
276    let mut app = SensApplication::new(a_data, backsolver, opts);
277    let n = var_indices.len();
278    let mut hr = vec![0.0; n * n];
279    let (eigenvalues, eigenvectors) = if compute_eigen {
280        let mut w = vec![0.0; n];
281        let mut v = vec![0.0; n * n];
282        if !app.compute_reduced_hessian_eigen(&mut hr, &mut w, &mut v) {
283            eprintln!("pounce: reduced-Hessian eigendecomp failed");
284            return None;
285        }
286        (Some(w), Some(v))
287    } else {
288        if !app.compute_reduced_hessian(&mut hr) {
289            eprintln!("pounce: reduced-Hessian computation failed");
290            return None;
291        }
292        (None, None)
293    };
294    let _ = cq;
295    Some(RedHessianResult {
296        var_indices,
297        hr,
298        eigenvalues,
299        eigenvectors,
300    })
301}
302
303/// Try to compute the parametric sensitivity step from the suffixes
304/// declared in the input `.nl`. Returns `None` (quietly) when any
305/// required suffix is missing — typical for `.nl` files that aren't
306/// sensitivity inputs.
307#[allow(clippy::too_many_arguments)]
308fn try_compute_sens_step(
309    data: &pounce_algorithm::ipopt_data::IpoptDataHandle,
310    cq: &pounce_algorithm::ipopt_cq::IpoptCqHandle,
311    nlp: &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
312    pd: Rc<RefCell<pounce_algorithm::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
313    suffixes: &NlSuffixes,
314    n_full: usize,
315    _m_full: usize,
316    x_nominal: &[Number],
317) -> Option<Vec<Number>> {
318    // Required suffixes. The "_1" suffix tier corresponds to upstream
319    // sIPOPT's `n_sens_steps=1` mode. Higher tiers (sens_state_2 etc.)
320    // are a Phase-2 follow-up.
321    let sens_state = suffixes.var_int.get("sens_state_1")?;
322    let sens_state_value = suffixes.var_real.get("sens_state_value_1")?;
323    let sens_init_constr = suffixes.con_int.get("sens_init_constr")?;
324
325    if sens_state.len() != n_full || sens_state_value.len() != n_full {
326        eprintln!("pounce: sens_state_1 / sens_state_value_1 length mismatch (expected {n_full})");
327        return None;
328    }
329
330    // Number of parameters and per-parameter (var_idx, constraint_idx)
331    // pairs. The integer suffix value is 1..n_params, indexing which
332    // parameter slot each variable / constraint maps to.
333    let n_params = sens_state.iter().copied().max().unwrap_or(0).max(0) as usize;
334    if n_params == 0 {
335        return None;
336    }
337
338    // For each parameter slot, find its variable index (from
339    // sens_state_1) and its pinning-constraint index (from
340    // sens_init_constr).
341    let mut param_var_idx: Vec<Option<usize>> = vec![None; n_params];
342    for (var_idx, &slot) in sens_state.iter().enumerate() {
343        if slot > 0 {
344            let s = slot as usize;
345            if s <= n_params {
346                param_var_idx[s - 1] = Some(var_idx);
347            }
348        }
349    }
350    let mut param_con_idx: Vec<Option<usize>> = vec![None; n_params];
351    for (con_idx, &slot) in sens_init_constr.iter().enumerate() {
352        if slot > 0 {
353            let s = slot as usize;
354            if s <= n_params {
355                param_con_idx[s - 1] = Some(con_idx);
356            }
357        }
358    }
359    for k in 0..n_params {
360        if param_var_idx[k].is_none() || param_con_idx[k].is_none() {
361            eprintln!(
362                "pounce: parameter {} missing sens_state_1 or sens_init_constr tag",
363                k + 1
364            );
365            return None;
366        }
367    }
368
369    // Build the SchurData rows: flat compound-vector index for each
370    // pinning constraint = n_x + n_s + c_block_idx (i.e. y_c[…] slot).
371    // Pounce's compound layout matches upstream's
372    // `MetadataMeasurement::GetInitialEqConstraints`
373    // (`ref/Ipopt/contrib/sIPOPT/src/SensMetadataMeasurement.cpp:69-83`).
374    // The full-g → c-block transform (needed when the c/d split
375    // reorders constraints) is the backsolver's canonical
376    // `map_pin_g_to_kkt_rows` (pounce#128 single source of truth).
377    let backsolver = PdSensBacksolver::new(data, cq, nlp, pd)
378        .map_err(|e| eprintln!("pounce: could not capture the KKT factor: {e}"))
379        .ok()?;
380    let pin_g: Vec<Index> = param_con_idx
381        .iter()
382        .map(|ci| ci.unwrap() as Index)
383        .collect();
384    let rows = match backsolver.map_pin_g_to_kkt_rows(&pin_g) {
385        Ok(r) => r,
386        Err(e) => {
387            eprintln!("pounce: {e}");
388            return None;
389        }
390    };
391    let signs: Vec<Index> = vec![1; n_params];
392    let a_data = IndexSchurData::from_parts(rows, signs).ok()?;
393
394    // Δp[k] = perturbed_value - current_value for parameter k. Both
395    // sides are read from the user's full-x array (length `n_full`); the
396    // caller passes `x_nominal` already lifted via
397    // `IpoptNlp::lift_x_to_full`, so indexing by the full-x var index
398    // works whether or not other variables were eliminated.
399    let mut delta_p: Vec<Number> = Vec::with_capacity(n_params);
400    for k in 0..n_params {
401        let vi = param_var_idx[k].unwrap();
402        delta_p.push(sens_state_value[vi] - x_nominal[vi]);
403    }
404    let n_full_pd = backsolver.dim();
405    let mut rhs_full = vec![0.0; n_full_pd];
406    a_data
407        .trans_multiply(&delta_p, &mut rhs_full)
408        .map_err(|e| eprintln!("pounce: trans_multiply error: {e:?}"))
409        .ok()?;
410    let mut dx_full = vec![0.0; n_full_pd];
411    if !backsolver.solve(&rhs_full, &mut dx_full) {
412        eprintln!("pounce: KKT backsolve failed");
413        return None;
414    }
415    Some(dx_full)
416}