Skip to main content

Solver

Struct Solver 

Source
pub struct Solver { /* private fields */ }
Expand description

Session-style solver: holds an IpoptApplication, its TNLP, and the converged factor between calls.

Implementations§

Source§

impl Solver

Source

pub fn new(app: IpoptApplication, tnlp: Rc<RefCell<dyn TNLP>>) -> Self

Build a new session. The app should already have its options configured and initialize() called.

Examples found in repository?
examples/sensitivity_session.rs (line 166)
160fn main() {
161    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
162        eta1: 5.0,
163        eta2: 1.0,
164    }));
165
166    let mut solver = Solver::new(make_app(), tnlp);
167    let status = solver.solve();
168    println!("solve status: {status:?}");
169    assert!(solver.converged().is_some(), "solver did not converge");
170
171    let pins = vec![2 as Index, 3];
172
173    // Two cheap parametric steps against the same factor.
174    for deltas in &[vec![-0.5, 0.0], vec![0.0, 0.2]] {
175        let dx = solver
176            .parametric_step(&pins, deltas)
177            .expect("parametric_step ok");
178        println!("parametric_step(deltas={deltas:?}) -> dx = {dx:?}");
179    }
180
181    // Reduced Hessian over the same pinned-row set.
182    let hr = solver
183        .compute_reduced_hessian(&pins, 1.0)
184        .expect("reduced Hessian ok");
185    println!("reduced Hessian (2x2, column-major) = {hr:?}");
186
187    // Raw back-solve against a zero RHS — must come back zero.
188    let dim = solver.kkt_dim().expect("kkt_dim available");
189    let rhs = vec![0.0; dim];
190    let mut lhs = vec![1.0; dim];
191    solver.kkt_solve(&rhs, &mut lhs).expect("kkt_solve ok");
192    let max_abs = lhs.iter().fold(0.0_f64, |a, b| a.max(b.abs()));
193    println!("kkt_solve(0) max |lhs| = {max_abs:e}");
194    assert!(max_abs < 1e-10);
195}
More examples
Hide additional examples
examples/parametric_mpc.rs (line 178)
171fn main() {
172    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
173        eta1: 5.0,
174        eta2: 1.0,
175    }));
176
177    // One full IPM solve at the nominal parameter.
178    let mut solver = Solver::new(make_app(), tnlp);
179    let t0 = Instant::now();
180    let status = solver.solve();
181    let solve_dt = t0.elapsed();
182    println!(
183        "IPM solve at nominal (eta1=5.0, eta2=1.0): status={status:?}, {:.3} ms",
184        solve_dt.as_secs_f64() * 1e3
185    );
186    assert!(solver.converged().is_some());
187
188    // 10 parametric steps as eta2 sweeps from 1.0 to 1.5. Each one is
189    // a back-solve against the held factor — no IPM iteration.
190    let pins = vec![2 as Index, 3];
191    let mut total_step_dt = std::time::Duration::ZERO;
192    let mut steps = Vec::new();
193    for k in 1..=10 {
194        let d_eta2 = 0.05 * k as f64;
195        let t = Instant::now();
196        let dx = solver
197            .parametric_step(&pins, &[0.0, d_eta2])
198            .expect("parametric_step ok");
199        total_step_dt += t.elapsed();
200        steps.push((d_eta2, dx));
201    }
202
203    println!("\nparametric steps against the held factor:");
204    for (d_eta2, dx) in &steps {
205        println!(
206            "  Δeta2 = {d_eta2:+.2}  -> Δx_primal = [{:+.5}, {:+.5}, {:+.5}]",
207            dx[0], dx[1], dx[2]
208        );
209    }
210    let avg_step_us = total_step_dt.as_secs_f64() * 1e6 / steps.len() as f64;
211    println!(
212        "\n10 parametric steps: total {:.3} ms, mean {avg_step_us:.1} µs/step",
213        total_step_dt.as_secs_f64() * 1e3
214    );
215    println!(
216        "\nRatio: each parametric step is roughly {:.0}x cheaper than the IPM solve.",
217        solve_dt.as_secs_f64() / (total_step_dt.as_secs_f64() / steps.len() as f64),
218    );
219}
examples/sensitivity_factor_reuse_bench.rs (line 167)
157fn main() {
158    let deltas: Vec<Vec<Number>> = (1..=N).map(|k| vec![0.0, 0.01 * k as Number]).collect();
159    let pins = vec![2 as Index, 3];
160
161    // (1) Held-factor path: 1 solve + N parametric steps.
162    let t = Instant::now();
163    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
164        eta1: 5.0,
165        eta2: 1.0,
166    }));
167    let mut solver = Solver::new(make_app(), tnlp);
168    solver.solve();
169    let solve_dt = t.elapsed();
170    let mut held_steps = Vec::with_capacity(N);
171    let t = Instant::now();
172    for d in &deltas {
173        let dx = solver
174            .parametric_step(&pins, d)
175            .expect("parametric_step ok");
176        held_steps.push(dx);
177    }
178    let held_step_dt = t.elapsed();
179    let held_total = solve_dt + held_step_dt;
180
181    // (2) Cold path: N fresh SensSolve runs (one full IPM each).
182    let mut cold_steps = Vec::with_capacity(N);
183    let t = Instant::now();
184    for d in &deltas {
185        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
186            eta1: 5.0,
187            eta2: 1.0,
188        }));
189        let mut app = make_app();
190        let r = SensSolve::new(pins.clone())
191            .with_deltas(d.clone())
192            .run(&mut app, tnlp);
193        cold_steps.push(r.dx.expect("dx"));
194    }
195    let cold_total = t.elapsed();
196
197    // Cross-check: held vs cold dx should agree.
198    let mut max_err = 0.0_f64;
199    for (h, c) in held_steps.iter().zip(cold_steps.iter()) {
200        for (a, b) in h.iter().zip(c.iter()) {
201            max_err = max_err.max((a - b).abs());
202        }
203    }
204
205    println!("Workload: 1 IPM solve + {N} parametric steps (Δeta2 sweep).");
206    println!();
207    println!(
208        "Held-factor path : solve {:.2} ms + {N} steps {:.2} ms = {:.2} ms total",
209        solve_dt.as_secs_f64() * 1e3,
210        held_step_dt.as_secs_f64() * 1e3,
211        held_total.as_secs_f64() * 1e3,
212    );
213    println!(
214        "Cold-restart path:                          {N} fresh solves = {:.2} ms total",
215        cold_total.as_secs_f64() * 1e3,
216    );
217    println!();
218    println!(
219        "Speedup of held-factor over cold-restart: {:.1}x",
220        cold_total.as_secs_f64() / held_total.as_secs_f64()
221    );
222    println!("Numerical agreement (held vs cold dx): max |err| = {max_err:.2e}");
223}
examples/rh_orientation_check.rs (line 143)
132fn main() {
133    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(PinnedQuadratic { p0: 1.0, p1: 2.0 }));
134    let mut app = IpoptApplication::new();
135    app.options_mut()
136        .set_integer_value("print_level", 0, true, false)
137        .unwrap();
138    app.options_mut()
139        .set_string_value("sb", "yes", true, false)
140        .unwrap();
141    app.initialize().unwrap();
142
143    let mut solver = Solver::new(app, tnlp);
144    let status = solver.solve();
145    assert!(
146        matches!(
147            status,
148            ApplicationReturnStatus::SolveSucceeded
149                | ApplicationReturnStatus::SolvedToAcceptableLevel
150        ),
151        "solve failed: {status:?}"
152    );
153
154    let (hr, vals, vecs) = solver
155        .compute_reduced_hessian_eigen(&[0, 1], 1.0)
156        .expect("reduced Hessian");
157
158    println!("model:  min x0² + x1² + x0·x1  s.t.  x0 = 1, x1 = 2");
159    println!("H    = [[2, 1], [1, 2]]   (eigenvalues 1 and 3)");
160    println!();
161    println!("compute_reduced_hessian(pins=[0, 1]):");
162    for i in 0..2 {
163        println!("  [{:>9.6}, {:>9.6}]", hr[i], hr[i + 2]);
164    }
165    println!(
166        "  eigenvalues (ascending) = [{:>9.6}, {:>9.6}]",
167        vals[0], vals[1]
168    );
169    for j in 0..2 {
170        println!(
171            "  eigenvector[{j}]          = [{:>9.6}, {:>9.6}]",
172            vecs[2 * j],
173            vecs[2 * j + 1]
174        );
175    }
176    println!();
177
178    // Three candidates, discriminated by magnitude as well as sign:
179    // inv(H) = [[2/3, -1/3], [-1/3, 2/3]].
180    let h_inv: [Number; 4] = [2.0 / 3.0, -1.0 / 3.0, -1.0 / 3.0, 2.0 / 3.0];
181    let candidates: [(&str, [Number; 4]); 3] = [
182        ("+H_R", H),
183        ("-H_R", [-H[0], -H[1], -H[2], -H[3]]),
184        ("H_R⁻¹", h_inv),
185    ];
186    for (name, want) in candidates {
187        let err = (0..4)
188            .map(|k| (hr[k] - want[k]).abs())
189            .fold(0.0 as Number, Number::max);
190        println!(
191            "  vs {name:<6} max|Δ| = {err:.3e}  {}",
192            if err < 1e-7 { "← MATCH" } else { "" }
193        );
194    }
195    println!();
196    println!(
197        "So the ascending spectrum runs STIFFEST first: {:.6} is the curvature-3",
198        vals[0]
199    );
200    println!(
201        "mode and {:.6} the curvature-1 (soft) one — the reverse of the",
202        vals[1]
203    );
204    println!("order a caller reading `+H_R` would assume.");
205}
Source

pub fn app(&self) -> &IpoptApplication

Borrow the underlying IpoptApplication (e.g. to read its options table after a solve). Mutation between solve calls is supported via Self::app_mut.

Source

pub fn app_mut(&mut self) -> &mut IpoptApplication

Mutable borrow of the underlying IpoptApplication. Useful for reconfiguring options before a follow-up solve(). Note that changing options that affect the KKT linear system between calls will invalidate the cached factor; the next solve() rebuilds it.

Source

pub fn solve(&mut self) -> ApplicationReturnStatus

Run the IPM to convergence. On a successful solve the ConvergedState (including the KKT backsolver) is stashed inside the Solver and accessible via Self::converged.

Each call to solve() overwrites the previous converged state; the previously held factor is dropped.

Examples found in repository?
examples/sensitivity_session.rs (line 167)
160fn main() {
161    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
162        eta1: 5.0,
163        eta2: 1.0,
164    }));
165
166    let mut solver = Solver::new(make_app(), tnlp);
167    let status = solver.solve();
168    println!("solve status: {status:?}");
169    assert!(solver.converged().is_some(), "solver did not converge");
170
171    let pins = vec![2 as Index, 3];
172
173    // Two cheap parametric steps against the same factor.
174    for deltas in &[vec![-0.5, 0.0], vec![0.0, 0.2]] {
175        let dx = solver
176            .parametric_step(&pins, deltas)
177            .expect("parametric_step ok");
178        println!("parametric_step(deltas={deltas:?}) -> dx = {dx:?}");
179    }
180
181    // Reduced Hessian over the same pinned-row set.
182    let hr = solver
183        .compute_reduced_hessian(&pins, 1.0)
184        .expect("reduced Hessian ok");
185    println!("reduced Hessian (2x2, column-major) = {hr:?}");
186
187    // Raw back-solve against a zero RHS — must come back zero.
188    let dim = solver.kkt_dim().expect("kkt_dim available");
189    let rhs = vec![0.0; dim];
190    let mut lhs = vec![1.0; dim];
191    solver.kkt_solve(&rhs, &mut lhs).expect("kkt_solve ok");
192    let max_abs = lhs.iter().fold(0.0_f64, |a, b| a.max(b.abs()));
193    println!("kkt_solve(0) max |lhs| = {max_abs:e}");
194    assert!(max_abs < 1e-10);
195}
More examples
Hide additional examples
examples/parametric_mpc.rs (line 180)
171fn main() {
172    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
173        eta1: 5.0,
174        eta2: 1.0,
175    }));
176
177    // One full IPM solve at the nominal parameter.
178    let mut solver = Solver::new(make_app(), tnlp);
179    let t0 = Instant::now();
180    let status = solver.solve();
181    let solve_dt = t0.elapsed();
182    println!(
183        "IPM solve at nominal (eta1=5.0, eta2=1.0): status={status:?}, {:.3} ms",
184        solve_dt.as_secs_f64() * 1e3
185    );
186    assert!(solver.converged().is_some());
187
188    // 10 parametric steps as eta2 sweeps from 1.0 to 1.5. Each one is
189    // a back-solve against the held factor — no IPM iteration.
190    let pins = vec![2 as Index, 3];
191    let mut total_step_dt = std::time::Duration::ZERO;
192    let mut steps = Vec::new();
193    for k in 1..=10 {
194        let d_eta2 = 0.05 * k as f64;
195        let t = Instant::now();
196        let dx = solver
197            .parametric_step(&pins, &[0.0, d_eta2])
198            .expect("parametric_step ok");
199        total_step_dt += t.elapsed();
200        steps.push((d_eta2, dx));
201    }
202
203    println!("\nparametric steps against the held factor:");
204    for (d_eta2, dx) in &steps {
205        println!(
206            "  Δeta2 = {d_eta2:+.2}  -> Δx_primal = [{:+.5}, {:+.5}, {:+.5}]",
207            dx[0], dx[1], dx[2]
208        );
209    }
210    let avg_step_us = total_step_dt.as_secs_f64() * 1e6 / steps.len() as f64;
211    println!(
212        "\n10 parametric steps: total {:.3} ms, mean {avg_step_us:.1} µs/step",
213        total_step_dt.as_secs_f64() * 1e3
214    );
215    println!(
216        "\nRatio: each parametric step is roughly {:.0}x cheaper than the IPM solve.",
217        solve_dt.as_secs_f64() / (total_step_dt.as_secs_f64() / steps.len() as f64),
218    );
219}
examples/sensitivity_factor_reuse_bench.rs (line 168)
157fn main() {
158    let deltas: Vec<Vec<Number>> = (1..=N).map(|k| vec![0.0, 0.01 * k as Number]).collect();
159    let pins = vec![2 as Index, 3];
160
161    // (1) Held-factor path: 1 solve + N parametric steps.
162    let t = Instant::now();
163    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
164        eta1: 5.0,
165        eta2: 1.0,
166    }));
167    let mut solver = Solver::new(make_app(), tnlp);
168    solver.solve();
169    let solve_dt = t.elapsed();
170    let mut held_steps = Vec::with_capacity(N);
171    let t = Instant::now();
172    for d in &deltas {
173        let dx = solver
174            .parametric_step(&pins, d)
175            .expect("parametric_step ok");
176        held_steps.push(dx);
177    }
178    let held_step_dt = t.elapsed();
179    let held_total = solve_dt + held_step_dt;
180
181    // (2) Cold path: N fresh SensSolve runs (one full IPM each).
182    let mut cold_steps = Vec::with_capacity(N);
183    let t = Instant::now();
184    for d in &deltas {
185        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
186            eta1: 5.0,
187            eta2: 1.0,
188        }));
189        let mut app = make_app();
190        let r = SensSolve::new(pins.clone())
191            .with_deltas(d.clone())
192            .run(&mut app, tnlp);
193        cold_steps.push(r.dx.expect("dx"));
194    }
195    let cold_total = t.elapsed();
196
197    // Cross-check: held vs cold dx should agree.
198    let mut max_err = 0.0_f64;
199    for (h, c) in held_steps.iter().zip(cold_steps.iter()) {
200        for (a, b) in h.iter().zip(c.iter()) {
201            max_err = max_err.max((a - b).abs());
202        }
203    }
204
205    println!("Workload: 1 IPM solve + {N} parametric steps (Δeta2 sweep).");
206    println!();
207    println!(
208        "Held-factor path : solve {:.2} ms + {N} steps {:.2} ms = {:.2} ms total",
209        solve_dt.as_secs_f64() * 1e3,
210        held_step_dt.as_secs_f64() * 1e3,
211        held_total.as_secs_f64() * 1e3,
212    );
213    println!(
214        "Cold-restart path:                          {N} fresh solves = {:.2} ms total",
215        cold_total.as_secs_f64() * 1e3,
216    );
217    println!();
218    println!(
219        "Speedup of held-factor over cold-restart: {:.1}x",
220        cold_total.as_secs_f64() / held_total.as_secs_f64()
221    );
222    println!("Numerical agreement (held vs cold dx): max |err| = {max_err:.2e}");
223}
examples/rh_orientation_check.rs (line 144)
132fn main() {
133    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(PinnedQuadratic { p0: 1.0, p1: 2.0 }));
134    let mut app = IpoptApplication::new();
135    app.options_mut()
136        .set_integer_value("print_level", 0, true, false)
137        .unwrap();
138    app.options_mut()
139        .set_string_value("sb", "yes", true, false)
140        .unwrap();
141    app.initialize().unwrap();
142
143    let mut solver = Solver::new(app, tnlp);
144    let status = solver.solve();
145    assert!(
146        matches!(
147            status,
148            ApplicationReturnStatus::SolveSucceeded
149                | ApplicationReturnStatus::SolvedToAcceptableLevel
150        ),
151        "solve failed: {status:?}"
152    );
153
154    let (hr, vals, vecs) = solver
155        .compute_reduced_hessian_eigen(&[0, 1], 1.0)
156        .expect("reduced Hessian");
157
158    println!("model:  min x0² + x1² + x0·x1  s.t.  x0 = 1, x1 = 2");
159    println!("H    = [[2, 1], [1, 2]]   (eigenvalues 1 and 3)");
160    println!();
161    println!("compute_reduced_hessian(pins=[0, 1]):");
162    for i in 0..2 {
163        println!("  [{:>9.6}, {:>9.6}]", hr[i], hr[i + 2]);
164    }
165    println!(
166        "  eigenvalues (ascending) = [{:>9.6}, {:>9.6}]",
167        vals[0], vals[1]
168    );
169    for j in 0..2 {
170        println!(
171            "  eigenvector[{j}]          = [{:>9.6}, {:>9.6}]",
172            vecs[2 * j],
173            vecs[2 * j + 1]
174        );
175    }
176    println!();
177
178    // Three candidates, discriminated by magnitude as well as sign:
179    // inv(H) = [[2/3, -1/3], [-1/3, 2/3]].
180    let h_inv: [Number; 4] = [2.0 / 3.0, -1.0 / 3.0, -1.0 / 3.0, 2.0 / 3.0];
181    let candidates: [(&str, [Number; 4]); 3] = [
182        ("+H_R", H),
183        ("-H_R", [-H[0], -H[1], -H[2], -H[3]]),
184        ("H_R⁻¹", h_inv),
185    ];
186    for (name, want) in candidates {
187        let err = (0..4)
188            .map(|k| (hr[k] - want[k]).abs())
189            .fold(0.0 as Number, Number::max);
190        println!(
191            "  vs {name:<6} max|Δ| = {err:.3e}  {}",
192            if err < 1e-7 { "← MATCH" } else { "" }
193        );
194    }
195    println!();
196    println!(
197        "So the ascending spectrum runs STIFFEST first: {:.6} is the curvature-3",
198        vals[0]
199    );
200    println!(
201        "mode and {:.6} the curvature-1 (soft) one — the reverse of the",
202        vals[1]
203    );
204    println!("order a caller reading `+H_R` would assume.");
205}
Source

pub fn converged(&self) -> Option<Ref<'_, ConvergedState>>

Borrow the converged state, if a successful solve has been run. Returns None if no solve has run or if the most recent solve failed before reaching convergence.

Examples found in repository?
examples/sensitivity_session.rs (line 169)
160fn main() {
161    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
162        eta1: 5.0,
163        eta2: 1.0,
164    }));
165
166    let mut solver = Solver::new(make_app(), tnlp);
167    let status = solver.solve();
168    println!("solve status: {status:?}");
169    assert!(solver.converged().is_some(), "solver did not converge");
170
171    let pins = vec![2 as Index, 3];
172
173    // Two cheap parametric steps against the same factor.
174    for deltas in &[vec![-0.5, 0.0], vec![0.0, 0.2]] {
175        let dx = solver
176            .parametric_step(&pins, deltas)
177            .expect("parametric_step ok");
178        println!("parametric_step(deltas={deltas:?}) -> dx = {dx:?}");
179    }
180
181    // Reduced Hessian over the same pinned-row set.
182    let hr = solver
183        .compute_reduced_hessian(&pins, 1.0)
184        .expect("reduced Hessian ok");
185    println!("reduced Hessian (2x2, column-major) = {hr:?}");
186
187    // Raw back-solve against a zero RHS — must come back zero.
188    let dim = solver.kkt_dim().expect("kkt_dim available");
189    let rhs = vec![0.0; dim];
190    let mut lhs = vec![1.0; dim];
191    solver.kkt_solve(&rhs, &mut lhs).expect("kkt_solve ok");
192    let max_abs = lhs.iter().fold(0.0_f64, |a, b| a.max(b.abs()));
193    println!("kkt_solve(0) max |lhs| = {max_abs:e}");
194    assert!(max_abs < 1e-10);
195}
More examples
Hide additional examples
examples/parametric_mpc.rs (line 186)
171fn main() {
172    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
173        eta1: 5.0,
174        eta2: 1.0,
175    }));
176
177    // One full IPM solve at the nominal parameter.
178    let mut solver = Solver::new(make_app(), tnlp);
179    let t0 = Instant::now();
180    let status = solver.solve();
181    let solve_dt = t0.elapsed();
182    println!(
183        "IPM solve at nominal (eta1=5.0, eta2=1.0): status={status:?}, {:.3} ms",
184        solve_dt.as_secs_f64() * 1e3
185    );
186    assert!(solver.converged().is_some());
187
188    // 10 parametric steps as eta2 sweeps from 1.0 to 1.5. Each one is
189    // a back-solve against the held factor — no IPM iteration.
190    let pins = vec![2 as Index, 3];
191    let mut total_step_dt = std::time::Duration::ZERO;
192    let mut steps = Vec::new();
193    for k in 1..=10 {
194        let d_eta2 = 0.05 * k as f64;
195        let t = Instant::now();
196        let dx = solver
197            .parametric_step(&pins, &[0.0, d_eta2])
198            .expect("parametric_step ok");
199        total_step_dt += t.elapsed();
200        steps.push((d_eta2, dx));
201    }
202
203    println!("\nparametric steps against the held factor:");
204    for (d_eta2, dx) in &steps {
205        println!(
206            "  Δeta2 = {d_eta2:+.2}  -> Δx_primal = [{:+.5}, {:+.5}, {:+.5}]",
207            dx[0], dx[1], dx[2]
208        );
209    }
210    let avg_step_us = total_step_dt.as_secs_f64() * 1e6 / steps.len() as f64;
211    println!(
212        "\n10 parametric steps: total {:.3} ms, mean {avg_step_us:.1} µs/step",
213        total_step_dt.as_secs_f64() * 1e3
214    );
215    println!(
216        "\nRatio: each parametric step is roughly {:.0}x cheaper than the IPM solve.",
217        solve_dt.as_secs_f64() / (total_step_dt.as_secs_f64() / steps.len() as f64),
218    );
219}
Source

pub fn kkt_dim(&self) -> Option<usize>

Total dimension of the compound KKT vector (sum of block_dims). Returns None if no converged factor is held.

Examples found in repository?
examples/sensitivity_session.rs (line 188)
160fn main() {
161    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
162        eta1: 5.0,
163        eta2: 1.0,
164    }));
165
166    let mut solver = Solver::new(make_app(), tnlp);
167    let status = solver.solve();
168    println!("solve status: {status:?}");
169    assert!(solver.converged().is_some(), "solver did not converge");
170
171    let pins = vec![2 as Index, 3];
172
173    // Two cheap parametric steps against the same factor.
174    for deltas in &[vec![-0.5, 0.0], vec![0.0, 0.2]] {
175        let dx = solver
176            .parametric_step(&pins, deltas)
177            .expect("parametric_step ok");
178        println!("parametric_step(deltas={deltas:?}) -> dx = {dx:?}");
179    }
180
181    // Reduced Hessian over the same pinned-row set.
182    let hr = solver
183        .compute_reduced_hessian(&pins, 1.0)
184        .expect("reduced Hessian ok");
185    println!("reduced Hessian (2x2, column-major) = {hr:?}");
186
187    // Raw back-solve against a zero RHS — must come back zero.
188    let dim = solver.kkt_dim().expect("kkt_dim available");
189    let rhs = vec![0.0; dim];
190    let mut lhs = vec![1.0; dim];
191    solver.kkt_solve(&rhs, &mut lhs).expect("kkt_solve ok");
192    let max_abs = lhs.iter().fold(0.0_f64, |a, b| a.max(b.abs()));
193    println!("kkt_solve(0) max |lhs| = {max_abs:e}");
194    assert!(max_abs < 1e-10);
195}
Source

pub fn block_dims(&self) -> Option<[usize; 8]>

Block dimensions of the compound KKT vector in (x, s, y_c, y_d, z_l, z_u, v_l, v_u) order. Returns None if no converged factor is held.

Source

pub fn classify_activity(&self) -> Result<ActivityReport, SolverError>

Classify every bounded variable and every finite-bounded inequality row of the converged solve by activity: see crate::activity and dev-notes/covariance-information-roadmap.md item 0 (gh #362).

Requires the held solve to have run with bound_relax_factor=0 (the Ipopt default is 1e-8): with relaxed bounds the solver’s slacks are measured against perturbed bounds, and the complementarity products the classifier reads no longer track μ.

The guard reads ConvergedState::bound_relax_factor — the value that solve ran under — not the application’s current options. Setting the option after the fact neither unlocks a state whose bounds were relaxed nor invalidates one whose bounds were not; re-solve to change the answer.

§Neither classes’ q is a reduced curvature

A variable’s ratio is Σ_i/|H_ii|, and at a kink the multiplier is generated by the curvature reduced along that coordinate, not by the diagonal. The two agree only where the coordinate is decoupled, so a genuine kink coupled to a neighbour reads AMBIGUOUS here at any tolerance (gh#763). Do not read that class as “probably not a kink”: use Self::reduced_activity, which normalizes by the reduced curvature at one back-solve per coordinate.

A row’s ratio divides by the curvature along the row’s own gradient instead, which is a genuine directional curvature but still not a reduced one, so the same warning and the same remedy apply there: its ratio is reduced/directional and Self::reduced_row_activity answers the kink question (gh#804).

Source

pub fn reduced_activity( &self, user_vars: &[usize], ) -> Result<ReducedActivityReport, SolverError>

Self::classify_activity’s per-variable verdict for user_vars, re-measured against the reduced curvature along each coordinate instead of the Hessian diagonal — one back-solve against the held factor per variable (gh#763).

classify_activity normalizes a variable’s Σ by H_ii, but the multiplier at a kink is generated by the curvature left after the other free variables re-optimize. The two agree only where the coordinate is decoupled, so a genuine kink coupled to a neighbour reads AMBIGUOUS there at any tolerance — the ratio is μ-independent. Ask here and the same kink reads WEAKLY_ACTIVE.

Indices are user space (full-x), as the report’s are. The intended call is over a report’s ambiguous entries:

let report = solver.classify_activity()?;
let ask: Vec<usize> = (0..report.var_status.len())
    .filter(|&i| report.var_status[i] == AMBIGUOUS)
    .collect();
let refined = solver.reduced_activity(&ask)?;

The cost is one back-solve per index, so it is a refinement to call over the entries in question, not over every bounded variable of a large model. See [crate::activity::reduced_activity] for the algebra and the edge cases.

Requires the held solve to have run with bound_relax_factor=0 for the same reason Self::classify_activity does: both read the slacks relaxed bounds shift.

Source

pub fn reduced_row_activity( &self, user_rows: &[usize], ) -> Result<ReducedRowActivityReport, SolverError>

Self::classify_activity’s per-ROW verdict for user_rows, re-measured against the reduced curvature along each row’s gradient instead of the directional curvature ∇dᵀH∇d/‖∇d‖² — one back-solve against the held factor per row (gh#804).

The row counterpart of Self::reduced_activity, and the same defect one block over. A row’s directional denominator is a genuine curvature along the row’s own gradient — strictly better than the variable path’s bare H_ii, which is why gh#763 fixed the variables first — but it is still not reduced: it does not account for the other free coordinates re-optimizing, and the multiplier is generated by what is left after they do. So a row’s ratio there is reduced/directional, equal to 1 only where the row’s direction is decoupled from the remaining free space, and a genuine row kink that is coupled reads AMBIGUOUS at any tolerance — the ratio is μ-independent. Ask here and the same kink reads WEAKLY_ACTIVE.

Indices are user space (full-g), as the report’s are — equality rows included, which report EQUALITY rather than being an error. The intended call is over a report’s ambiguous rows:

let report = solver.classify_activity()?;
let ask: Vec<usize> = (0..report.row_status.len())
    .filter(|&j| report.row_status[j] == AMBIGUOUS)
    .collect();
let refined = solver.reduced_row_activity(&ask)?;

The cost is one back-solve per index, so it is a refinement to call over the rows in question, not over every bounded row of a large model. See [crate::activity::reduced_row_activity] for the algebra and the edge cases.

Requires the held solve to have run with bound_relax_factor=0 for the same reason Self::classify_activity does: both read the slacks relaxed bounds shift.

Source

pub fn row_normal(&self, user_row: usize) -> Result<Vec<Number>, SolverError>

The gradient of user constraint row user_row at the converged iterate, in user variable order (length n_full_x) and in natural (unscaled) units: the internal Jacobian row carries the solver’s per-row c_scale/d_scale, which is divided out here, so this is the gradient of the row as the user wrote it. Equality and inequality rows alike; entries for fixed (make_parameter-removed) variables are 0 because the solve dropped their columns. Errors on an out-of-range row.

Serves the covariance roadmap’s item 1: a binding row’s normal restricted to the fitted block is the projection direction.

Source

pub fn hessian_vec(&self, v: &[Number]) -> Result<Vec<Number>, SolverError>

The exact Lagrangian Hessian times a user-space vector, in user variable order and natural units (see [crate::activity::hessian_vec]). Errors on a length mismatch.

Source

pub fn kkt_solve( &self, rhs: &[Number], lhs: &mut [Number], ) -> Result<(), SolverError>

Solve K · lhs = rhs against the converged KKT factor. Both slices must have length kkt_dim(); the layout is the flat x || s || y_c || y_d || z_l || z_u || v_l || v_u packing.

K here is the natural-units (unscaled) KKT matrix: when the IPM solved with active NLP scaling, the backsolver scales the RHS/solution (all eight blocks, including the z/v bound-multiplier rows) so callers pass and receive data in the user’s own units (pounce#128) — see crate::PdSensBacksolver::solve. For the raw scaled-space back-solve use Self::kkt_solve_scaled.

Examples found in repository?
examples/sensitivity_session.rs (line 191)
160fn main() {
161    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
162        eta1: 5.0,
163        eta2: 1.0,
164    }));
165
166    let mut solver = Solver::new(make_app(), tnlp);
167    let status = solver.solve();
168    println!("solve status: {status:?}");
169    assert!(solver.converged().is_some(), "solver did not converge");
170
171    let pins = vec![2 as Index, 3];
172
173    // Two cheap parametric steps against the same factor.
174    for deltas in &[vec![-0.5, 0.0], vec![0.0, 0.2]] {
175        let dx = solver
176            .parametric_step(&pins, deltas)
177            .expect("parametric_step ok");
178        println!("parametric_step(deltas={deltas:?}) -> dx = {dx:?}");
179    }
180
181    // Reduced Hessian over the same pinned-row set.
182    let hr = solver
183        .compute_reduced_hessian(&pins, 1.0)
184        .expect("reduced Hessian ok");
185    println!("reduced Hessian (2x2, column-major) = {hr:?}");
186
187    // Raw back-solve against a zero RHS — must come back zero.
188    let dim = solver.kkt_dim().expect("kkt_dim available");
189    let rhs = vec![0.0; dim];
190    let mut lhs = vec![1.0; dim];
191    solver.kkt_solve(&rhs, &mut lhs).expect("kkt_solve ok");
192    let max_abs = lhs.iter().fold(0.0_f64, |a, b| a.max(b.abs()));
193    println!("kkt_solve(0) max |lhs| = {max_abs:e}");
194    assert!(max_abs < 1e-10);
195}
Source

pub fn kkt_solve_scaled( &self, rhs: &[Number], lhs: &mut [Number], ) -> Result<(), SolverError>

Self::kkt_solve without the natural-units conjugation: the back-solve runs against the factor exactly as the IPM holds it (the solver’s internal scaled space). Identical to kkt_solve when no NLP scaling is active. “Scaled space” includes a user-scaling change of variables (gh#486), so on such a solve the x and z blocks here are in the substituted coordinates x̃ = d ⊙ x, not the model’s.

Source

pub fn kkt_solve_many( &self, rhs_flat: &[Number], lhs_flat: &mut [Number], n_rhs: usize, ) -> Result<(), SolverError>

Batched-RHS back-solve. rhs_flat and lhs_flat are row-major (n_rhs, kkt_dim) buffers; each row is solved against the same converged factor. Equivalent in result to looping Self::kkt_solve but reuses one IteratesVector for the RHS and one for the result across all n_rhs calls — see crate::algorithm_backsolver::PdSensBacksolver::solve_many.

Source

pub fn kkt_solve_many_scaled( &self, rhs_flat: &[Number], lhs_flat: &mut [Number], n_rhs: usize, ) -> Result<(), SolverError>

Self::kkt_solve_many without the natural-units conjugation (the batched sibling of Self::kkt_solve_scaled).

Source

pub fn parametric_step( &self, pin_constraint_indices: &[Index], deltas: &[Number], ) -> Result<Vec<Number>, SolverError>

First-order parametric step Δx ≈ ∂x*/∂p · Δp for a set of pinned equality constraints. pin_constraint_indices are 0-based indices into the user’s g(x); deltas is the perturbation Δp (same length).

Returns the n_x-long primal step. For the full KKT-space step, use Self::kkt_solve directly.

Examples found in repository?
examples/sensitivity_session.rs (line 176)
160fn main() {
161    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
162        eta1: 5.0,
163        eta2: 1.0,
164    }));
165
166    let mut solver = Solver::new(make_app(), tnlp);
167    let status = solver.solve();
168    println!("solve status: {status:?}");
169    assert!(solver.converged().is_some(), "solver did not converge");
170
171    let pins = vec![2 as Index, 3];
172
173    // Two cheap parametric steps against the same factor.
174    for deltas in &[vec![-0.5, 0.0], vec![0.0, 0.2]] {
175        let dx = solver
176            .parametric_step(&pins, deltas)
177            .expect("parametric_step ok");
178        println!("parametric_step(deltas={deltas:?}) -> dx = {dx:?}");
179    }
180
181    // Reduced Hessian over the same pinned-row set.
182    let hr = solver
183        .compute_reduced_hessian(&pins, 1.0)
184        .expect("reduced Hessian ok");
185    println!("reduced Hessian (2x2, column-major) = {hr:?}");
186
187    // Raw back-solve against a zero RHS — must come back zero.
188    let dim = solver.kkt_dim().expect("kkt_dim available");
189    let rhs = vec![0.0; dim];
190    let mut lhs = vec![1.0; dim];
191    solver.kkt_solve(&rhs, &mut lhs).expect("kkt_solve ok");
192    let max_abs = lhs.iter().fold(0.0_f64, |a, b| a.max(b.abs()));
193    println!("kkt_solve(0) max |lhs| = {max_abs:e}");
194    assert!(max_abs < 1e-10);
195}
More examples
Hide additional examples
examples/parametric_mpc.rs (line 197)
171fn main() {
172    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
173        eta1: 5.0,
174        eta2: 1.0,
175    }));
176
177    // One full IPM solve at the nominal parameter.
178    let mut solver = Solver::new(make_app(), tnlp);
179    let t0 = Instant::now();
180    let status = solver.solve();
181    let solve_dt = t0.elapsed();
182    println!(
183        "IPM solve at nominal (eta1=5.0, eta2=1.0): status={status:?}, {:.3} ms",
184        solve_dt.as_secs_f64() * 1e3
185    );
186    assert!(solver.converged().is_some());
187
188    // 10 parametric steps as eta2 sweeps from 1.0 to 1.5. Each one is
189    // a back-solve against the held factor — no IPM iteration.
190    let pins = vec![2 as Index, 3];
191    let mut total_step_dt = std::time::Duration::ZERO;
192    let mut steps = Vec::new();
193    for k in 1..=10 {
194        let d_eta2 = 0.05 * k as f64;
195        let t = Instant::now();
196        let dx = solver
197            .parametric_step(&pins, &[0.0, d_eta2])
198            .expect("parametric_step ok");
199        total_step_dt += t.elapsed();
200        steps.push((d_eta2, dx));
201    }
202
203    println!("\nparametric steps against the held factor:");
204    for (d_eta2, dx) in &steps {
205        println!(
206            "  Δeta2 = {d_eta2:+.2}  -> Δx_primal = [{:+.5}, {:+.5}, {:+.5}]",
207            dx[0], dx[1], dx[2]
208        );
209    }
210    let avg_step_us = total_step_dt.as_secs_f64() * 1e6 / steps.len() as f64;
211    println!(
212        "\n10 parametric steps: total {:.3} ms, mean {avg_step_us:.1} µs/step",
213        total_step_dt.as_secs_f64() * 1e3
214    );
215    println!(
216        "\nRatio: each parametric step is roughly {:.0}x cheaper than the IPM solve.",
217        solve_dt.as_secs_f64() / (total_step_dt.as_secs_f64() / steps.len() as f64),
218    );
219}
examples/sensitivity_factor_reuse_bench.rs (line 174)
157fn main() {
158    let deltas: Vec<Vec<Number>> = (1..=N).map(|k| vec![0.0, 0.01 * k as Number]).collect();
159    let pins = vec![2 as Index, 3];
160
161    // (1) Held-factor path: 1 solve + N parametric steps.
162    let t = Instant::now();
163    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
164        eta1: 5.0,
165        eta2: 1.0,
166    }));
167    let mut solver = Solver::new(make_app(), tnlp);
168    solver.solve();
169    let solve_dt = t.elapsed();
170    let mut held_steps = Vec::with_capacity(N);
171    let t = Instant::now();
172    for d in &deltas {
173        let dx = solver
174            .parametric_step(&pins, d)
175            .expect("parametric_step ok");
176        held_steps.push(dx);
177    }
178    let held_step_dt = t.elapsed();
179    let held_total = solve_dt + held_step_dt;
180
181    // (2) Cold path: N fresh SensSolve runs (one full IPM each).
182    let mut cold_steps = Vec::with_capacity(N);
183    let t = Instant::now();
184    for d in &deltas {
185        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
186            eta1: 5.0,
187            eta2: 1.0,
188        }));
189        let mut app = make_app();
190        let r = SensSolve::new(pins.clone())
191            .with_deltas(d.clone())
192            .run(&mut app, tnlp);
193        cold_steps.push(r.dx.expect("dx"));
194    }
195    let cold_total = t.elapsed();
196
197    // Cross-check: held vs cold dx should agree.
198    let mut max_err = 0.0_f64;
199    for (h, c) in held_steps.iter().zip(cold_steps.iter()) {
200        for (a, b) in h.iter().zip(c.iter()) {
201            max_err = max_err.max((a - b).abs());
202        }
203    }
204
205    println!("Workload: 1 IPM solve + {N} parametric steps (Δeta2 sweep).");
206    println!();
207    println!(
208        "Held-factor path : solve {:.2} ms + {N} steps {:.2} ms = {:.2} ms total",
209        solve_dt.as_secs_f64() * 1e3,
210        held_step_dt.as_secs_f64() * 1e3,
211        held_total.as_secs_f64() * 1e3,
212    );
213    println!(
214        "Cold-restart path:                          {N} fresh solves = {:.2} ms total",
215        cold_total.as_secs_f64() * 1e3,
216    );
217    println!();
218    println!(
219        "Speedup of held-factor over cold-restart: {:.1}x",
220        cold_total.as_secs_f64() / held_total.as_secs_f64()
221    );
222    println!("Numerical agreement (held vs cold dx): max |err| = {max_err:.2e}");
223}
Source

pub fn parametric_step_bounded( &self, pin_constraint_indices: &[Index], deltas: &[Number], max_iter: usize, bound_eps: Option<Number>, ) -> Result<(Vec<Number>, Vec<Index>, RefineStop), SolverError>

Parametric step with the bounds respected by pinning, not by clamping. Returns the n_x-long primal step, the rows it constrained to reach it, and why the refinement stopped.

Self::parametric_step answers where the linear predictor points, which can be outside the box. Clamping a coordinate back to its bound leaves every other coordinate at its predictor value, so the answer is feasible but no longer consistent with the KKT relations. This instead adds a row pinning each offending coordinate at its bound and re-solves, so the others move to stay consistent under the pins, which is the refinement upstream runs under sens_boundcheck.

A pass takes every crossing it can see, pins them together, and re-solves, so the loop ends when nothing is left outside rather than when the passes run out. Each pass rebuilds the Schur complement over the pins so far, so a pass carrying k of them costs one dense k × k solve and k + 1 back-solves; the factorization itself is never rebuilt for a pin.

What counts as outside a bound is the eps argument when the caller passes one, and the solve’s own margin when it passes None: the solve was willing to leave a converged point bound_relax_factor outside its bound, so anything within that is on the bound. An unrelaxed solve gets a roundoff floor.

Passes stop when nothing is outside its bound by that much, when a pin cannot be achieved because the pins have exhausted the problem’s degrees of freedom, or at max_iter, which is a safety limit rather than a budget: it took one pin per pass until gh#732, where a model with more crossings than passes had its answer picked by the limit. None of those is an error, and the returned crate::boundcheck::RefineStop says which happened.

Source

pub fn parametric_step_path( &self, pin_constraint_indices: &[Index], deltas: &[Number], max_iter: usize, ) -> Result<(Vec<Number>, Vec<PathSegment>), SolverError>

Parametric step applied a little at a time instead of taken whole, stopping wherever the active set changes and continuing from there under the new one. Returns the primal step and the breakpoints crossed.

Self::parametric_step_bounded decides every condition at the base point, which is upstream’s fix-relax. This is past it: the result is piecewise linear in the parameter, exact for a QP because a QP’s solution is piecewise affine in the parameter, and still a predictor for an NLP because nothing is re-linearized between breakpoints.

max_iter caps the breakpoints crossed. It is in practice a budget on factorizations, since a pin is a back-solve against the held factor while a release re-factors.

Source

pub fn parametric_step_path_decided( &self, pin_constraint_indices: &[Index], deltas: &[Number], max_iter: usize, held_var_rows: &[Index], ) -> Result<(Vec<Number>, Vec<PathSegment>), SolverError>

Self::parametric_step_path with the weak-row decision supplied by the caller instead of searched for. held_var_rows names the var-x rows of the weakly active bounds the direction holds; every other weakly active bound is forced into the walk’s base-activity table as a leaving row. A row left there is still reachable, so a caller that hands in an empty held list — every weak row declared a leaver, which is what an undecided study of the all-released step does — gets the bound back at the fraction the walk finds the direction pressing into it, rather than an answer outside the box (gh#852). Study surface for an externally solved eq. 14 QP.

Source

pub fn correct_step( &self, pin_constraint_indices: &[Index], deltas: &[Number], step: &[Number], max_iter: usize, ) -> Result<(Vec<Number>, CorrectorReport), SolverError>

Newton iterations on the barrier system, refining a step that some mode already produced.

step is a full compound step, the shape Self::parametric_step_full returns, so any mode’s result can be handed in. Every correction pays one derivative evaluation and one factorization at the predicted point, and each iteration after that costs one back-solve. Returns the refined step and a [CorrectorReport] saying what the iterations bought.

The corrector aims at the barrier solution at the μ the solve finished on, not at a re-solve, so the accuracy it can reach is bounded by that offset. Its operator is assembled at the PREDICTED point, every block: the Hessian, the constraint Jacobians, and the barrier diagonal all evaluated at the stepped iterate with the step’s own multipliers, and the predictor’s active set applied to the diagonal in that frame. A base solve the sigma ceiling (gh#737) touched, or one that crossed over into the declared frame (gh#654), is no exception: both rules are re-derived at the predicted point rather than read from the base-point diagonals stored for the held factor’s own back-solves. A chord iteration contracts at the rate the distance between its operator and the true Jacobian sets, and the predicted point is where the truth is. Under a limited-memory solve the quasi-Newton matrix is kept as is, since no exact Hessian exists to evaluate elsewhere. Where the perturbation needs a bound to leave the active set that the step’s endpoint does not show, no released row is applied: the step’s clamped multiplier leaves a weak diagonal entry there, the iterations can move the coordinate partway off the bound, and the answer is not the re-solve. The release-deciding modes are the ones that cross exactly. CorrectorReport::improved reports whether the residual fell; when it did not, the step handed back is the caller’s own.

The returned point always satisfies the variable bounds, since the barrier residual is undefined outside them and the fraction-to-boundary rule keeps every iterate inside. A step that arrives pointing out of the box is therefore put back in before the first iteration, which means max_iter = 0 is not a no-op: it costs the derivative evaluation and the residual evaluation, no back-solve, and reports the residual the caller’s step leaves.

Errors with SolverError::SensComputationFailed when the barrier residual at that starting point is not finite, which is what a predicted point outside the domain of one of the model’s functions gives (gh#845). A declared bound is protection here, since the clamp above puts the coordinate back inside it; a variable held in a function’s domain by a constraint has no bound to be put back inside, and an ordinary log, sqrt or reciprocal is then reachable by a large enough perturbation. There is no correction to make from such a point, so it is an error rather than a report – and never a step full of NaN carrying residual = 0.0 and converged = true.

Source

pub fn parametric_step_bounded_decided( &self, pin_constraint_indices: &[Index], deltas: &[Number], max_iter: usize, held_var_rows: &[Index], bound_eps: Option<Number>, ) -> Result<(Vec<Number>, Vec<Index>, RefineStop), SolverError>

Self::parametric_step_bounded with the weak-row decision supplied by the caller instead of searched for. The direction is computed for the given working set (all weak rows released, the held variables pinned through Schur rows), then refined onto the bounds exactly as the searched variant does. Study surface for an externally solved eq. 14 QP.

Source

pub fn path_direction_decided( &self, pin_constraint_indices: &[Index], deltas: &[Number], released_bound_rows: &[Index], held_primal_rows: &[Index], operator: PathOperator, ) -> Result<(Vec<Number>, Vec<Number>), SolverError>

crate::boundcheck::path_direction for a working set the caller names, and the force each held row carries under it. Study surface, and the seam the two pins are measured against each other through.

released_bound_rows are compound bound-multiplier rows, as Self::weakly_active_bounds reports them; held_primal_rows are primal rows – x block for a variable, s block for a constraint’s own limit (gh#928).

operator picks which operator the walk’s (single, exact) Schur pin is applied to. Plain is the released system as the factorization already holds it – one factorization for the whole segment, and no inverse at all when releasing two curvature-free variables that share a row leaves the stationarity rows dependent (gh#930). Regularized raises the pinned diagonals until it is invertible, at the cost of rebuilding the diagonal per solve, so the factorization cache misses. Preferred is what the walk itself runs: the plain one, falling back when it fails or when its pins do not take, which is not the same test – see crate::boundcheck::path_direction.

Both operators give the same answer: the Schur row enforces Eᵀ w = 0, which annihilates the added diagonal, so the system solved is the released one either way and both return values agree in value, frame and units. issue_930_two_curvature_free_releases.rs measures that.

Source

pub fn parametric_step_release_all( &self, pin_constraint_indices: &[Index], deltas: &[Number], ) -> Result<(Vec<Number>, usize), SolverError>

The all-released step: the plain parametric step solved with every weakly active bound’s row released, and nothing decided.

This is Self::parametric_step_directional’s first back-solve returned as the answer instead of refined. The caller trades the engagement’s budget for whatever violations the released direction carries at weak bounds the perturbation actually holds, which come back as crossings for the mode’s clamp, pins, or path segments, or for a correction, to handle. A clean base point takes the plain step. Returns the direction over the model’s variables and the number of rows released.

Source

pub fn parametric_step_directional( &self, pin_constraint_indices: &[Index], deltas: &[Number], max_iter: usize, ) -> Result<(Vec<Number>, Vec<usize>, usize), SolverError>

The eq. 14 directional derivative, decided by pounce-qp over the weak rows the direction engages.

One released factorization serves the whole decision: the released Σ is built once and every solve passes the same object, so the factorization cache reuses the factor across the all-released direction and the basis columns. The decision itself is the dual of eq. 14 restricted to the weak rows the direction engages: with a_k the signed unit vector of weak row k (positive for a lower bound), X_k = K_rel^{-1} a_k, S = aᵀX and m = aᵀd0, the pin forces λ solve

    min  ½ λᵀ S λ + mᵀ λ    s.t.  λ ≥ 0

whose KKT conditions are eq. 14’s complementarity: a released row moves to its feasible side (the QP gradient Sλ + m ≥ 0) and a held row’s pin force is nonnegative. Rows outside the engaged set are verified against the decided direction and the set expands until no new row violates. Nothing reads the perturbation’s size, so the decision is linear in the step.

An engaged row is decided only when its bound is at a kink, read off kappa = sigma * S_kk, the barrier weight times the row’s own diagonal of the reduced matrix. sigma equals the curvature reduced along the coordinate at an exact kink, and S_kk is that reduced curvature’s inverse, so kappa is 1 there at any curvature, coupling, or scaling, and it falls as the squared ratio of kink width to slack away from one. A row below KAPPA_MIN is dropped from the engaged set and its plain movement stands: its bound is too far from a kink for a pin force to decide, and the error of leaving it undecided is bounded by its own slack, order sqrt(mu) at the threshold. A coordinate an equality pins is the limiting case, S_kk exactly zero, dropped by the same test.

max_iter is the total back-solve budget: the all-released solve, every basis column, and the combined solve that recovers the direction all count against it. A budget of zero errs before any work. Any budget above that pays the all-released factorization first, because which rows engage is only known once that solve has run, and the shortfall is reported when the basis columns cannot fit. Either way the caller falls back to the one-sided step. Returns the direction, the var-x rows held, and the back-solves spent.

Source

pub fn weakly_active_bounds(&self) -> Result<Vec<WeakBound>, SolverError>

The bounds the activity classifier could not call at the base point: on the bound with a multiplier of the same order as the slack. Each entry is a bound row present in the held factorization, with the side taken from the smaller slack, which is the only side an ambiguous label can come from.

Both WEAKLY_ACTIVE and AMBIGUOUS count as weak here, deliberately: the ambiguous class contains genuine kinks whose coordinate is coupled to a neighbour (gh#763), so treating it as “not a kink” would drop real weak rows. That is why the mislabeling is not a wrong answer in the step path — see Self::reduced_activity for the class itself.

The classifier reports per user variable, in full-x, while the bound context and the factor’s rows are var-x, and the two index spaces diverge from the first fixed variable on. Each var-x row’s status is read through the same map the classifier scattered through, so a fixed variable shifts nothing. Using the full-x index as a factor row instead returns a NEIGHBORING variable’s answer, plausible and wrong, which is the gh#450 hazard the primal_row discipline exists to prevent.

Source

pub fn parametric_step_full( &self, pin_constraint_indices: &[Index], deltas: &[Number], ) -> Result<Vec<Number>, SolverError>

Full KKT-space parametric step for a set of pinned equality constraints: the same computation as Self::parametric_step, returned WITHOUT truncating to the primal block. The layout is the compound KKT vector (x, s, y_c, y_d, z_l, z_u, v_l, v_u); use Self::block_dims for the block sizes and Self::g_multiplier_rows to locate a constraint’s multiplier row. This exposes the multiplier sensitivities ∂λ*/∂p alongside the primal step.

Source

pub fn g_multiplier_rows( &self, g_indices: &[Index], ) -> Result<Vec<Option<Index>>, SolverError>

Flat rows of the compound KKT vector holding the equality multipliers y_c for the given 0-based full-g constraint indices. None for inequalities — their multipliers live in the y_d block, which Self::d_multiplier_rows addresses. Row r of a Self::parametric_step_full result is then ∂λ_g/∂p · Δp.

Source

pub fn d_multiplier_rows( &self, g_indices: &[Index], ) -> Result<Vec<Option<Index>>, SolverError>

Flat rows of the compound KKT vector holding the inequality multipliers y_d for the given 0-based full-g constraint indices. None for equalities (those are Self::g_multiplier_rows’s). The y_d counterpart of that accessor, added by gh#910: parametric_step_full already returned the y_d block, and this is the map that says which row of it belongs to which user constraint.

Reading the row is not the same as the row being a derivative. y_d holds a number for every inequality, in all three activity regimes, and only one of them has a two-sided ∂λ/∂p at all:

  • strictly active (s ≈ 0, λ > 0): the row behaves as an equality over a neighbourhood of the solved point, and this row is the same back-solve an equality gets. Well defined.
  • inactive (s > 0, λ ≈ 0): the derivative is a structural zero over a neighbourhood; the KKT row carries the barrier’s residue rather than a derivative of anything.
  • weakly active (a kink: s ≈ 0 and λ ≈ 0): the two one-sided derivatives differ and no two-sided value exists. The entry holds whichever side the factorization landed on — the silently-wrong-while-reporting-success class. Self::parametric_step_directional is what answers a kink, and it needs a direction.

So a caller reading these rows as ∂λ/∂p must gate on the regime first, and the classifier that answers it is Self::reduced_row_activity, not Self::classify_activity: a genuine kink whose row couples to the remaining free space reports INACTIVE on the directional normalizer at strong enough coupling (gh#804), and INACTIVE is the one class whose derivative a caller may legitimately read as a structural zero. Gating on the cheap classifier would therefore answer “it does not move” about a kink — a wrong answer wearing a refusal’s clothes. That inference, reading an activity class as a proxy for kink-ness, is what shipped gh#756.

Source

pub fn d_slack_rows( &self, g_indices: &[Index], ) -> Result<Vec<Option<Index>>, SolverError>

Flat rows of the compound KKT vector holding an inequality’s slack s, for the given 0-based full-g row indices; None for a row that is not an inequality.

The primal counterpart of Self::d_multiplier_rows, and the discriminator a consumer of Self::parametric_step_path needs. A limit written as g(x) <= cap bounds this slack rather than any variable, so a breakpoint on it carries a primal KKT row in the s block (gh#928). Reading such a row as a var-x index returns a neighbouring variable’s answer, the gh#450 hazard, so a caller that maps rows back to model objects resolves them here.

The s block sits immediately after x, and is indexed by d-block position exactly as y_d is, so this row and Self::d_multiplier_rows’s row name the same inequality from the two sides of its complementarity pair.

Source

pub fn x_primal_rows( &self, x_indices: &[Index], ) -> Result<Vec<Option<Index>>, SolverError>

Flat rows of the compound KKT vector holding the primal values x for the given 0-based full-x variable indices. None where the solve removed the column (x_l == x_u under fixed_variable_treatment = make_parameter), which has no row in the factor at all.

The x counterpart of Self::g_multiplier_rows, and needed for the same reason: a caller holding user-space indices — from the .col file, from Self::classify_activity, from Self::row_normal — cannot index the factor with them directly. Row r of a Self::parametric_step_full result is then ∂x/∂p · Δp for that variable, and e_r is the unit vector selecting its column in a Self::kkt_solve.

Source

pub fn n_full_x(&self) -> Result<usize, SolverError>

The user TNLP’s variable count: the length of a full-x report and the domain of Self::x_primal_rows.

Source

pub fn n_full_g(&self) -> Result<usize, SolverError>

The user TNLP’s constraint count: the length of a full-g report and the domain of Self::reduced_row_activity.

Source

pub fn compute_reduced_hessian( &self, pin_constraint_indices: &[Index], obj_scal: Number, ) -> Result<Vec<Number>, SolverError>

Reduced Hessian over the pinned equality-constraint rows: obj_scal · B K⁻¹ Bᵀ, where B selects the pin_constraint_indices rows of the y_c block and K is the natural-units (unscaled) KKT matrix — active NLP scaling is undone by the backsolver, so −inv of the returned matrix is directly the parameter covariance regardless of nlp_scaling_method (pounce#128). obj_scal survives as a plain extra multiplier (default 1.0); it is no longer needed to recover natural units. Returns the -long column-major dense matrix (n = pin_constraint_indices.len()).

§Sign convention: this returns −H_R, not H_R (gh#937)

The matrix is the negated reduced Hessian. On a model whose objective Hessian is [[2, 1], [1, 2]] with both variables pinned, this returns [[−2, −1], [−1, −2]]. So a well-posed minimum reports an all-negative spectrum; that is the convention, not an indefiniteness or convergence bug.

The minus is the augmented system’s, and it is why the covariance recipe above negates: pin indices map to the y_c multiplier block, and for K = [[H, Aᵀ], [A, 0]] the (y_c, y_c) block of K⁻¹ is −(A H⁻¹ Aᵀ)⁻¹ — so over pin rows B K⁻¹ Bᵀ is the multiplier sensitivity ∂λ/∂p = −∂²f*/∂p², i.e. ±H_R itself and not a submatrix of an inverse. (The x block of K⁻¹ is an inverse. The two blocks sit on opposite sides of one inversion, which is what makes the CLI’s red_hessian suffix path — upstream sIPOPT’s, selecting x rows — a different quantity rather than the same one with a different sign.)

Negate to read curvature: −hr is H_R, and −inv(hr) is the covariance. Pinned by tests/issue_937_reduced_hessian_sign.rs; demonstrated by examples/rh_orientation_check.rs.

Equivalent to crate::SensSolve::with_reduced_hessian but usable post-hoc on a held Solver. For the solver-space (pre-#128) value use Self::compute_reduced_hessian_scaled; the factors themselves are exposed via Self::nlp_scaling / Self::pin_g_scaling.

Examples found in repository?
examples/sensitivity_session.rs (line 183)
160fn main() {
161    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ParametricTNLP {
162        eta1: 5.0,
163        eta2: 1.0,
164    }));
165
166    let mut solver = Solver::new(make_app(), tnlp);
167    let status = solver.solve();
168    println!("solve status: {status:?}");
169    assert!(solver.converged().is_some(), "solver did not converge");
170
171    let pins = vec![2 as Index, 3];
172
173    // Two cheap parametric steps against the same factor.
174    for deltas in &[vec![-0.5, 0.0], vec![0.0, 0.2]] {
175        let dx = solver
176            .parametric_step(&pins, deltas)
177            .expect("parametric_step ok");
178        println!("parametric_step(deltas={deltas:?}) -> dx = {dx:?}");
179    }
180
181    // Reduced Hessian over the same pinned-row set.
182    let hr = solver
183        .compute_reduced_hessian(&pins, 1.0)
184        .expect("reduced Hessian ok");
185    println!("reduced Hessian (2x2, column-major) = {hr:?}");
186
187    // Raw back-solve against a zero RHS — must come back zero.
188    let dim = solver.kkt_dim().expect("kkt_dim available");
189    let rhs = vec![0.0; dim];
190    let mut lhs = vec![1.0; dim];
191    solver.kkt_solve(&rhs, &mut lhs).expect("kkt_solve ok");
192    let max_abs = lhs.iter().fold(0.0_f64, |a, b| a.max(b.abs()));
193    println!("kkt_solve(0) max |lhs| = {max_abs:e}");
194    assert!(max_abs < 1e-10);
195}
Source

pub fn compute_reduced_hessian_eigen( &self, pin_constraint_indices: &[Index], obj_scal: Number, ) -> Result<(Vec<Number>, Vec<Number>, Vec<Number>), SolverError>

Self::compute_reduced_hessian plus its eigendecomposition — (H_R, eigenvalues, eigenvectors).

The curvature on the null space of the active constraints is the question; its spectrum is what answers “is this parameter identifiable, and along which direction”. SensSolve has offered that since gh#561 (crate::SensSolve::with_reduced_hessian_eigen), and the session API did not — so a caller holding a Solver had to re-solve the whole NLP through the one-shot builder to get a decomposition of a matrix it already had. That is the gap this closes; the numbers are the one-shot path’s, from the same pounce_linalg::symmetric_eigen.

Eigenvectors are column-major, length , column j belonging to eigenvalue j, and sign-pinned by symmetric_eigen so a column read as a direction reproduces across builds.

§This is the spectrum of −H_R, so ascending runs stiffest first

Self::compute_reduced_hessian returns the negated reduced Hessian (gh#937, and see its docs for why). The eigenvalues here are that matrix’s, in ascending order — which on −H_R runs from most negative to least, i.e. stiffest mode first and softest last, the reverse of what the identifiability reading wants. On H = [[2, 1], [1, 2]] fully pinned they come back [−3, −1]: the leading column is the curvature-3 stiff direction, the trailing one the curvature-1 soft direction.

So a caller taking the leading columns as the least-identifiable directions gets the best-identified ones, and nothing looks wrong — the vectors are unit-norm, sign-pinned and entirely plausible. Either negate the eigenvalues and reverse the order, or read the trailing columns as the soft modes. Pinned by tests/issue_937_reduced_hessian_sign.rs.

Examples found in repository?
examples/rh_orientation_check.rs (line 155)
132fn main() {
133    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(PinnedQuadratic { p0: 1.0, p1: 2.0 }));
134    let mut app = IpoptApplication::new();
135    app.options_mut()
136        .set_integer_value("print_level", 0, true, false)
137        .unwrap();
138    app.options_mut()
139        .set_string_value("sb", "yes", true, false)
140        .unwrap();
141    app.initialize().unwrap();
142
143    let mut solver = Solver::new(app, tnlp);
144    let status = solver.solve();
145    assert!(
146        matches!(
147            status,
148            ApplicationReturnStatus::SolveSucceeded
149                | ApplicationReturnStatus::SolvedToAcceptableLevel
150        ),
151        "solve failed: {status:?}"
152    );
153
154    let (hr, vals, vecs) = solver
155        .compute_reduced_hessian_eigen(&[0, 1], 1.0)
156        .expect("reduced Hessian");
157
158    println!("model:  min x0² + x1² + x0·x1  s.t.  x0 = 1, x1 = 2");
159    println!("H    = [[2, 1], [1, 2]]   (eigenvalues 1 and 3)");
160    println!();
161    println!("compute_reduced_hessian(pins=[0, 1]):");
162    for i in 0..2 {
163        println!("  [{:>9.6}, {:>9.6}]", hr[i], hr[i + 2]);
164    }
165    println!(
166        "  eigenvalues (ascending) = [{:>9.6}, {:>9.6}]",
167        vals[0], vals[1]
168    );
169    for j in 0..2 {
170        println!(
171            "  eigenvector[{j}]          = [{:>9.6}, {:>9.6}]",
172            vecs[2 * j],
173            vecs[2 * j + 1]
174        );
175    }
176    println!();
177
178    // Three candidates, discriminated by magnitude as well as sign:
179    // inv(H) = [[2/3, -1/3], [-1/3, 2/3]].
180    let h_inv: [Number; 4] = [2.0 / 3.0, -1.0 / 3.0, -1.0 / 3.0, 2.0 / 3.0];
181    let candidates: [(&str, [Number; 4]); 3] = [
182        ("+H_R", H),
183        ("-H_R", [-H[0], -H[1], -H[2], -H[3]]),
184        ("H_R⁻¹", h_inv),
185    ];
186    for (name, want) in candidates {
187        let err = (0..4)
188            .map(|k| (hr[k] - want[k]).abs())
189            .fold(0.0 as Number, Number::max);
190        println!(
191            "  vs {name:<6} max|Δ| = {err:.3e}  {}",
192            if err < 1e-7 { "← MATCH" } else { "" }
193        );
194    }
195    println!();
196    println!(
197        "So the ascending spectrum runs STIFFEST first: {:.6} is the curvature-3",
198        vals[0]
199    );
200    println!(
201        "mode and {:.6} the curvature-1 (soft) one — the reverse of the",
202        vals[1]
203    );
204    println!("order a caller reading `+H_R` would assume.");
205}
Source

pub fn compute_reduced_hessian_scaled( &self, pin_constraint_indices: &[Index], obj_scal: Number, ) -> Result<Vec<Number>, SolverError>

The reduced Hessian as the solver’s internal scaled space sees it — the value Self::compute_reduced_hessian returned before pounce#128: H̃_ij = (df / (dc_i·dc_j)) · H_ij. Identical to compute_reduced_hessian when no NLP scaling is active.

Sign: this is Self::compute_reduced_hessian’s −H_R multiplied through by df / (dc_i·dc_j) (gh#937), so unlike the natural-units value its orientation is not fixed. Measured on a fully pinned [[2, 1], [1, 2]]: [[−2, −1], [−1, −2]] by default, but [[2, 1], [1, 2]] under obj_scaling_factor = −1, where df carries the minus that makes a maximization a minimization. Read the sign off the reported factors (Self::nlp_scaling, Self::pin_g_scaling) rather than assuming it, or use the natural-units value, whose −H_R holds whatever the scaling.

Source

pub fn nlp_scaling( &self, ) -> Result<(Number, Option<Vec<Number>>, Option<Vec<Number>>), SolverError>

Effective NLP scaling the IPM applied on the most recent converged solve: (obj_scaling_factor, c_scale, d_scale). (1.0, None, None) ⇔ no scaling was active. The vectors are per-row factors over the algorithm’s equality (c) and inequality (d) blocks.

Source

pub fn variable_scaling(&self) -> Result<Option<Vec<Number>>, SolverError>

The per-variable user-scaling factors d the held solve ran under (gh#486), in the user TNLP’s full-x space, or None when the solve applied no change of variables.

Every accessor on this type already reports natural units, so this is diagnostic rather than a correction a caller has to apply — it answers “was this solve conditioned, and by how much”, the x-axis counterpart of Self::nlp_scaling.

Source

pub fn kkt_perturbations(&self) -> Result<[Number; 4], SolverError>

Inertia-correction perturbations (δ_x, δ_s, δ_c, δ_d) baked into the held KKT factor. All zero ⇔ the final factorization was unregularized and the natural-units back-solves invert the exact KKT matrix — see crate::PdSensBacksolver::kkt_perturbations.

Source

pub fn pin_g_scaling( &self, pin_constraint_indices: &[Index], ) -> Result<Vec<Number>, SolverError>

Per-pin equality-row scaling factors dc_i (1.0 entries when no constraint scaling is active), ordered like pin_constraint_indices.

Auto Trait Implementations§

§

impl !Freeze for Solver

§

impl !RefUnwindSafe for Solver

§

impl !Send for Solver

§

impl !Sync for Solver

§

impl !UnwindSafe for Solver

§

impl Unpin for Solver

§

impl UnsafeUnpin for Solver

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> ByRef<T> for T

Source§

fn by_ref(&self) -> &T

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Imply<T> for U
where T: ?Sized, U: ?Sized,

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> Pointable for T

Source§

const ALIGN: usize

The alignment of pointer.
Source§

type Init = T

The type for initializers.
Source§

unsafe fn init(init: <T as Pointable>::Init) -> usize

Initializes a with the given initializer. Read more
Source§

unsafe fn deref<'a>(ptr: usize) -> &'a T

Dereferences the given pointer. Read more
Source§

unsafe fn deref_mut<'a>(ptr: usize) -> &'a mut T

Mutably dereferences the given pointer. Read more
Source§

unsafe fn drop(ptr: usize)

Drops the object pointed to by the given pointer. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more