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}