Skip to main content

pounce_sens_core/
sens_app.rs

1//! `SensApplication` — high-level entry point for sensitivity analysis.
2//!
3//! Port of upstream
4//! [`SensApplication.{hpp,cpp}`](../../../ref/Ipopt/contrib/sIPOPT/src/SensApplication.cpp).
5//!
6//! # Phase-D scope (this file)
7//!
8//! Ships the **skeleton** entry point and the option-key set that
9//! upstream registers (so user-side `ipopt.opt` files port cleanly).
10//! The orchestration runs the four-stage pipeline:
11//!
12//! 1. Construct an [`crate::IndexPCalculator`] from a converged
13//!    [`crate::SensBacksolver`] + an `A` SchurData.
14//! 2. Build a [`crate::DenseGenSchurDriver`] on top.
15//! 3. Use [`crate::StdStepCalc`] to compute the sensitivity step
16//!    or [`crate::reduced_hessian::compute_reduced_hessian`] to
17//!    extract the reduced Hessian.
18//!
19//! Phase B.2 is the missing piece: a real [`crate::SensBacksolver`]
20//! that wraps `pounce-algorithm::kkt::AugSystemSolver` so the
21//! application can be driven by a live pounce IPM solve rather than
22//! a synthetic dense LU.
23
24use crate::backsolver::SensBacksolver;
25use crate::p_calculator::IndexPCalculator;
26use crate::reduced_hessian::compute_reduced_hessian;
27use crate::schur_data::{IndexSchurData, SchurData};
28use crate::schur_driver::{DenseGenSchurDriver, SchurDriver};
29use crate::step_calc::{SensStepCalc, StdStepCalc};
30use pounce_common::types::Number;
31use pounce_linalg::symmetric_eigen;
32
33/// User-facing entry point for sensitivity analysis on a converged
34/// pounce solve.
35///
36/// Mirrors `Ipopt::SensApplication` from
37/// [`SensApplication.hpp:35-188`](../../../ref/Ipopt/contrib/sIPOPT/src/SensApplication.hpp).
38///
39/// Phase D ships only the **skeleton** — construction + Run-style
40/// dispatch over the Phase B.1 numerical components. The option-table
41/// integration with `pounce-algorithm::OptionsList` is via
42/// [`register_options`] below; calling code reads option values from
43/// the application's owning `OptionsList` and configures `SensOptions`
44/// before invoking `SensApplication::run_*`.
45pub struct SensApplication<B: SensBacksolver> {
46    /// The `A` schur data for the perturbation rows. For
47    /// reduced-Hessian use this also serves as the free-variable
48    /// selector.
49    a_data: IndexSchurData,
50    /// Converged backsolver against `K`. Phase B.1 uses
51    /// `DenseLuBacksolver` for tests; Phase B.2 wraps the real
52    /// `pounce-algorithm` aug system solver.
53    backsolver: B,
54    /// Pre-resolved option values controlling the sensitivity run.
55    options: SensOptions,
56}
57
58/// Numeric / boolean knobs that drive a `SensApplication`. The
59/// fields' names + defaults mirror the option keys registered in
60/// [`register_options`].
61#[derive(Debug, Clone, Copy)]
62pub struct SensOptions {
63    /// Whether to compute the reduced Hessian. Mapped from
64    /// `compute_red_hessian` (upstream
65    /// [`SensApplication.cpp:73-75`](../../../ref/Ipopt/contrib/sIPOPT/src/SensApplication.cpp)).
66    pub compute_red_hessian: bool,
67    /// Whether to run the sensitivity step. Mapped from `run_sens`
68    /// ([`SensApplication.cpp:80-83`](../../../ref/Ipopt/contrib/sIPOPT/src/SensApplication.cpp)).
69    pub run_sens: bool,
70    /// Number of parameter perturbations to step. Mapped from
71    /// `n_sens_steps` (default 1, upstream
72    /// [`SensApplication.cpp:60-62`](../../../ref/Ipopt/contrib/sIPOPT/src/SensApplication.cpp)).
73    ///
74    /// **Only `1` is implemented**, and nothing reads this field: the
75    /// pipeline below computes the single `sens_state_1` tier. Rather
76    /// than let a larger value round quietly down to one tier, the
77    /// `n_sens_steps` *option* is refused above its default by
78    /// [`pounce_algorithm::unimplemented_options`] (gh#677). The field
79    /// stays for shape-compatibility with upstream's `SensOptions`.
80    pub n_sens_steps: i32,
81    /// Objective scaling factor to apply when reporting the reduced
82    /// Hessian. Default 1.0; pounce's IPM-side scaling lands in
83    /// Phase B.2's algorithm-wrapper.
84    pub obj_scal: Number,
85    /// Whether to also compute the eigendecomposition of the reduced
86    /// Hessian. Mapped from `rh_eigendecomp` (upstream
87    /// [`SensReducedHessianCalculator.cpp:38`](../../../ref/Ipopt/contrib/sIPOPT/src/SensReducedHessianCalculator.cpp)).
88    pub rh_eigendecomp: bool,
89}
90
91impl Default for SensOptions {
92    fn default() -> Self {
93        Self {
94            compute_red_hessian: false,
95            run_sens: false,
96            n_sens_steps: 1,
97            obj_scal: 1.0,
98            rh_eigendecomp: false,
99        }
100    }
101}
102
103impl<B: SensBacksolver> SensApplication<B> {
104    /// Build a SensApplication from a converged backsolver, a
105    /// parameter-row SchurData, and pre-resolved options.
106    ///
107    /// Equivalent of upstream's `SensApplication::Run` setup
108    /// path ([`SensApplication.cpp:127-198`](../../../ref/Ipopt/contrib/sIPOPT/src/SensApplication.cpp))
109    /// but split so the construction is testable without the
110    /// `IpoptApplication` plumbing.
111    pub fn new(a_data: IndexSchurData, backsolver: B, options: SensOptions) -> Self {
112        Self {
113            a_data,
114            backsolver,
115            options,
116        }
117    }
118
119    /// Compute the reduced Hessian into the caller-supplied
120    /// row-/column-major buffer (column-major in pounce, matching
121    /// `DenseGenSchurDriver`). Buffer length must be `n²` where
122    /// `n = a_data.nrows()`. Mirrors the
123    /// `compute_red_hessian=true` branch of upstream
124    /// [`SensApplication::Run`](../../../ref/Ipopt/contrib/sIPOPT/src/SensApplication.cpp).
125    ///
126    /// Returns `false` if the underlying schur computation fails.
127    pub fn compute_reduced_hessian(&mut self, out: &mut [Number]) -> bool
128    where
129        B: Clone,
130    {
131        // We need ownership of the backsolver for the PCalculator;
132        // clone here so the application can be re-used for follow-up
133        // step computations. Phase B.2's real backsolver will be
134        // cheap to clone (Arc handle to a factored matrix); the
135        // synthetic DenseLuBacksolver is also cheap (Vec<f64>
136        // arithmetic, no large reusable resources).
137        let backsolver = self.backsolver.clone();
138        let mut pcalc = IndexPCalculator::new(backsolver, self.a_data.clone());
139        compute_reduced_hessian(&mut pcalc, &self.a_data, self.options.obj_scal, out)
140    }
141
142    /// Compute the reduced Hessian and its symmetric eigendecomposition
143    /// in one pass. `hr_out` is the column-major `n²` Hessian buffer
144    /// (same shape as [`Self::compute_reduced_hessian`]); `eigvals_out`
145    /// receives the `n` eigenvalues in ascending order; `eigvecs_out`
146    /// receives the `n²` column-major eigenvector matrix (column `j`
147    /// is the eigenvector for `eigvals_out[j]`, sign-pinned by
148    /// [`pounce_linalg::symmetric_eigen`] so the direction reproduces).
149    ///
150    /// Mirrors the `rh_eigendecomp=true` branch of upstream
151    /// [`SensReducedHessianCalculator::ComputeReducedHessian`](../../../ref/Ipopt/contrib/sIPOPT/src/SensReducedHessianCalculator.cpp).
152    ///
153    /// Returns `false` if either the Schur reduction or the
154    /// eigendecomposition fails (or any buffer is mis-sized).
155    pub fn compute_reduced_hessian_eigen(
156        &mut self,
157        hr_out: &mut [Number],
158        eigvals_out: &mut [Number],
159        eigvecs_out: &mut [Number],
160    ) -> bool
161    where
162        B: Clone,
163    {
164        let n = self.a_data.nrows() as usize;
165        if hr_out.len() != n * n || eigvals_out.len() != n || eigvecs_out.len() != n * n {
166            return false;
167        }
168        if !self.compute_reduced_hessian(hr_out) {
169            return false;
170        }
171        symmetric_eigen(hr_out, n, eigvals_out, eigvecs_out)
172    }
173
174    /// Compute one sensitivity step: given `rhs_u` (length
175    /// `a_data.nrows()`) the Schur-space parameter perturbation,
176    /// produce `du` (Schur-space step) and `dx_full` (KKT-space
177    /// step). Mirrors upstream's `run_sens=true` flow.
178    ///
179    /// `dx_full` must be the length of the backsolver's state
180    /// dimension. `rhs_u.len() == du.len() == a_data.nrows()`.
181    ///
182    /// Returns `false` if the Schur driver factor fails or the inner
183    /// backsolves fail.
184    pub fn run_sens_step(
185        &mut self,
186        b_data: &IndexSchurData,
187        rhs_u: &[Number],
188        du: &mut [Number],
189        dx_full: &mut [Number],
190    ) -> bool
191    where
192        B: Clone,
193    {
194        let backsolver = self.backsolver.clone();
195        let pcalc = IndexPCalculator::new(backsolver, self.a_data.clone());
196        let mut driver = DenseGenSchurDriver::<_, B>::new(pcalc);
197        if !driver.schur_build_and_factor(b_data) {
198            return false;
199        }
200        let step = StdStepCalc::new(&driver, driver.pcalc());
201        let ok = step.compute_step(rhs_u, du, dx_full);
202        ok
203    }
204
205    /// Compute the **parametric** sensitivity step
206    /// `Δw = K⁻¹ · Aᵀ · Δp` directly, without the Schur factor. This
207    /// is the no-bound-check branch of upstream
208    /// [`SensStdStepCalc::Step`](../../../ref/Ipopt/contrib/sIPOPT/src/SensStdStepCalc.cpp)
209    /// (lines 48–83): scatter the parameter perturbation onto the
210    /// y_c / x slots picked by `a_data`, then run one backsolve
211    /// against the converged KKT factor.
212    ///
213    /// `delta_p` has length `a_data.nrows()`; `dx_full` has length
214    /// `backsolver.dim()`. Unlike [`Self::run_sens_step`] this method
215    /// is the right one for the canonical "parametric Δx" use case —
216    /// the Schur factor's only role in sIPOPT's std flow is the
217    /// active-set bound-check refinement after a violating step, and
218    /// that refinement is a follow-up (sens_boundcheck = yes).
219    /// The right-hand side [`Self::parametric_step`] solves against,
220    /// without solving it. A release re-solves in a system the
221    /// converged factor does not describe, so it needs the right-hand
222    /// side rather than the step.
223    pub fn parametric_rhs(&self, delta_p: &[Number], out: &mut [Number]) -> bool {
224        if out.len() != self.backsolver.dim() || delta_p.len() != self.a_data.nrows() as usize {
225            return false;
226        }
227        out.iter_mut().for_each(|v| *v = 0.0);
228        self.a_data.trans_multiply(delta_p, out).is_ok()
229    }
230
231    pub fn parametric_step(&self, delta_p: &[Number], dx_full: &mut [Number]) -> bool {
232        let n_full = self.backsolver.dim();
233        if dx_full.len() != n_full {
234            return false;
235        }
236        if delta_p.len() != self.a_data.nrows() as usize {
237            return false;
238        }
239        let mut rhs_full = vec![0.0; n_full];
240        if self.a_data.trans_multiply(delta_p, &mut rhs_full).is_err() {
241            return false;
242        }
243        self.backsolver.solve(&rhs_full, dx_full)
244    }
245
246    /// Borrow the resolved option set.
247    pub fn options(&self) -> &SensOptions {
248        &self.options
249    }
250}
251
252/// Register sIPOPT's option keys against pounce's
253/// `RegisteredOptions`. Mirrors upstream
254/// [`SensApplication::RegisterOptions`](../../../ref/Ipopt/contrib/sIPOPT/src/SensApplication.cpp)
255/// (lines 54–117).
256///
257/// The same key set is also registered by
258/// `pounce-algorithm::upstream_options::register_sipopt_options`
259/// (called transitively from `register_all_upstream_options`) so any
260/// `IpoptApplication::new()` accepts these keys out of the box —
261/// `pounce-cli` and `cutest_suite` recognize them without extra
262/// wiring. This standalone copy stays for callers building a
263/// `RegisteredOptions` without `pounce-algorithm`'s defaults (e.g.
264/// integration tests inside `pounce-sensitivity` itself). Keep the
265/// two blocks in lockstep when adding or renaming options.
266pub fn register_options(
267    r: &pounce_common::reg_options::RegisteredOptions,
268) -> Result<(), pounce_common::exception::SolverException> {
269    r.set_registering_category("sIPOPT");
270    r.add_lower_bounded_integer_option(
271        "n_sens_steps",
272        "Number of sensitivity steps to perform per converged solve.",
273        0,
274        1,
275        "Number of parameter perturbations to step through. Mirrors upstream `n_sens_steps` (SensApplication.cpp:60).",
276    )?;
277    r.add_bool_option(
278        "compute_red_hessian",
279        "Compute the reduced Hessian at the converged solution.",
280        false,
281        "When set, after the IPM converges pounce-sensitivity assembles `H_R = obj_scal · B K⁻¹ Bᵀ` with B selecting the free variables. Output is written to the user via the sIPOPT C ABI (Phase D follow-up). Mirrors upstream `compute_red_hessian` (SensApplication.cpp:73).",
282    )?;
283    r.add_bool_option(
284        "run_sens",
285        "Run the sensitivity step calc after convergence.",
286        false,
287        "When set, pounce-sensitivity computes a forward-sensitivity step for the parameter perturbation declared via TNLP suffixes. Mirrors upstream `run_sens` (SensApplication.cpp:80).",
288    )?;
289    r.add_bool_option(
290        "sens_boundcheck",
291        "Verify the sensitivity step does not violate bound multipliers.",
292        false,
293        "Mirrors upstream `sens_boundcheck` (SensApplication.cpp:63).",
294    )?;
295    r.add_lower_bounded_number_option(
296        "sens_bound_eps",
297        "Safety margin enforced when sens_boundcheck is set.",
298        0.0,
299        true,
300        1.0e-3,
301        "Mirrors upstream `sens_bound_eps` (SensApplication.cpp:67).",
302    )?;
303    r.add_lower_bounded_number_option(
304        "sens_max_pdpert",
305        "Maximum primal-dual perturbation accepted in the sensitivity step.",
306        0.0,
307        true,
308        1.0e-3,
309        "Mirrors upstream `sens_max_pdpert` (SensApplication.cpp:98).",
310    )?;
311    r.add_bool_option(
312        "rh_eigendecomp",
313        "Compute eigendecomposition of the reduced Hessian.",
314        false,
315        "Mirrors upstream `rh_eigendecomp` (SensReducedHessianCalculator.cpp:38). When set together with `compute_red_hessian=yes`, pounce-sensitivity returns the eigenvalues and eigenvectors of `H_R` alongside the matrix itself (cyclic Jacobi rotation, pure Rust).",
316    )?;
317    Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::backsolver::DenseLuBacksolver;
324    use crate::schur_data::IndexSchurData;
325
326    /// End-to-end Phase-D smoke: build a SensApplication, ask for the
327    /// reduced Hessian, verify it matches the same closed-form answer
328    /// as the direct `compute_reduced_hessian` call in
329    /// `reduced_hessian::tests`.
330    #[test]
331    fn sens_application_computes_reduced_hessian() {
332        #[rustfmt::skip]
333        let k = vec![
334             2.0, -1.0,  0.0,
335            -1.0,  2.0, -1.0,
336             0.0, -1.0,  2.0,
337        ];
338        let backsolver = DenseLuBacksolver::from_dense(3, &k).unwrap();
339        let a = IndexSchurData::from_parts(vec![0, 2], vec![1, 1]).unwrap();
340        let opts = SensOptions {
341            compute_red_hessian: true,
342            obj_scal: 1.0,
343            ..SensOptions::default()
344        };
345        let mut app = SensApplication::new(a, backsolver, opts);
346        let mut hr = vec![0.0; 4];
347        assert!(app.compute_reduced_hessian(&mut hr));
348        // Same expected values as reduced_hessian::tests::reduced_hessian_matches_kinv_submatrix.
349        assert!((hr[0] - 0.75).abs() < 1e-12);
350        assert!((hr[1] - 0.25).abs() < 1e-12);
351        assert!((hr[2] - 0.25).abs() < 1e-12);
352        assert!((hr[3] - 0.75).abs() < 1e-12);
353    }
354
355    #[test]
356    fn sens_application_runs_step() {
357        #[rustfmt::skip]
358        let k = vec![
359             2.0, -1.0,  0.0,
360            -1.0,  2.0, -1.0,
361             0.0, -1.0,  2.0,
362        ];
363        let backsolver = DenseLuBacksolver::from_dense(3, &k).unwrap();
364        let a = IndexSchurData::from_parts(vec![0, 2], vec![1, 1]).unwrap();
365        let b = IndexSchurData::from_parts(vec![0, 2], vec![1, 1]).unwrap();
366        let opts = SensOptions {
367            run_sens: true,
368            ..SensOptions::default()
369        };
370        let mut app = SensApplication::new(a, backsolver, opts);
371        let rhs_u = [1.0, 0.0];
372        let mut du = [0.0; 2];
373        let mut dx = [0.0; 3];
374        assert!(app.run_sens_step(&b, &rhs_u, &mut du, &mut dx));
375        // Same expected values as step_calc::tests::std_step_calc_runs_two_step_pipeline.
376        assert!((du[0] - (-1.5)).abs() < 1e-10);
377        assert!((du[1] - 0.5).abs() < 1e-10);
378        assert!((dx[0] - (-1.0)).abs() < 1e-10);
379        assert!((dx[1] - (-0.5)).abs() < 1e-10);
380        assert!((dx[2] - 0.0).abs() < 1e-10);
381    }
382
383    #[test]
384    fn sens_application_computes_reduced_hessian_eigen() {
385        // Same fixture as the plain reduced-Hessian test: expected
386        // H_R = [[3/4, 1/4], [1/4, 3/4]] (rows {0,2} of K⁻¹).
387        // Eigenvalues: 1/2 and 1, eigenvectors [1,-1]/√2 and
388        // [1,1]/√2.
389        #[rustfmt::skip]
390        let k = vec![
391             2.0, -1.0,  0.0,
392            -1.0,  2.0, -1.0,
393             0.0, -1.0,  2.0,
394        ];
395        let backsolver = DenseLuBacksolver::from_dense(3, &k).unwrap();
396        let a = IndexSchurData::from_parts(vec![0, 2], vec![1, 1]).unwrap();
397        let opts = SensOptions {
398            compute_red_hessian: true,
399            rh_eigendecomp: true,
400            ..SensOptions::default()
401        };
402        let mut app = SensApplication::new(a, backsolver, opts);
403        let mut hr = vec![0.0; 4];
404        let mut w = vec![0.0; 2];
405        let mut v = vec![0.0; 4];
406        assert!(app.compute_reduced_hessian_eigen(&mut hr, &mut w, &mut v));
407
408        assert!((w[0] - 0.5).abs() < 1e-12, "eig0 = {}", w[0]);
409        assert!((w[1] - 1.0).abs() < 1e-12, "eig1 = {}", w[1]);
410
411        // Verify H_R · v_j = λ_j · v_j for each column.
412        for j in 0..2 {
413            let v0 = v[2 * j];
414            let v1 = v[2 * j + 1];
415            let av0 = hr[0] * v0 + hr[2] * v1;
416            let av1 = hr[1] * v0 + hr[3] * v1;
417            assert!((av0 - w[j] * v0).abs() < 1e-10);
418            assert!((av1 - w[j] * v1).abs() < 1e-10);
419        }
420    }
421
422    #[test]
423    fn register_options_round_trips_through_options_list() {
424        // The option keys should be registerable against a fresh
425        // RegisteredOptions without collision; this exercises the
426        // public register_options() path.
427        let r = pounce_common::reg_options::RegisteredOptions::default();
428        register_options(&r).expect("registration must succeed");
429    }
430}