pounce_algorithm/intermediate.rs
1//! Per-iteration "intermediate" context shared with downstream
2//! callers (notably the C-API inspector functions
3//! `GetIpoptCurrentIterate` / `GetIpoptCurrentViolations`).
4//!
5//! Mirrors upstream Ipopt's `OrigIpoptNLP::GetIpoptCurrent*` flow: the
6//! main loop installs a snapshot of the algorithm-side state into
7//! thread-local storage immediately before invoking the user's
8//! intermediate callback, and clears it on return. Inspector functions
9//! consult the TLS slot; outside the callback window every accessor
10//! reports "not available".
11//!
12//! The snapshot is intentionally cheap to assemble — we stash `Rc`
13//! handles to `IpoptData`, `IpoptCq`, and the algorithm-side `IpoptNlp`
14//! rather than precomputing every field, so callers that read just one
15//! quantity pay only for what they look at.
16
17use crate::ipopt_cq::IpoptCqHandle;
18use crate::ipopt_data::IpoptDataHandle;
19use crate::ipopt_nlp::IpoptNlp;
20use pounce_common::types::{Index, Number};
21use std::cell::RefCell;
22use std::rc::Rc;
23
24/// Snapshot stashed in TLS for the duration of one
25/// `TNLP::intermediate_callback` invocation.
26#[derive(Clone)]
27pub struct IntermediateContext {
28 pub data: IpoptDataHandle,
29 pub cq: IpoptCqHandle,
30 pub nlp: Rc<RefCell<dyn IpoptNlp>>,
31}
32
33/// The materialised iterate of the user's problem (full `n`, full `m`,
34/// original `g` order), and its window. Defined in
35/// [`pounce_nlp::live_iterate`] so the decorators that lift it one layer
36/// per `intermediate_callback` forward — presolve's above all — can reach
37/// it; re-exported here, where callers have always found it.
38///
39/// The active-set SQP arm installs one at every fire. The interior-point
40/// arm installs one when the TNLP asks for it
41/// ([`pounce_nlp::tnlp::TNLP::wants_current_iterate`]), built by
42/// [`ipm_user_iterate`].
43pub use pounce_nlp::live_iterate::{UserIterate, UserIterateGuard, with_current_user_iterate};
44
45thread_local! {
46 static CURRENT_CTX: RefCell<Option<IntermediateContext>> = const { RefCell::new(None) };
47}
48
49/// The interior-point iterate in the space of the TNLP the NLP wraps, by
50/// the conversions `finalize_via_orig_nlp` applies to the final one, so the
51/// iterate read at the last fire is the solution `finalize_solution`
52/// reports:
53///
54/// * `x` through `finalize_solution_x` (fixed variables spliced back in);
55/// * `z_L`, `z_U`, `λ` through the `finalize_solution_*` lifts, which
56/// divide out `obj_scale_factor` (the unscaled-Lagrangian convention);
57/// * the bound multipliers of variables `make_parameter` removed recovered
58/// from `z_L − z_U = ∇f + Jᵀλ` when `fixed_var_duals` — upstream
59/// `TNLP::get_curr_iterate` does the same through
60/// `TNLPAdapter::ResortBoundMultipliers` for every treatment but
61/// `make_parameter_nodual`. Left at `0` they were not a zero multiplier
62/// but a missing one;
63/// * per-variable `user-scaling` (`x̃ = d ⊙ x`) is *not* undone here: the
64/// `ScalingTnlp` that substitutes it undoes it on the iterate's way
65/// through its `intermediate_callback`, as its `finalize_solution` does;
66/// * `g` from the iterate's own `c` / `d` values, unscaled.
67///
68/// The fixed-variable recovery evaluates the TNLP, so this must run before
69/// the callback borrows it, never from inside the callback.
70pub fn ipm_user_iterate(
71 nlp: &Rc<RefCell<dyn IpoptNlp>>,
72 data: &IpoptDataHandle,
73 cq: &IpoptCqHandle,
74 fixed_var_duals: bool,
75) -> Option<UserIterate> {
76 let curr = data.borrow().curr.clone()?;
77 // `curr_c` / `curr_d` evaluate through the NLP when not cached; no NLP
78 // borrow may be alive across them.
79 let (c, d, glx) = {
80 let cq = cq.borrow();
81 (cq.curr_c(), cq.curr_d(), cq.curr_grad_lag_x())
82 };
83 let nlp = nlp.borrow();
84 let n = nlp.n_full_x() as usize;
85 let m = nlp.n_full_g() as usize;
86 let x = nlp.finalize_solution_x(&*curr.x);
87 let mut z_l = nlp.finalize_solution_z_l(&*curr.z_l);
88 if z_l.is_empty() {
89 z_l = vec![0.0; n];
90 }
91 let mut z_u = nlp.finalize_solution_z_u(&*curr.z_u);
92 if z_u.is_empty() {
93 z_u = vec![0.0; n];
94 }
95 let mut lambda = nlp.finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
96 if lambda.is_empty() {
97 lambda = vec![0.0; m];
98 }
99 if fixed_var_duals {
100 nlp.complete_fixed_var_bound_multipliers(&x, &lambda, &mut z_l, &mut z_u);
101 }
102 let g = nlp.pack_g_for_user(&*c, &*d);
103 let scaling = Some(iterate_scaling(&*nlp));
104 let (x_l_alg, x_u_alg) = algorithm_x_bounds(&*nlp, &x);
105 let grad_lag_x = user_grad_lag(&*nlp, &dense_values(&*glx));
106 Some(UserIterate {
107 x,
108 z_l,
109 z_u,
110 g,
111 lambda,
112 scaling,
113 x_l_alg,
114 x_u_alg,
115 grad_lag_x,
116 })
117}
118
119fn dense_values(v: &dyn pounce_linalg::Vector) -> Vec<Number> {
120 v.as_any()
121 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
122 .map(|d| d.expanded_values())
123 .unwrap_or_default()
124}
125
126/// The algorithm's own `∇L` (var-x, scaled: `obj·∇f + J̃ᵀỹ − z̃_L + z̃_U`,
127/// upstream `curr_grad_lag_x`) as the unscaled `∇f + Jᵀλ − z_L + z_U` of the
128/// TNLP `nlp` wraps — `÷ obj`, the row factors cancelling in `J̃ᵀỹ` — at
129/// full x, `0` at a column the algorithm does not hold (upstream
130/// `ResortX(…, usefixedvals=false)`). Its `∇f` and `J` are the ones the
131/// algorithm evaluated at the iterate, so this costs no evaluation.
132pub fn user_grad_lag(nlp: &dyn IpoptNlp, glx: &[Number]) -> Vec<Number> {
133 let n = nlp.n_full_x() as usize;
134 let obj = nlp.obj_scaling_factor();
135 let inv = if obj == 0.0 { 1.0 } else { 1.0 / obj };
136 (0..n)
137 .map(|j| match nlp.full_x_to_var_x(j as Index) {
138 Some(k) => glx.get(k as usize).map_or(Number::NAN, |g| g * inv),
139 None => 0.0,
140 })
141 .collect()
142}
143
144/// The variable bounds `nlp` iterates against — its live `x_L` / `x_U`,
145/// i.e. the declared box after `bound_relax_factor` widened it and any
146/// safe-slack adjustment moved it (upstream `IpoptNLP::x_L()`, what
147/// `curr_slack_x_L` is measured to) — in the shape of the TNLP it wraps
148/// ([`UserIterate::x_l_alg`]): `±∞` at a column with no such bound, and at a
149/// fixed variable the algorithm does not hold, its value `x[j]`.
150pub fn algorithm_x_bounds(nlp: &dyn IpoptNlp, x: &[Number]) -> (Vec<Number>, Vec<Number>) {
151 use pounce_linalg::dense_vector::DenseVectorSpace;
152 let n = nlp.n_full_x() as usize;
153 let side = |live: &dyn pounce_linalg::Vector,
154 upper: bool,
155 packed: &dyn Fn(&dyn pounce_linalg::Vector) -> Vec<Number>| {
156 let mut ones = DenseVectorSpace::new(live.dim()).make_new_dense();
157 if live.dim() > 0 {
158 ones.set_values(&vec![1.0; live.dim() as usize]);
159 }
160 let (vals, mask) = (packed(live), packed(&ones));
161 (0..n)
162 .map(|j| {
163 if nlp.full_x_to_var_x(j as Index).is_none() {
164 x.get(j).copied().unwrap_or(Number::NAN)
165 } else if mask.get(j).copied().unwrap_or(0.0) != 0.0 {
166 vals[j]
167 } else if upper {
168 Number::INFINITY
169 } else {
170 Number::NEG_INFINITY
171 }
172 })
173 .collect::<Vec<Number>>()
174 };
175 (
176 side(nlp.x_l(), false, &|v| nlp.pack_z_l_for_user(v)),
177 side(nlp.x_u(), true, &|v| nlp.pack_z_u_for_user(v)),
178 )
179}
180
181/// The [`IterateScaling`](pounce_nlp::live_iterate::IterateScaling) of the
182/// problem `nlp` iterates on, in the shape of the TNLP it wraps — the
183/// objective factor and the per-row `c` / `d` factors, and which columns
184/// the algorithm holds (not a fixed variable, which upstream's `ResortX`
185/// reports at the TNLP's own value). The per-variable factor is `1` here:
186/// a `ScalingTnlp` substituting `x̃ = d ⊙ x` multiplies its `d` in when it
187/// lifts the iterate. Both arms solve through the same `OrigIpoptNlp`, so
188/// both read their scaling here.
189pub fn iterate_scaling(nlp: &dyn IpoptNlp) -> pounce_nlp::live_iterate::IterateScaling {
190 let n = nlp.n_full_x() as usize;
191 let m = nlp.n_full_g() as usize;
192 let x_in_alg: Vec<bool> = (0..n)
193 .map(|j| nlp.full_x_to_var_x(j as Index).is_some())
194 .collect();
195 let x = vec![1.0; n];
196 let cs = nlp.c_scale_vec();
197 let ds = nlp.d_scale_vec();
198 let g = (0..m)
199 .map(|i| {
200 if let Some(k) = nlp.full_g_to_c_block(i as Index) {
201 cs.as_ref()
202 .and_then(|v| v.get(k as usize).copied())
203 .unwrap_or(1.0)
204 } else if let Some(k) = nlp.full_g_to_d_block(i as Index) {
205 ds.as_ref()
206 .and_then(|v| v.get(k as usize).copied())
207 .unwrap_or(1.0)
208 } else {
209 1.0
210 }
211 })
212 .collect();
213 pounce_nlp::live_iterate::IterateScaling {
214 obj: nlp.obj_scaling_factor(),
215 x,
216 x_in_alg,
217 g,
218 }
219}
220
221/// RAII guard — installs `ctx` on construction, clears on drop. Used
222/// by the algorithm to scope visibility of live iterate state to one
223/// callback fire.
224pub struct CtxGuard {
225 _private: (),
226}
227
228impl CtxGuard {
229 pub fn install(ctx: IntermediateContext) -> Self {
230 CURRENT_CTX.with(|c| *c.borrow_mut() = Some(ctx));
231 Self { _private: () }
232 }
233}
234
235impl Drop for CtxGuard {
236 fn drop(&mut self) {
237 CURRENT_CTX.with(|c| *c.borrow_mut() = None);
238 }
239}
240
241/// Read access to the currently installed context. Returns `None`
242/// outside the intermediate-callback window.
243pub fn with_current<F, R>(f: F) -> Option<R>
244where
245 F: FnOnce(&IntermediateContext) -> R,
246{
247 CURRENT_CTX.with(|c| c.borrow().as_ref().map(f))
248}
249
250/// Whether a context is currently installed.
251pub fn is_active() -> bool {
252 CURRENT_CTX.with(|c| c.borrow().is_some()) || pounce_nlp::live_iterate::is_installed()
253}