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