Skip to main content

pounce_algorithm/init/
warm_start.rs

1//! Warm-start iterate initializer — port of
2//! `IpWarmStartIterateInitializer.{hpp,cpp}`. Used when a previous
3//! solve has left a trial point that should be reused.
4//!
5//! There are two callers we serve:
6//!
7//! * **A full primal-dual warm restart** installed via
8//!   `Application::set_warm_start_iterate` and consumed by the next
9//!   `optimize_tnlp` (e.g. the debugger `resolve` re-solve): `data.curr`
10//!   already carries the previous solve's iterate, so we keep it, clamp
11//!   multipliers, and optionally override `mu`.
12//! * **First solves from `OptimizeTNLP`** that opt into
13//!   `warm_start_init_point=yes` to forward user-supplied
14//!   primal/dual seeds via `TNLP::get_starting_point`. Here
15//!   `data.curr` carries only dim metadata (uninitialized vectors);
16//!   we pull seeds from the NLP, push primals/slacks into the bound
17//!   interior with warm-start `bound_push`/`bound_frac`, and then
18//!   apply the same multiplier clamps.
19//!
20//! Wired options today: `bound_push`, `bound_frac`,
21//! `slack_bound_push`, `slack_bound_frac`, `mult_bound_push`,
22//! `mult_init_max`, `target_mu`. `mult_bound_push` floors the four
23//! bound-multiplier blocks (mirroring upstream's `ElementWiseMax`
24//! with `warm_start_mult_bound_push`): a user-seeded `z = 0` would
25//! otherwise start the barrier on its boundary. The remaining knobs
26//! (`entire_iterate`, `same_structure`) are parsed for Ipopt option
27//! compatibility but not yet consumed — they require the
28//! `GetWarmStartIterate` TNLP surface, which pounce does not expose.
29
30use crate::alg_builder::WarmStartOptions;
31use crate::init::default::push_x_into_interior;
32use crate::init::r#trait::IterateInitializer;
33use crate::ipopt_cq::IpoptCqHandle;
34use crate::ipopt_data::IpoptDataHandle;
35use crate::ipopt_nlp::IpoptNlp;
36use crate::iterates_vector::IteratesVector;
37use crate::kkt::aug_system_solver::AugSystemSolver;
38use pounce_linalg::Vector;
39use pounce_linalg::compound_vector::CompoundVector;
40use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
41use std::cell::RefCell;
42use std::rc::Rc;
43
44pub struct WarmStartIterateInitializer {
45    opts: WarmStartOptions,
46}
47
48impl WarmStartIterateInitializer {
49    pub fn new() -> Self {
50        Self {
51            opts: WarmStartOptions::default(),
52        }
53    }
54
55    pub fn with_options(opts: WarmStartOptions) -> Self {
56        Self { opts }
57    }
58}
59
60impl Default for WarmStartIterateInitializer {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl IterateInitializer for WarmStartIterateInitializer {
67    fn set_initial_iterates(
68        &mut self,
69        data: &IpoptDataHandle,
70        _cq: &IpoptCqHandle,
71        nlp: &Rc<RefCell<dyn IpoptNlp>>,
72        _aug_solver: &mut dyn AugSystemSolver,
73    ) -> bool {
74        // Two entry points share this initializer: the re-optimize path
75        // (curr.x carries values from the prior solve) and the first
76        // OptimizeTNLP call that opted into warm_start_init_point=yes
77        // (curr.x is the application's placeholder seed — allocated but
78        // never written). Detect the latter and rebuild `curr` from the
79        // NLP's get_starting_x/y/z hooks before clamping.
80        let needs_seed_from_nlp = {
81            let borrow = data.borrow();
82            match borrow.curr.as_ref() {
83                None => return false,
84                Some(c) => !is_initialized(&c.x),
85            }
86        };
87
88        if needs_seed_from_nlp && !seed_from_nlp(data, nlp, &self.opts) {
89            return false;
90        }
91
92        {
93            // Rebuild `curr` with clamped multipliers. Components are
94            // shared via `Rc` with previous solves, so we make fresh
95            // copies before mutating to avoid clobbering downstream
96            // borrowers. Bound multipliers are additionally floored at
97            // `mult_bound_push` (upstream `warm_start_mult_bound_push`):
98            // the barrier needs them strictly positive, and a carried-in
99            // 0 (e.g. an inactive bound in the previous solution) would
100            // otherwise start on the boundary. This block runs even
101            // with both clamps disabled (cap = inf, floor = 0; the
102            // floor still clamps a negative z/v to 0) because it also
103            // resolves NaN seeds: NaN in a user-supplied multiplier
104            // means "unseeded", and takes `bound_mult_init_val` for
105            // bound multipliers, or 0 for equality multipliers. That 0
106            // is the warm path's existing unseeded value (what
107            // `seed_from_nlp` produced already), NOT the cold path's
108            // least-squares estimate; routing NaN duals through the
109            // least-squares calculator is a possible refinement.
110            let mut borrow = data.borrow_mut();
111            let curr = borrow.curr.as_ref().unwrap();
112            let cap = if self.opts.mult_init_max > 0.0 {
113                self.opts.mult_init_max
114            } else {
115                f64::INFINITY
116            };
117            let z_floor = self.opts.mult_bound_push.max(0.0);
118            let z_nan = self.opts.bound_mult_init_val;
119            let new_curr = IteratesVector::new(
120                Rc::clone(&curr.x),
121                Rc::clone(&curr.s),
122                clone_clamped(&curr.y_c, -cap, cap, 0.0),
123                clone_clamped(&curr.y_d, -cap, cap, 0.0),
124                clone_clamped(&curr.z_l, z_floor, cap, z_nan),
125                clone_clamped(&curr.z_u, z_floor, cap, z_nan),
126                clone_clamped(&curr.v_l, z_floor, cap, z_nan),
127                clone_clamped(&curr.v_u, z_floor, cap, z_nan),
128            );
129            borrow.set_curr(new_curr);
130        }
131
132        if self.opts.target_mu > 0.0 {
133            data.borrow_mut().curr_mu = self.opts.target_mu;
134        }
135
136        true
137    }
138}
139
140/// Pull a fresh starting iterate from the NLP (which routes to
141/// `TNLP::get_starting_point` with `init_x` / `init_lambda` /
142/// `init_z` all true), push the primals and slacks into the bound
143/// interior using warm-start-specific `bound_push`/`bound_frac`, and
144/// install the result on `data.curr`. Mirrors steps 1-4 of
145/// `DefaultIterateInitializer::set_initial_iterates`, but with
146/// upstream's warm-start option block governing the push.
147fn seed_from_nlp(
148    data: &IpoptDataHandle,
149    nlp: &Rc<RefCell<dyn IpoptNlp>>,
150    opts: &WarmStartOptions,
151) -> bool {
152    if !nlp.borrow_mut().prepare_warm_start() {
153        return false;
154    }
155    let (n_x, n_s, n_yc, n_yd, n_zl, n_zu, n_vl, n_vu) = {
156        let borrow = data.borrow();
157        let c = borrow.curr.as_ref().unwrap();
158        (
159            c.x.dim(),
160            c.s.dim(),
161            c.y_c.dim(),
162            c.y_d.dim(),
163            c.z_l.dim(),
164            c.z_u.dim(),
165            c.v_l.dim(),
166            c.v_u.dim(),
167        )
168    };
169
170    let mut x = DenseVectorSpace::new(n_x).make_new_dense();
171    nlp.borrow_mut().get_starting_x(&mut x);
172    {
173        let nlp_ref = nlp.borrow();
174        push_x_into_interior(
175            &mut x,
176            &*nlp_ref.px_l(),
177            nlp_ref.x_l(),
178            &*nlp_ref.px_u(),
179            nlp_ref.x_u(),
180            opts.bound_push,
181            opts.bound_frac,
182        );
183    }
184
185    let mut s = DenseVectorSpace::new(n_s).make_new_dense();
186    nlp.borrow_mut().eval_d(&x, &mut s);
187    {
188        let nlp_ref = nlp.borrow();
189        push_x_into_interior(
190            &mut s,
191            &*nlp_ref.pd_l(),
192            nlp_ref.d_l(),
193            &*nlp_ref.pd_u(),
194            nlp_ref.d_u(),
195            opts.slack_bound_push,
196            opts.slack_bound_frac,
197        );
198    }
199
200    let mut y_c = DenseVectorSpace::new(n_yc).make_new_dense();
201    let mut y_d = DenseVectorSpace::new(n_yd).make_new_dense();
202    y_c.set(0.0);
203    y_d.set(0.0);
204    nlp.borrow_mut().get_starting_y(&mut y_c, &mut y_d);
205
206    let mut z_l = DenseVectorSpace::new(n_zl).make_new_dense();
207    let mut z_u = DenseVectorSpace::new(n_zu).make_new_dense();
208    let mut v_l = DenseVectorSpace::new(n_vl).make_new_dense();
209    let mut v_u = DenseVectorSpace::new(n_vu).make_new_dense();
210    z_l.set(0.0);
211    z_u.set(0.0);
212    v_l.set(0.0);
213    v_u.set(0.0);
214    nlp.borrow_mut()
215        .get_starting_z(&mut z_l, &mut z_u, &mut v_l, &mut v_u);
216    nlp.borrow_mut().finish_warm_start();
217
218    let iv = IteratesVector::new(
219        Rc::new(x),
220        Rc::new(s),
221        Rc::new(y_c),
222        Rc::new(y_d),
223        Rc::new(z_l),
224        Rc::new(z_u),
225        Rc::new(v_l),
226        Rc::new(v_u),
227    );
228    data.borrow_mut().set_curr(iv);
229    true
230}
231
232fn is_initialized(v: &Rc<dyn Vector>) -> bool {
233    if v.dim() == 0 {
234        return true;
235    }
236    v.as_any()
237        .downcast_ref::<DenseVector>()
238        .map(|d| d.is_initialized())
239        .unwrap_or(true)
240}
241
242/// Replace every NaN entry of `v` with `fill`, in place.
243///
244/// NaN in a user-supplied multiplier seed means "unseeded" (see the
245/// `Problem.solve` contract), and has to be resolved before the
246/// clamps: `element_wise_min`/`element_wise_max` would propagate it
247/// into the iterate, poisoning the solve.
248///
249/// Both `Vector` storage layouts are handled. A dense block is
250/// scanned directly; a compound block recurses into its components,
251/// so the contract holds wherever the iterate's multiplier blocks
252/// live — the seed path (`seed_from_nlp`) always builds dense
253/// vectors, but the re-optimize path reuses whatever the previous
254/// solve's spaces produced, and a debug-only guard would be compiled
255/// out of exactly the release builds that ship.
256fn resolve_nan_seeds(v: &mut dyn Vector, fill: f64) {
257    // Type-test before taking the mutable borrow: `if let Some(d) =
258    // v.as_any_mut()… else` would keep that borrow live across the
259    // else arm.
260    if v.as_any().is::<DenseVector>() {
261        let d = v.as_any_mut().downcast_mut::<DenseVector>().unwrap();
262        for e in d.values_mut() {
263            if e.is_nan() {
264                *e = fill;
265            }
266        }
267    } else if v.as_any().is::<CompoundVector>() {
268        let c = v.as_any_mut().downcast_mut::<CompoundVector>().unwrap();
269        for i in 0..c.n_comps() {
270            resolve_nan_seeds(c.comp_mut(i), fill);
271        }
272    } else {
273        // `DenseVector` and `CompoundVector` are the only `Vector`
274        // implementations; a third one must be handled here, or NaN
275        // rides the clamps into the iterate as a silent poison.
276        debug_assert!(false, "resolve_nan_seeds: unhandled Vector implementation");
277    }
278}
279
280/// Clone `v` into a fresh owned vector and clamp every entry to
281/// `[lo, hi]` componentwise. Empty vectors short-circuit. Vectors that
282/// were never written to (the application's placeholder seed iterates
283/// before any solve ran) collapse to a zero-initialized vector — `0`
284/// is inside every well-formed warm-start clamp range, so this matches
285/// upstream's behavior when a multiplier block has no carry-over
286/// value.
287fn clone_clamped(v: &Rc<dyn Vector>, lo: f64, hi: f64, nan_fill: f64) -> Rc<dyn Vector> {
288    let n = v.dim();
289    if n == 0 {
290        return Rc::clone(v);
291    }
292    let mut out = v.make_new();
293    let initialized = v
294        .as_any()
295        .downcast_ref::<DenseVector>()
296        .map(|d| d.is_initialized())
297        .unwrap_or(true);
298    if initialized {
299        out.copy(&**v);
300        // NaN marks an unseeded entry; resolve it before the clamps
301        // (element-wise min/max would just propagate it)
302        resolve_nan_seeds(&mut *out, nan_fill);
303    } else {
304        out.set(0.0);
305    }
306    let mut cap_hi = v.make_new();
307    cap_hi.set(hi);
308    out.element_wise_min(&*cap_hi);
309    let mut cap_lo = v.make_new();
310    cap_lo.set(lo);
311    out.element_wise_max(&*cap_lo);
312    Rc::from(out)
313}
314
315#[cfg(test)]
316mod tests_nan_seed {
317    use super::*;
318    use pounce_linalg::compound_vector::CompoundVectorSpace;
319    use pounce_linalg::dense_vector::DenseVectorSpace;
320
321    #[test]
322    fn nan_entries_take_the_fill_before_clamping() {
323        let space = DenseVectorSpace::new(3);
324        let mut d = space.make_new_dense();
325        d.values_mut().copy_from_slice(&[0.5, f64::NAN, 2e7]);
326        let v: Rc<dyn Vector> = Rc::from(d);
327        let out = clone_clamped(&v, 1e-3, 1e6, 7.0);
328        let out = out.as_any().downcast_ref::<DenseVector>().unwrap();
329        assert_eq!(out.values()[0], 0.5);
330        assert_eq!(out.values()[1], 7.0); // unseeded -> fill
331        assert_eq!(out.values()[2], 1e6); // then the cap applies
332    }
333
334    /// The re-optimize path reuses the previous solve's vector spaces,
335    /// which are compound for a blocked NLP. NaN has to resolve there
336    /// too: a debug-only guard is compiled out of the release builds
337    /// that ship, so an unresolved NaN would ride the clamps into the
338    /// iterate and poison the solve.
339    #[test]
340    fn nan_resolves_inside_a_compound_vector() {
341        let inner = DenseVectorSpace::new(2);
342        let space = CompoundVectorSpace::new(2, 4);
343        for icomp in 0..2 {
344            let inner = Rc::clone(&inner);
345            space.set_comp(icomp, 2, move || {
346                let mut d = inner.make_new_dense();
347                d.set(0.0);
348                Box::new(d)
349            });
350        }
351        let mut cv = CompoundVector::new(Rc::clone(&space));
352        for (icomp, vals) in [[0.5, f64::NAN], [f64::NAN, 2e7]].into_iter().enumerate() {
353            let c = cv.comp_mut(icomp as pounce_common::types::Index);
354            let d = c.as_any_mut().downcast_mut::<DenseVector>().unwrap();
355            d.values_mut().copy_from_slice(&vals);
356        }
357
358        let v: Rc<dyn Vector> = Rc::from(cv);
359        let out = clone_clamped(&v, 1e-3, 1e6, 7.0);
360
361        let out = out.as_any().downcast_ref::<CompoundVector>().unwrap();
362        let flat: Vec<f64> = (0..out.n_comps())
363            .flat_map(|i| {
364                out.comp(i)
365                    .as_any()
366                    .downcast_ref::<DenseVector>()
367                    .unwrap()
368                    .values()
369                    .to_vec()
370            })
371            .collect();
372        assert_eq!(flat[0], 0.5);
373        assert_eq!(flat[1], 7.0); // unseeded -> fill, not NaN
374        assert_eq!(flat[2], 7.0);
375        assert_eq!(flat[3], 1e6); // then the cap applies
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use pounce_linalg::dense_vector::DenseVectorSpace;
383
384    fn dense(n: i32, fill: f64) -> Rc<dyn Vector> {
385        let space = DenseVectorSpace::new(n);
386        let mut v = space.make_new_dense();
387        v.set(fill);
388        Rc::new(v)
389    }
390
391    #[test]
392    fn clamps_multipliers_to_cap() {
393        let v = dense(3, 1e10);
394        let out = clone_clamped(&v, 0.0, 1e6, 0.0);
395        assert_eq!(out.amax(), 1e6);
396        let v2 = dense(3, -1e10);
397        let out2 = clone_clamped(&v2, -1e6, 1e6, 0.0);
398        assert_eq!(out2.amax(), 1e6);
399    }
400
401    #[test]
402    fn clamps_bound_mults_nonneg() {
403        let v = dense(3, -5.0);
404        let out = clone_clamped(&v, 0.0, 1e6, 0.0);
405        assert_eq!(out.amax(), 0.0);
406    }
407
408    #[test]
409    fn empty_vector_short_circuits() {
410        let v = dense(0, 0.0);
411        let out = clone_clamped(&v, 0.0, 1.0, 0.0);
412        assert_eq!(out.dim(), 0);
413    }
414
415    #[test]
416    fn in_range_values_pass_through_untouched() {
417        let v = dense(3, 0.5);
418        let out = clone_clamped(&v, 0.0, 1.0, 0.0);
419        assert!((out.max() - 0.5).abs() < 1e-15);
420        assert!((out.min() - 0.5).abs() < 1e-15);
421    }
422
423    #[test]
424    fn mult_bound_push_floors_zero_bound_multipliers() {
425        // A carried-in z = 0 (inactive bound in the previous solution)
426        // must be floored at warm_start_mult_bound_push, matching
427        // upstream's ElementWiseMax — the barrier needs z > 0.
428        let v = dense(3, 0.0);
429        let out = clone_clamped(&v, 1e-3, 1e6, 0.0);
430        assert!((out.min() - 1e-3).abs() < 1e-18);
431        // Values already above the floor pass through.
432        let v2 = dense(3, 0.7);
433        let out2 = clone_clamped(&v2, 1e-3, 1e6, 0.0);
434        assert!((out2.max() - 0.7).abs() < 1e-15);
435    }
436
437    #[test]
438    fn uninitialized_source_collapses_to_zero() {
439        // Application's placeholder seed iterate: vector allocated but
440        // never written. `clone_clamped` must fall back to zero instead
441        // of tripping the dense-vector "must be initialized" assert.
442        let space = DenseVectorSpace::new(4);
443        let v: Rc<dyn Vector> = Rc::new(space.make_new_dense());
444        let out = clone_clamped(&v, 0.0, 1e6, 0.0);
445        assert_eq!(out.amax(), 0.0);
446    }
447}