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.
26//!
27//! # Residual-adaptive recentering (gh#606)
28//!
29//! `warm_start_recentering=residual` (the default) adds a pass over
30//! the *supplied* point before the clamps: measure what was actually
31//! handed in, reconstruct what is missing, and choose μ from the
32//! measurement rather than from a universal constant.
33//!
34//! 1. **Measure.** `inf_pr` comes first because it is the one residual
35//!    that does not depend on the duals, so it is meaningful even when
36//!    every multiplier block is absent. It is reported rather than
37//!    acted on (step 4 explains why).
38//! 2. **Reconstruct bound multipliers.** An entry that arrives as
39//!    exactly `0` (or NaN) is not a legal barrier multiplier — the
40//!    barrier needs `z > 0` — so it was never a seed. Upstream floors
41//!    it at the constant `warm_start_mult_bound_push`; here it takes
42//!    `μ̂ / slack` instead, the same complementarity relation the
43//!    solver is about to enforce. Seeded (strictly positive) entries
44//!    are left alone.
45//!
46//!    This step needs no dual to work from — only the slacks the
47//!    supplied *point* already determines — so it runs even for a
48//!    caller who seeded nothing but `x` (gh#622). That case used to
49//!    fall through to the constant, and the constant it fell through
50//!    to was `warm_start_mult_bound_push`: 1e-3 by default, and 1e-9
51//!    under the tightened pushes `pounce.WarmStart` ships, i.e. a
52//!    start declaring every bound inactive. Against the pre-gh#622
53//!    behaviour, filling it properly is worth 49 -> 44 iterations at
54//!    horizon 5 and 67 -> 55 at horizon 20 on that issue's
55//!    receding-horizon family, and takes an HS071 restart from a
56//!    transferred point with no duals from 11 iterations to 7.
57//!
58//!    It costs one iteration in one place: HS071 restarted from its
59//!    own solution under `warm_start_recentering=none`, 3 -> 4, where
60//!    the kill switch keeps the constant fill and the constant is now
61//!    `bound_mult_init_val` rather than a bound-multiplier push so
62//!    small it read as "inactive" and happened to be right about a
63//!    solution whose bounds mostly are. Under the default the same
64//!    restart is 3 -> 2.
65//! 3. **Reconstruct equality multipliers.** A `y` block that is
66//!    identically zero is likewise unseeded, and is re-derived from
67//!    stationarity by the same regularized least-squares augmented
68//!    solve the cold path uses ([`LeastSquareMults`]) — now with the
69//!    reconstructed `z` in its right-hand side, so the estimate is not
70//!    forced to absorb the bound multipliers.
71//!
72//!    Unlike step 2, this one *completes a partial seed* and is gated
73//!    on [`any_dual_seeded`]: from a point alone it is the cold path's
74//!    estimate wearing the warm path's barrier, measured over
75//!    `benchmarks/warmstart` at 1102 -> 1211 iterations across 27
76//!    parametric paths. Step 4 is gated the same way and for the same
77//!    reason.
78//! 4. **Choose μ.** With the point complete, μ is raised to the
79//!    measured `avrg_compl` when that overshoots what `mu_init` asked
80//!    for by more than [`MU_ESCALATION_TRIGGER`], clamped to
81//!    `[MU_FLOOR, MU_CEILING]`. A KKT-quality point
82//!    measures its own converged complementarity and keeps it; a stale
83//!    one, whose multipliers and slacks no longer pair up, measures a
84//!    large one and gets a correspondingly loose barrier — the "safely
85//!    fall back to stronger recentering" half. The primal and dual
86//!    residuals are deliberately *not* in that max; see [`final_mu`].
87//!    `warm_start_target_mu` still wins outright when set.
88//!
89//! `warm_start_recentering=none` restores the pre-gh#606 behaviour
90//! exactly: constant floor, zero-filled `y`, μ untouched. It is the
91//! kill switch for this whole block.
92//!
93//! Every branch above records what it did on
94//! [`WarmStartDiagnostics`], which lands on `IpoptData` and is
95//! readable afterwards through
96//! `IpoptApplication::warm_start_diagnostics()`.
97//!
98//! [`LeastSquareMults`]: crate::eq_mult::least_square::LeastSquareMults
99
100use crate::alg_builder::{WarmStartOptions, WarmStartRecentering};
101use crate::eq_mult::least_square::LeastSquareMults;
102use crate::eq_mult::r#trait::EqMultCalculator;
103use crate::init::default::push_x_into_interior;
104use crate::init::r#trait::IterateInitializer;
105use crate::ipopt_cq::IpoptCqHandle;
106use crate::ipopt_data::IpoptDataHandle;
107use crate::ipopt_nlp::IpoptNlp;
108use crate::iterates_vector::IteratesVector;
109use crate::kkt::aug_system_solver::AugSystemSolver;
110use pounce_common::types::Number;
111use pounce_linalg::Vector;
112use pounce_linalg::compound_vector::CompoundVector;
113use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
114use std::cell::RefCell;
115use std::rc::Rc;
116
117/// Hard floor on the residual-derived μ. Below this the barrier term
118/// is at the noise level of a `tol=1e-8` solve's complementarity and
119/// carries no information; letting a measurement drive μ further down
120/// would start the solve on a boundary the line search then has to
121/// climb back off.
122const MU_FLOOR: Number = 1e-11;
123
124/// Hard ceiling on the residual-derived μ: upstream's registered
125/// `mu_init` default. A warm start that measures badly should degrade
126/// *to* a cold start, never past one.
127const MU_CEILING: Number = 0.1;
128
129/// How far the measured complementarity has to exceed `mu_init` before
130/// it is allowed to override it.
131///
132/// Moving μ reroutes the entire trajectory, so the move has to be
133/// worth it. The cases this exists for — a stale seed, a caller who
134/// kept only the primal point — miss by three to six orders of
135/// magnitude; a good seed misses by a factor of two, and overriding
136/// there buys nothing and costs whatever the reroute costs. On
137/// `cresc4` (a nonconvex model that enters restoration on iteration 2,
138/// where small perturbations compound) a measurement of `5e-7` against
139/// a `mu_init` of `1e-7` moved μ by half an order and the solve from
140/// 85 iterations to 206 — same status, same objective, 2.4x the work.
141/// With this gate that model is bit-identical again, and the
142/// three-order cases still fire.
143const MU_ESCALATION_TRIGGER: Number = 10.0;
144
145/// How far a *seeded* bound-multiplier block's implied complementarity
146/// may sit above what the primal point it arrived with can support
147/// before the block is refused outright (gh#617).
148///
149/// Deliberately the same factor as [`MU_ESCALATION_TRIGGER`], and for
150/// the same reason: refusing a seed is as much a trajectory change as
151/// moving μ, so it has to be worth it. A converged seed measures its
152/// own barrier and misses by a factor of two; a stale one is caught by
153/// the `inf_pr` half of the comparison rather than by this factor. What
154/// this is here to reject is a block that *cannot* have come from a
155/// solve of this problem at this point — a `z` that reads `1e2` against
156/// a primal point that is feasible to `1e-9`.
157const SEED_REJECTION_TRIGGER: Number = 10.0;
158
159/// What the recentering pass measured about the point as *supplied*,
160/// before anything was rebuilt from it.
161///
162/// Grouped rather than passed loose because the three travel together
163/// through every decision gh#617 and gh#618 added, and because the
164/// distinction that matters is exactly "measured on what the caller
165/// handed over" versus "measured on what this pass then built" — the
166/// second is what `eq_seed_is_incoherent` must never see.
167#[derive(Debug, Clone, Copy)]
168struct SeedMeasurement {
169    /// The provisional barrier: what `mu_init` asked for, clamped into
170    /// the band a warm start may use.
171    mu_hat: Number,
172    /// `‖c(x)‖∞` of the supplied primal point.
173    inf_pr: Number,
174    /// Largest bound multiplier the *caller* supplied that survived the
175    /// coherence test. Zero when the caller seeded none, or when every
176    /// seeded block was refused.
177    seeded_z_amax: Number,
178}
179
180/// What happened to one multiplier block of the supplied warm point.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182pub enum BlockVerdict {
183    /// The block has dimension zero — the model has no such block.
184    #[default]
185    Absent,
186    /// Every entry arrived seeded and was kept (modulo the clamps).
187    Accepted,
188    /// Some or all entries arrived unseeded and were rebuilt.
189    Reconstructed,
190    /// A reconstruction ran and was thrown away (it exceeded
191    /// `constr_mult_init_max`, or the augmented solve failed); the
192    /// block fell back to the pre-gh#606 constant fill.
193    Discarded,
194    /// The caller *did* seed this block, and the seed was refused: its
195    /// implied complementarity was orders of magnitude away from what
196    /// the primal point it arrived with supports, so it cannot have
197    /// come from a solve of this problem (gh#617). The block took the
198    /// pre-gh#606 constant fill, and nothing was reconstructed off it.
199    Rejected,
200    /// The caller supplied no dual information *at all*, so this block
201    /// kept the pre-gh#606 constant fill for want of anything to
202    /// derive it from. Since gh#622 only the equality multipliers
203    /// report this: the bound blocks are reconstructible from the
204    /// slacks alone and are filled whichever way the caller seeded.
205    /// See [`any_dual_seeded`].
206    Unseeded,
207}
208
209/// What the warm-start initializer accepted, reconstructed, or
210/// discarded, and the residuals it based those calls on (gh#606).
211///
212/// Written once per solve, at initialization. `mu_in` is what
213/// `mu_init` / `warm_start_target_mu` asked for; `mu_out` is what the
214/// iterate actually starts at.
215#[derive(Debug, Clone)]
216pub struct WarmStartDiagnostics {
217    /// `‖c(x)‖_∞` of the supplied primal point, measured before any
218    /// dual reconstruction (it does not depend on the duals).
219    pub primal_residual: Number,
220    /// `‖∇_x L‖_∞` after reconstruction.
221    pub dual_residual: Number,
222    /// Average complementarity after reconstruction.
223    pub complementarity: Number,
224    pub mu_in: Number,
225    pub mu_out: Number,
226    pub bound_duals: BlockVerdict,
227    pub eq_duals: BlockVerdict,
228    /// Bound-multiplier entries that arrived unseeded and were filled
229    /// from `μ̂ / slack` rather than from the constant floor.
230    pub bound_duals_reconstructed: usize,
231    /// Bound-multiplier entries that arrived *seeded* and were refused
232    /// as incoherent with the primal point, taking the pre-gh#606
233    /// constant fill instead (gh#617).
234    pub bound_duals_rejected: usize,
235    /// `true` when the seeded equality multipliers were refused as
236    /// incoherent with the primal point, so the stationarity split was
237    /// not run off them (gh#617). The `y` block itself is left where
238    /// the caller put it — that is what the pre-gh#606 path does with a
239    /// supplied `y` — but it stops being an input to anything.
240    pub eq_duals_rejected: bool,
241    /// `true` when the unseeded bound multipliers were re-derived from
242    /// the stationarity identity rather than left at `μ̂ / slack`. That
243    /// needs a caller-supplied `y`; see
244    /// [`refine_bound_duals_from_stationarity`].
245    pub stationarity_split: bool,
246    /// `true` when `warm_start_recentering=none` turned all of the
247    /// above off and the fields are the legacy constants.
248    pub recentering_disabled: bool,
249}
250
251impl Default for WarmStartDiagnostics {
252    fn default() -> Self {
253        Self {
254            primal_residual: Number::NAN,
255            dual_residual: Number::NAN,
256            complementarity: Number::NAN,
257            mu_in: Number::NAN,
258            mu_out: Number::NAN,
259            bound_duals: BlockVerdict::Absent,
260            eq_duals: BlockVerdict::Absent,
261            bound_duals_reconstructed: 0,
262            bound_duals_rejected: 0,
263            eq_duals_rejected: false,
264            stationarity_split: false,
265            recentering_disabled: false,
266        }
267    }
268}
269
270pub struct WarmStartIterateInitializer {
271    opts: WarmStartOptions,
272}
273
274impl WarmStartIterateInitializer {
275    pub fn new() -> Self {
276        Self {
277            opts: WarmStartOptions::default(),
278        }
279    }
280
281    pub fn with_options(opts: WarmStartOptions) -> Self {
282        Self { opts }
283    }
284}
285
286impl Default for WarmStartIterateInitializer {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292impl IterateInitializer for WarmStartIterateInitializer {
293    fn set_initial_iterates(
294        &mut self,
295        data: &IpoptDataHandle,
296        cq: &IpoptCqHandle,
297        nlp: &Rc<RefCell<dyn IpoptNlp>>,
298        aug_solver: &mut dyn AugSystemSolver,
299    ) -> bool {
300        // Two entry points share this initializer: the re-optimize path
301        // (curr.x carries values from the prior solve) and the first
302        // OptimizeTNLP call that opted into warm_start_init_point=yes
303        // (curr.x is the application's placeholder seed — allocated but
304        // never written). Detect the latter and rebuild `curr` from the
305        // NLP's get_starting_x/y/z hooks before clamping.
306        let needs_seed_from_nlp = {
307            let borrow = data.borrow();
308            match borrow.curr.as_ref() {
309                None => return false,
310                Some(c) => !is_initialized(&c.x),
311            }
312        };
313
314        if needs_seed_from_nlp && !seed_from_nlp(data, nlp, &self.opts) {
315            return false;
316        }
317
318        let mut diag = WarmStartDiagnostics {
319            mu_in: data.borrow().curr_mu,
320            recentering_disabled: self.opts.recentering == WarmStartRecentering::None,
321            ..Default::default()
322        };
323
324        // gh#606 steps 1-3: measure the supplied point, then rebuild
325        // whatever it did not carry. Runs *before* the clamp block
326        // below, which still has the last word on the caps.
327        //
328        // Reconstruction completes a *partial* warm start: each missing
329        // block is derived from the blocks that were supplied. When
330        // nothing at all was supplied there is nothing to derive from,
331        // and what comes out is the cold path's estimate wearing the
332        // warm path's barrier — measured over `benchmarks/warmstart`,
333        // reconstructing from a primal-only seed cost 1102 -> 1211
334        // iterations across 27 parametric paths (`degenerate_corner`
335        // 17 -> 38 at every step size). So a seed with no duals in it
336        // keeps the pre-gh#606 fills, and only μ is still measured.
337        let dual_info =
338            self.opts.recentering == WarmStartRecentering::Residual && any_dual_seeded(data);
339        // gh#622: the bound blocks are filled from the barrier relation
340        // even when the caller seeded nothing, because `mu / slack`
341        // needs no dual to derive it from — only the slacks the
342        // supplied point already determines. What stays gated on
343        // `dual_info` is everything that *does* need one: the
344        // least-squares `y`, the stationarity split, and letting the
345        // measurement move mu.
346        let bound_fill_only = !dual_info && self.opts.recentering == WarmStartRecentering::Residual;
347        let mu_hat = if dual_info {
348            let (seed, unseeded) = recenter_from_residuals(data, cq, &self.opts, true, &mut diag);
349            reconstruct_eq_duals(data, cq, nlp, aug_solver, &self.opts, &mut diag);
350            // The split below reads `y`; running it against a `y` this
351            // same pass just derived *from* the provisional `z` is
352            // circular, and measurably so (see the fn docs). Only a
353            // caller-supplied `y` earns it.
354            if diag.eq_duals != BlockVerdict::Reconstructed {
355                // Reported from the function's own return value, not
356                // from the fact that it was called: it returns early
357                // with nothing to do when every bound multiplier
358                // arrived seeded, and claiming the split ran there
359                // made `info["warm_start"]` say something untrue
360                // (gh#606 review).
361                let split = refine_bound_duals_from_stationarity(
362                    data, cq, nlp, &self.opts, seed, &unseeded, &mut diag,
363                );
364                diag.stationarity_split = split;
365            }
366            Some(seed.mu_hat)
367        } else if bound_fill_only {
368            let (seed, _unseeded) = recenter_from_residuals(data, cq, &self.opts, false, &mut diag);
369            // Left alone deliberately: with no `y` and no `z` supplied,
370            // a least-squares `y` would be the cold path's estimate
371            // wearing the warm path's barrier, which is the case gh#606
372            // measured at 1102 -> 1211 iterations and gated off.
373            diag.eq_duals = BlockVerdict::Unseeded;
374            Some(seed.mu_hat)
375        } else {
376            None
377        };
378        // Whether the measurement is allowed to *move* mu. Not on a
379        // seed with no duals in it: `final_mu` reads the
380        // complementarity of the iterate the solve starts from, and on
381        // that path every multiplier in it was just written by this
382        // initializer as `mu / slack` — so the measurement returns the
383        // mu it was handed, and any escalation off it is the barrier
384        // arguing with itself. It stayed academic while an unsupplied
385        // block arrived as a literal 0 floored at
386        // `warm_start_mult_bound_push`, a fill so small `avrg_compl`
387        // never cleared the trigger, and became load bearing the
388        // moment gh#622 gave those blocks an honest one.
389        let mu_may_move = dual_info;
390
391        {
392            // Rebuild `curr` with clamped multipliers. Components are
393            // shared via `Rc` with previous solves, so we make fresh
394            // copies before mutating to avoid clobbering downstream
395            // borrowers. Bound multipliers are additionally floored at
396            // `mult_bound_push` (upstream `warm_start_mult_bound_push`):
397            // the barrier needs them strictly positive, and a carried-in
398            // 0 (e.g. an inactive bound in the previous solution) would
399            // otherwise start on the boundary. This block runs even
400            // with both clamps disabled (cap = inf, floor = 0; the
401            // floor still clamps a negative z/v to 0) because it also
402            // resolves NaN seeds: NaN in a user-supplied multiplier
403            // means "unseeded", and takes `bound_mult_init_val` for
404            // bound multipliers, or 0 for equality multipliers. That 0
405            // is the warm path's existing unseeded value (what
406            // `seed_from_nlp` produced already).
407            //
408            // Under `warm_start_recentering=residual` the unseeded
409            // entries have already been rebuilt above, so what reaches
410            // here is a complete point and the constants only act as
411            // the outer caps.
412            let mut borrow = data.borrow_mut();
413            let curr = borrow.curr.as_ref().unwrap();
414            let cap = if self.opts.mult_init_max > 0.0 {
415                self.opts.mult_init_max
416            } else {
417                f64::INFINITY
418            };
419            let z_floor = self.opts.mult_bound_push.max(0.0);
420            let z_nan = self.opts.bound_mult_init_val;
421            let new_curr = IteratesVector::new(
422                Rc::clone(&curr.x),
423                Rc::clone(&curr.s),
424                clone_clamped(&curr.y_c, -cap, cap, 0.0),
425                clone_clamped(&curr.y_d, -cap, cap, 0.0),
426                clone_clamped(&curr.z_l, z_floor, cap, z_nan),
427                clone_clamped(&curr.z_u, z_floor, cap, z_nan),
428                clone_clamped(&curr.v_l, z_floor, cap, z_nan),
429                clone_clamped(&curr.v_u, z_floor, cap, z_nan),
430            );
431            borrow.set_curr(new_curr);
432        }
433
434        // `warm_start_target_mu` is an explicit instruction and still
435        // wins outright; the residual estimate only fills the gap the
436        // user left. gh#606 step 4.
437        if self.opts.target_mu > 0.0 {
438            data.borrow_mut().curr_mu = self.opts.target_mu;
439        } else if mu_hat.is_some() {
440            let mu = final_mu(data, cq, &mut diag, mu_may_move);
441            data.borrow_mut().curr_mu = mu;
442        }
443
444        {
445            let mut borrow = data.borrow_mut();
446            diag.mu_out = borrow.curr_mu;
447            for token in diag.info_string_tokens() {
448                borrow.append_info_string(token);
449            }
450            borrow.warm_start_diagnostics = Some(diag);
451        }
452
453        true
454    }
455}
456
457impl WarmStartDiagnostics {
458    /// Single-token summaries for the iteration line, in the same
459    /// spirit as the cold path's `y` / `y0` / `yc`
460    /// (`DefaultIterateInitializer`). `wz` = bound multipliers
461    /// reconstructed, `wy` = equality multipliers reconstructed,
462    /// `wy0` = a reconstruction was discarded, `wmu` = μ was raised
463    /// above what `mu_init` asked for (the stale-point fallback).
464    fn info_string_tokens(&self) -> Vec<&'static str> {
465        let mut out = Vec::new();
466        match self.bound_duals {
467            BlockVerdict::Reconstructed => out.push("wz"),
468            BlockVerdict::Rejected => out.push("wz!"),
469            _ => {}
470        }
471        match self.eq_duals {
472            BlockVerdict::Reconstructed => out.push("wy"),
473            BlockVerdict::Discarded => out.push("wy0"),
474            _ => {}
475        }
476        if self.eq_duals_rejected {
477            out.push("wy!");
478        }
479        if self.mu_out > self.mu_in * 10.0 {
480            out.push("wmu");
481        }
482        out
483    }
484}
485
486/// gh#606 steps 1-2. Measure the dual-free primal residual of the
487/// supplied point, turn it into a provisional barrier parameter `μ̂`,
488/// and fill every *unseeded* bound-multiplier entry with `μ̂ / slack`.
489///
490/// "Unseeded" is `0` exactly, or NaN. Neither is a legal barrier
491/// multiplier — the barrier needs `z > 0` — so neither can have come
492/// from a converged solve, and both are already special-cased today
493/// (floored at the constant `warm_start_mult_bound_push`). What
494/// changes is the value they take.
495///
496/// Returns `μ̂` — which [`final_mu`] then refines once the whole point
497/// is assembled — and, per block, the mask of entries that were
498/// unseeded, so [`refine_bound_duals_from_stationarity`] can revisit
499/// exactly those and nothing else.
500fn recenter_from_residuals(
501    data: &IpoptDataHandle,
502    cq: &IpoptCqHandle,
503    opts: &WarmStartOptions,
504    seed_implies_barrier: bool,
505    diag: &mut WarmStartDiagnostics,
506) -> (SeedMeasurement, [Vec<bool>; 4]) {
507    let inf_pr = cq.borrow().curr_primal_infeasibility_max();
508    diag.primal_residual = inf_pr;
509
510    // The fill barrier is the one the *caller* asked for, not the one
511    // the residual will argue for below. `z_i = μ / slack_i` is the
512    // complementarity relation at barrier μ, so filling from `mu_init`
513    // makes the reconstructed multipliers consistent with the point
514    // that produced the slacks — on an exact restart it reproduces the
515    // converged multiplier to a few digits. Substituting a
516    // residual-inflated μ here instead multiplies every reconstructed
517    // entry by that inflation, which on a converged point (tiny slacks)
518    // is precisely where it does the most damage: measured on HS071, a
519    // 8x-inflated μ̂ put the reconstructed slack multiplier 8x high and
520    // took the exact restart from 1 iteration to 5. The residual has
521    // its say in [`final_mu`], after the point is assembled.
522    let mu_hat = safe_mu(data.borrow().curr_mu);
523
524    let mut unseeded: [Vec<bool>; 4] = [vec![], vec![], vec![], vec![]];
525    // Largest bound multiplier the *caller* supplied and that survived
526    // the coherence test — the only scale [`eq_seed_is_incoherent`] may
527    // measure the seeded `y` against.
528    let mut seeded_z_amax = 0.0;
529    let curr = match data.borrow().curr.clone() {
530        Some(c) => c,
531        None => {
532            return (
533                SeedMeasurement {
534                    mu_hat,
535                    inf_pr,
536                    seeded_z_amax,
537                },
538                unseeded,
539            );
540        }
541    };
542    let cq_ref = cq.borrow();
543    let slacks = [
544        cq_ref.curr_slack_x_l(),
545        cq_ref.curr_slack_x_u(),
546        cq_ref.curr_slack_s_l(),
547        cq_ref.curr_slack_s_u(),
548    ];
549    drop(cq_ref);
550    let blocks = [&curr.z_l, &curr.z_u, &curr.v_l, &curr.v_u];
551
552    // gh#618's test asks whether the point's infeasibility outruns the
553    // barrier the *seed* implies. A seed that carried no multipliers
554    // implies no barrier: `mu_hat` here is the caller's `mu_init`, an
555    // option, so comparing `inf_pr` against it measures nothing about
556    // the seed. On that path `μ / slack` is also the only information
557    // there is — gh#622 measured it beating every constant fill on
558    // exactly this fixture — so the guard is not armed. See
559    // [`swamping_residual`].
560    let swamping = if seed_implies_barrier {
561        swamping_residual(inf_pr, mu_hat)
562    } else {
563        0.0
564    };
565    let mut rebuilt: [Option<Rc<dyn Vector>>; 4] = [None, None, None, None];
566    let mut n_reconstructed = 0usize;
567    let mut n_rejected = 0usize;
568    let mut n_total = 0usize;
569    for (i, (z, slack)) in blocks.iter().zip(slacks.iter()).enumerate() {
570        if z.dim() == 0 {
571            continue;
572        }
573        n_total += z.dim() as usize;
574        let Some(mut vals) = flatten(&***z) else {
575            continue;
576        };
577        let Some(sl) = flatten(&**slack) else {
578            continue;
579        };
580        if sl.len() != vals.len() {
581            // Shapes disagree (a layout this helper does not model):
582            // leave the block to the constant floor below rather than
583            // pair up entries that may not correspond.
584            continue;
585        }
586        // gh#617. Before anything is derived *from* this block, ask
587        // whether it can have come from a solve of this problem at this
588        // primal point. A block that fails takes the pre-gh#606
589        // constant fill wholesale and is not used as an input again.
590        if seed_is_incoherent(&vals, &sl, mu_hat, inf_pr) {
591            let fill = opts.mult_bound_push.max(0.0);
592            for v in vals.iter_mut() {
593                *v = fill;
594            }
595            n_rejected += vals.len();
596            // No mask is recorded, so `refine_bound_duals_from_stationarity`
597            // skips this block entirely: a refused seed must not come
598            // back through the split's floor.
599            let mut out = z.make_new();
600            if scatter(&mut *out, &vals) {
601                rebuilt[i] = Some(Rc::from(out));
602            }
603            continue;
604        }
605        let mut touched = 0usize;
606        let mut mask = vec![false; vals.len()];
607        for (k, (v, s)) in vals.iter_mut().zip(sl.iter()).enumerate() {
608            if !(v.is_nan() || *v == 0.0) {
609                if v.is_finite() {
610                    seeded_z_amax = seeded_z_amax.max(v.abs());
611                }
612                continue;
613            }
614            touched += 1;
615            mask[k] = true;
616            // `slack` can be tiny (an active bound) or non-finite on a
617            // point outside its bounds; both are clamped into the band
618            // the caps below would have allowed anyway. A slack the
619            // point's own infeasibility swamps is not a measurement at
620            // all and takes the pre-gh#606 constant (gh#618) — see
621            // [`slack_is_swamped`].
622            let filled = if slack_is_swamped(*s, swamping) {
623                opts.mult_bound_push.max(0.0)
624            } else if s.is_finite() && *s > 0.0 {
625                mu_hat / *s
626            } else {
627                mu_hat
628            };
629            *v = filled.clamp(
630                opts.mult_bound_push.max(MU_FLOOR),
631                opts.mult_init_max_or_inf(),
632            );
633        }
634        if touched == 0 {
635            continue;
636        }
637        unseeded[i] = mask;
638        n_reconstructed += touched;
639        let mut out = z.make_new();
640        if scatter(&mut *out, &vals) {
641            rebuilt[i] = Some(Rc::from(out));
642        }
643    }
644
645    if n_total > 0 {
646        // A rejection is the loudest thing that can happen to a block,
647        // so it wins the summary: a caller who seeded a block and had
648        // it refused needs to see that, not "some other block was
649        // rebuilt" (gh#617).
650        diag.bound_duals = if n_rejected > 0 {
651            BlockVerdict::Rejected
652        } else if n_reconstructed == 0 {
653            BlockVerdict::Accepted
654        } else {
655            BlockVerdict::Reconstructed
656        };
657    }
658    diag.bound_duals_reconstructed = n_reconstructed;
659    diag.bound_duals_rejected = n_rejected;
660
661    if rebuilt.iter().any(|r| r.is_some()) {
662        let pick = |i: usize, orig: &Rc<dyn Vector>| -> Rc<dyn Vector> {
663            rebuilt[i].clone().unwrap_or_else(|| Rc::clone(orig))
664        };
665        let new_curr = IteratesVector::new(
666            Rc::clone(&curr.x),
667            Rc::clone(&curr.s),
668            Rc::clone(&curr.y_c),
669            Rc::clone(&curr.y_d),
670            pick(0, &curr.z_l),
671            pick(1, &curr.z_u),
672            pick(2, &curr.v_l),
673            pick(3, &curr.v_u),
674        );
675        data.borrow_mut().set_curr(new_curr);
676    }
677
678    (
679        SeedMeasurement {
680            mu_hat,
681            inf_pr,
682            seeded_z_amax,
683        },
684        unseeded,
685    )
686}
687
688/// How much primal infeasibility this point carries *in excess of what
689/// the barrier it claims explains* (gh#618). Zero for a point that is
690/// converged on its own terms.
691///
692/// A solve stopped at barrier μ leaves `inf_pr` at the level of its own
693/// tolerance, and the slacks reaching this initializer have been shoved
694/// to `warm_start_slack_bound_push` — routinely *smaller* than that
695/// tolerance. Comparing the two directly would therefore call an exact
696/// restart's active bounds "swamped" and throw away the reconstruction
697/// on precisely the case gh#606 wins on (measured: an unguarded
698/// comparison cost the exact partial restart 11 -> 15 iterations across
699/// the corpus). Requiring the miss to clear the barrier by
700/// [`SEED_REJECTION_TRIGGER`] first is the same conservatism the rest
701/// of this module applies to every other measurement-driven decision.
702///
703/// The caller decides whether to ask at all: `μ̂` is the barrier the
704/// *seed* claims, and a seed that carried no multipliers claims none —
705/// see the `seed_implies_barrier` gate in [`recenter_from_residuals`].
706fn swamping_residual(inf_pr: Number, mu_hat: Number) -> Number {
707    if inf_pr.is_finite() && inf_pr > SEED_REJECTION_TRIGGER * mu_hat {
708        inf_pr
709    } else {
710        0.0
711    }
712}
713
714/// `true` when this slack is smaller than the primal infeasibility of
715/// the point it was measured on, as resolved by [`swamping_residual`]
716/// (gh#618).
717///
718/// Both halves of the bound-multiplier reconstruction read a slack as a
719/// statement about activity. `μ̂ / slack` says "this slack is small, so
720/// this bound is active, so give it a large multiplier"; the
721/// stationarity split says "this bound is active, so it is what carries
722/// the stationarity residual". On a seed that still solves the problem
723/// being solved, both are right, and they are where gh#606's wins come
724/// from — an exact restart has `inf_pr` at round-off and nothing here
725/// ever fires.
726///
727/// A slack smaller than the point's own infeasibility supports neither
728/// claim. The point misses feasibility by more than the distance it
729/// reports to that bound, so which side of the bound it will end up on
730/// is not something this measurement knows. Both halves then have
731/// nothing to derive from, and what is left is the constant the
732/// pre-gh#606 path would have used — the same fallback gh#617 gives a
733/// seed it refuses, reached here by staleness rather than by
734/// incoherence.
735///
736/// This is a per-entry test, not a per-point gate: on a partly-stale
737/// seed the bounds whose slacks still outrun the residual keep their
738/// reconstruction and only the swamped ones fall back, so the
739/// reconstruction's reach scales down with the measurement instead of
740/// switching off at a threshold.
741fn slack_is_swamped(slack: Number, swamping: Number) -> bool {
742    swamping > slack
743}
744
745/// gh#617. Can this seeded bound-multiplier block have come from a
746/// solve of this problem, at the primal point it arrived with?
747///
748/// The test is the complementarity the block *implies*: `|z_i| · s_i`,
749/// averaged over the entries the caller actually seeded, which is the
750/// same quantity [`final_mu`] reads off the assembled point and the
751/// same one the barrier is. A point on any central path — converged,
752/// stale, or mid-solve — carries `z · s` of the order of its own
753/// barrier, and a point that misses feasibility by `inf_pr` can carry
754/// it of that order too. A block reading orders of magnitude above
755/// *both* is not describing this point.
756///
757/// `|z_i|` rather than `z_i`: a strictly negative bound multiplier is
758/// already impossible, and averaging signed products would let the
759/// negative half of a corrupted block cancel the positive half and hide
760/// the very thing being tested for.
761///
762/// Unseeded entries (`0` exactly, or NaN) are excluded — they are what
763/// the reconstruction is *for*, and pairing a zero multiplier against
764/// its slack would drag every average to zero.
765fn seed_is_incoherent(vals: &[Number], sl: &[Number], mu_hat: Number, inf_pr: Number) -> bool {
766    let mut acc = 0.0;
767    let mut n = 0usize;
768    for (v, s) in vals.iter().zip(sl.iter()) {
769        if v.is_nan() || *v == 0.0 {
770            continue;
771        }
772        if !v.is_finite() {
773            // An infinite seed is incoherent with any point at all.
774            return true;
775        }
776        if !s.is_finite() || *s <= 0.0 {
777            continue;
778        }
779        acc += v.abs() * *s;
780        n += 1;
781    }
782    if n == 0 {
783        return false;
784    }
785    let supported = if inf_pr.is_finite() && inf_pr > mu_hat {
786        inf_pr
787    } else {
788        mu_hat
789    };
790    acc / (n as Number) > SEED_REJECTION_TRIGGER * supported
791}
792
793/// gh#617, the equality-multiplier half. Does the seeded `y` belong to
794/// the primal point it arrived with?
795///
796/// At a stationary point `∇f + J_cᵀ y_c + J_dᵀ y_d = P_L z_L − P_U z_U`,
797/// so the residual `r_x` of the left-hand side is bounded by the bound
798/// multipliers — that is the identity
799/// [`refine_bound_duals_from_stationarity`] exists to exploit. A `y`
800/// that leaves `r_x` orders of magnitude above everything else in that
801/// identity is not the `y` of this point, and splitting `r_x` by sign
802/// then manufactures bound multipliers out of the miss.
803///
804/// The scale is `max(‖∇f‖∞, ‖z_seeded‖∞, mu_hat)`, which assumes the
805/// true multipliers are not orders of magnitude larger than the
806/// gradient they balance — for a nonbasic block that is a statement
807/// about the conditioning of the basis, not about the `y`. A badly
808/// conditioned model can therefore have a legitimate `y` refused. It
809/// is deliberate that `‖Jᵀy‖∞` is *not* in the scale: adding the very
810/// quantity under test would make the comparison vacuous. This bound
811/// is derived, not measured; the mitigation is that a refusal only
812/// declines to *derive* from the `y`, never discards it.
813///
814/// Unlike a rejected `z` block the `y` is **not** overwritten: the
815/// pre-gh#606 path keeps a supplied `y` (clamped) and there is no
816/// constant fill to fall back to. What the rejection buys is that
817/// nothing is *derived* from it.
818fn eq_seed_is_incoherent(
819    r_x: &dyn Vector,
820    grad_f: &dyn Vector,
821    seeded_z_amax: Number,
822    mu_hat: Number,
823) -> bool {
824    let resid = r_x.amax();
825    if !resid.is_finite() {
826        return true;
827    }
828    // The scale is what the *caller* supplied, never what this pass
829    // just built: scaling the test by the reconstruction's own output
830    // lets a `μ̂ / slack` fill at a tight bound — which is large by
831    // construction — vouch for the very seed it was derived from.
832    // Measured on `nmpc_vanderpol`, doing that hid a corrupted `y`
833    // behind a reconstructed `z` of order `1e3` and cost the corrupted
834    // partial seed 2 -> 12 iterations.
835    let scale = grad_f.amax().max(seeded_z_amax).max(mu_hat);
836    if !scale.is_finite() || scale <= 0.0 {
837        return false;
838    }
839    resid > SEED_REJECTION_TRIGGER * scale
840}
841
842/// gh#606 step 3b. Having reconstructed the equality multipliers,
843/// revisit the bound multipliers that were unseeded and replace the
844/// `μ̂ / slack` guess with the value stationarity actually implies.
845///
846/// `∇_x L = ∇f + J_cᵀ y_c + J_dᵀ y_d − P_L z_L + P_U z_U`, so at a
847/// stationary point `P_L z_L − P_U z_U = r_x` with
848/// `r_x = ∇f + J_cᵀ y_c + J_dᵀ y_d`; splitting `r_x` by sign and
849/// selecting through `P_Lᵀ` / `P_Uᵀ` is the positivity-preserving
850/// solution of that identity. The slack block is the same identity one
851/// row down: `∇_s L = −y_d − P_L v_L + P_U v_U`, so `−y_d` splits into
852/// `v_L` / `v_U`.
853///
854/// Only runs when the equality multipliers came from the caller. When
855/// they were themselves reconstructed a few lines earlier, `r_x` is
856/// built from a `y` that the least-squares solve derived *from* the
857/// provisional `μ̂ / slack` fill, so the split re-derives its own
858/// input — and the round trip is lossy at exactly the points that make
859/// it hard. Measured over `benchmarks/warmstart`, running it anyway
860/// cost a primal-only warm start 1102 -> 1263 iterations across 27
861/// paths (`degenerate_corner` 17 -> 38, `rosenbrock_ring` 28 -> 66),
862/// with the wins concentrated where `y` *was* supplied.
863///
864/// Why this and not `μ̂ / slack` alone: the slacks reaching here have
865/// already been shoved into the bound interior by
866/// `warm_start_slack_bound_push`, so on a converged point — where the
867/// true slack sits at `μ / z` and the push dominates it — `μ̂ / slack`
868/// is off by exactly the push's inflation. Measured on HS071, that put
869/// the reconstructed inequality multiplier 5.5x low and took an exact
870/// restart from 1 iteration to 5. The stationarity split is immune to
871/// it because it never looks at a slack.
872///
873/// The `μ̂ / slack` value survives as the *floor*: at an inactive bound
874/// the split contributes nothing and complementarity is what says how
875/// big the multiplier should be.
876fn refine_bound_duals_from_stationarity(
877    data: &IpoptDataHandle,
878    cq: &IpoptCqHandle,
879    nlp: &Rc<RefCell<dyn IpoptNlp>>,
880    opts: &WarmStartOptions,
881    seed: SeedMeasurement,
882    unseeded: &[Vec<bool>; 4],
883    diag: &mut WarmStartDiagnostics,
884) -> bool {
885    let SeedMeasurement {
886        mu_hat,
887        inf_pr,
888        seeded_z_amax,
889    } = seed;
890    // `true` only when the split actually rewrote a multiplier, so the
891    // caller can report `stationarity_split` honestly (gh#606 review).
892    if unseeded.iter().all(|m| m.is_empty()) {
893        return false;
894    }
895    let curr = match data.borrow().curr.clone() {
896        Some(c) => c,
897        None => return false,
898    };
899
900    // r_x = ∇f + J_cᵀ y_c + J_dᵀ y_d, and r_s = −y_d.
901    let (r_x, r_s, slacks, grad_f) = {
902        let cq_ref = cq.borrow();
903        let grad_f = cq_ref.curr_grad_f();
904        let jc_t = cq_ref.curr_jac_c_t_times_curr_y_c();
905        let jd_t = cq_ref.curr_jac_d_t_times_curr_y_d();
906        let mut r_x = grad_f.make_new();
907        r_x.copy(&*grad_f);
908        r_x.add_two_vectors(1.0, &*jc_t, 1.0, &*jd_t, 1.0);
909        let mut r_s = curr.y_d.make_new();
910        r_s.copy(&*curr.y_d);
911        r_s.scal(-1.0);
912        let slacks = [
913            cq_ref.curr_slack_x_l(),
914            cq_ref.curr_slack_x_u(),
915            cq_ref.curr_slack_s_l(),
916            cq_ref.curr_slack_s_u(),
917        ];
918        (r_x, r_s, slacks, grad_f)
919    };
920
921    // gh#617. The split's whole input is `r_x` / `r_s`, i.e. the
922    // supplied `y`. If that `y` does not belong to this primal point,
923    // splitting its miss by sign manufactures bound multipliers out of
924    // the corruption; the `μ̂ / slack` fill already in place is the
925    // pre-gh#606-shaped answer and is left standing.
926    if eq_seed_is_incoherent(&*r_x, &*grad_f, seeded_z_amax, mu_hat) {
927        diag.eq_duals_rejected = true;
928        return false;
929    }
930
931    let nlp_ref = nlp.borrow();
932    // Rearranged, the two identities read `P_L z_L − P_U z_U = r_x`
933    // and `P_L v_L − P_U v_U = r_s`, so each lower block takes `+Pᵀr`
934    // and each upper block `−Pᵀr`; the `max(·, 0)` that makes the
935    // split well-posed is the `.max(floor)` in the loop below.
936    let targets: [Option<Vec<Number>>; 4] = [
937        project(&*r_x, &*nlp_ref.px_l(), 1.0, &curr.z_l),
938        project(&*r_x, &*nlp_ref.px_u(), -1.0, &curr.z_u),
939        project(&*r_s, &*nlp_ref.pd_l(), 1.0, &curr.v_l),
940        project(&*r_s, &*nlp_ref.pd_u(), -1.0, &curr.v_u),
941    ];
942    drop(nlp_ref);
943
944    let blocks = [&curr.z_l, &curr.z_u, &curr.v_l, &curr.v_u];
945    let mut rebuilt: [Option<Rc<dyn Vector>>; 4] = [None, None, None, None];
946    for (i, block) in blocks.iter().enumerate() {
947        let mask = &unseeded[i];
948        if mask.is_empty() {
949            continue;
950        }
951        let (Some(target), Some(mut vals), Some(sl)) =
952            (targets[i].clone(), flatten(&***block), flatten(&*slacks[i]))
953        else {
954            continue;
955        };
956        if target.len() != vals.len() || sl.len() != vals.len() || mask.len() != vals.len() {
957            continue;
958        }
959        let cap = opts.mult_init_max_or_inf();
960        let hard_floor = opts.mult_bound_push.max(MU_FLOOR);
961        let swamping = swamping_residual(inf_pr, mu_hat);
962        for k in 0..vals.len() {
963            if !mask[k] {
964                continue;
965            }
966            // gh#618. Both terms below read `sl[k]` as a statement
967            // about whether this bound is active, and a slack the
968            // point's own infeasibility swamps makes no such statement
969            // — so the entry keeps the constant the pre-gh#606 path
970            // would have given it. Same principle [`final_mu`] applies
971            // to μ, one level down: a residual is not a multiplier.
972            if slack_is_swamped(sl[k], swamping) {
973                vals[k] = hard_floor.min(cap);
974                continue;
975            }
976            let compl_floor = if sl[k].is_finite() && sl[k] > 0.0 {
977                mu_hat / sl[k]
978            } else {
979                mu_hat
980            };
981            // gh#617. The split may raise a multiplier above what
982            // complementarity implies — that is the point of it, and
983            // the margin is real: the slacks reaching here have been
984            // shoved to `warm_start_slack_bound_push`, which on a
985            // converged point inflates them and put gh#606's
986            // reconstructed HS071 multiplier 5.5x low. It may not raise
987            // it by *orders*. A point at barrier μ̂ carries `z · s ≈ μ̂`
988            // at every bound, converged or stale, so a split demanding
989            // a thousand times that is not describing a point at this
990            // barrier — it is a corrupted `y`'s stationarity miss being
991            // laundered into a multiplier, which then reads back as
992            // enormous complementarity and escalates μ to the ceiling.
993            // Measured on `nmpc_vanderpol`, that took a corrupted
994            // partial seed to 12 iterations, against 2 with the cap.
995            let split = if target[k].is_finite() {
996                target[k].min(SEED_REJECTION_TRIGGER * compl_floor)
997            } else {
998                0.0
999            };
1000            vals[k] = split.max(compl_floor).max(hard_floor).min(cap);
1001        }
1002        let mut out = block.make_new();
1003        if scatter(&mut *out, &vals) {
1004            rebuilt[i] = Some(Rc::from(out));
1005        }
1006    }
1007
1008    if rebuilt.iter().all(|r| r.is_none()) {
1009        return false;
1010    }
1011    let pick = |i: usize, orig: &Rc<dyn Vector>| -> Rc<dyn Vector> {
1012        rebuilt[i].clone().unwrap_or_else(|| Rc::clone(orig))
1013    };
1014    let new_curr = IteratesVector::new(
1015        Rc::clone(&curr.x),
1016        Rc::clone(&curr.s),
1017        Rc::clone(&curr.y_c),
1018        Rc::clone(&curr.y_d),
1019        pick(0, &curr.z_l),
1020        pick(1, &curr.z_u),
1021        pick(2, &curr.v_l),
1022        pick(3, &curr.v_u),
1023    );
1024    data.borrow_mut().set_curr(new_curr);
1025    true
1026}
1027
1028/// `sign · Pᵀ r`, as a plain slice of length `n_out`. `P` is one of the
1029/// packed bound-selection matrices, so the transpose picks out the
1030/// components that actually carry that bound.
1031fn project(
1032    r: &dyn Vector,
1033    p: &dyn pounce_linalg::Matrix,
1034    sign: Number,
1035    template: &Rc<dyn Vector>,
1036) -> Option<Vec<Number>> {
1037    if template.dim() == 0 {
1038        return None;
1039    }
1040    let mut out = template.make_new();
1041    out.set(0.0);
1042    p.trans_mult_vector(sign, r, 0.0, &mut *out);
1043    flatten(&*out)
1044}
1045
1046/// gh#606 step 3. Re-derive an identically-zero `y` block from
1047/// stationarity.
1048///
1049/// A converged equality multiplier vector is zero only when every
1050/// equality is inactive in the Lagrangian, which the least-squares
1051/// solve reproduces anyway — so treating an all-zero block as
1052/// "unseeded" is self-correcting on a genuinely-zero one, and is the
1053/// only signal available: an absent `lagrange=` seed and a supplied
1054/// vector of zeros reach this initializer as the same bytes.
1055///
1056/// This is the same [`LeastSquareMults`] augmented solve the cold path
1057/// runs, so the "sparse regularized stationarity least-squares"
1058/// machinery is shared rather than duplicated — the difference is that
1059/// here it runs with real seeded bound multipliers in its right-hand
1060/// side instead of the cold path's constant `bound_mult_init_val`.
1061fn reconstruct_eq_duals(
1062    data: &IpoptDataHandle,
1063    cq: &IpoptCqHandle,
1064    nlp: &Rc<RefCell<dyn IpoptNlp>>,
1065    aug_solver: &mut dyn AugSystemSolver,
1066    opts: &WarmStartOptions,
1067    diag: &mut WarmStartDiagnostics,
1068) {
1069    let curr = match data.borrow().curr.clone() {
1070        Some(c) => c,
1071        None => return,
1072    };
1073    let (n_yc, n_yd) = (curr.y_c.dim(), curr.y_d.dim());
1074    if n_yc + n_yd == 0 {
1075        return;
1076    }
1077    // Upstream's own guard from the cold path: with as many equalities
1078    // as variables the least-squares system is square and the estimate
1079    // is not a projection of anything.
1080    if n_yc == curr.x.dim() {
1081        diag.eq_duals = BlockVerdict::Accepted;
1082        return;
1083    }
1084    let seeded = !is_identically_zero(&curr.y_c) || !is_identically_zero(&curr.y_d);
1085    if seeded {
1086        diag.eq_duals = BlockVerdict::Accepted;
1087        return;
1088    }
1089
1090    let mut new_y_c = DenseVectorSpace::new(n_yc).make_new_dense();
1091    let mut new_y_d = DenseVectorSpace::new(n_yd).make_new_dense();
1092    new_y_c.set(0.0);
1093    new_y_d.set(0.0);
1094    let ok = LeastSquareMults::new().calculate_y_eq(
1095        data,
1096        cq,
1097        nlp,
1098        aug_solver,
1099        &mut new_y_c,
1100        &mut new_y_d,
1101    );
1102    if !ok {
1103        diag.eq_duals = BlockVerdict::Discarded;
1104        return;
1105    }
1106    let norm = new_y_c.amax().max(new_y_d.amax());
1107    if !norm.is_finite() || (opts.constr_mult_init_max > 0.0 && norm > opts.constr_mult_init_max) {
1108        // Same verdict, and the same cap, the cold path reaches on an
1109        // over-large estimate: keep the zeros rather than start the
1110        // solve on a multiplier the cap was written to exclude. On a
1111        // rank-deficient model the least-squares system is singular and
1112        // this is the branch that catches it.
1113        diag.eq_duals = BlockVerdict::Discarded;
1114        return;
1115    }
1116    diag.eq_duals = BlockVerdict::Reconstructed;
1117    let new_curr = IteratesVector::new(
1118        Rc::clone(&curr.x),
1119        Rc::clone(&curr.s),
1120        Rc::new(new_y_c),
1121        Rc::new(new_y_d),
1122        Rc::clone(&curr.z_l),
1123        Rc::clone(&curr.z_u),
1124        Rc::clone(&curr.v_l),
1125        Rc::clone(&curr.v_u),
1126    );
1127    data.borrow_mut().set_curr(new_curr);
1128}
1129
1130/// gh#606 step 4. The barrier parameter the assembled point deserves.
1131///
1132/// Measured on the reconstructed iterate, so `avrg_compl` reflects the
1133/// multipliers the solve will actually start from rather than the
1134/// holes the caller left.
1135///
1136/// **Only the complementarity moves μ.** That is not an oversight: of
1137/// the three KKT residuals, complementarity is the one μ *is*, and the
1138/// other two are what the Newton step is for. A warm point at a
1139/// slightly moved parameter carries a primal and a dual residual of
1140/// order `Δθ` by construction — that is the premise of warm starting —
1141/// and raising μ to meet them throws away the warm start to pay for a
1142/// step the solver was about to take anyway. Measured over the
1143/// `benchmarks/warmstart` corpus, a `μ ≥ κ·max(inf_pr, inf_du)` rule
1144/// cost 715 → 1129 iterations across 27 parametric paths: on
1145/// `simplex_proj/tiny` a re-solve that needed one iteration measured
1146/// `inf_du = 2e-3`, took `μ = 2e-3`, and needed five. `avrg_compl` on
1147/// the same point read `2.6e-9` — the converged barrier, correctly
1148/// recognised. A genuinely stale point is still caught, because its
1149/// multipliers and its slacks no longer pair up and `avrg_compl` rises
1150/// with them.
1151///
1152/// `mu_in` is a floor, never a ceiling: `mu_init` is an explicit
1153/// statement about the barrier the caller wants, and the measurement
1154/// is here to catch a point that cannot support it, not to
1155/// second-guess a good one downward. It also has to miss by
1156/// [`MU_ESCALATION_TRIGGER`] before it is overridden at all.
1157/// `may_move` is false when the caller seeded no duals at all. The
1158/// measurements are still recorded — they describe the iterate the
1159/// solve really starts from, which is what `info["warm_start"]` is for
1160/// — but they describe *this initializer's own fill*, so they do not
1161/// get to reroute the solve. See the `mu_may_move` note at the call
1162/// site (gh#622).
1163fn final_mu(
1164    data: &IpoptDataHandle,
1165    cq: &IpoptCqHandle,
1166    diag: &mut WarmStartDiagnostics,
1167    may_move: bool,
1168) -> Number {
1169    let (compl, inf_du) = {
1170        let cq_ref = cq.borrow();
1171        (
1172            cq_ref.curr_avrg_compl(),
1173            cq_ref.curr_dual_infeasibility_max(),
1174        )
1175    };
1176    diag.complementarity = compl;
1177    // Recorded but deliberately not fed into μ — see above. It is the
1178    // number that says whether the reconstruction worked.
1179    diag.dual_residual = inf_du;
1180
1181    let mu_in = data.borrow().curr_mu;
1182    // A non-finite or non-positive barrier is not a setting, it is a
1183    // broken one, and the band's fallback still applies to it.
1184    if !mu_in.is_finite() || mu_in <= 0.0 {
1185        return MU_CEILING;
1186    }
1187    // Clamp the *measurement*, never the caller's setting (gh#606
1188    // review). The previous form applied `safe_mu` to the pass-through
1189    // value too, so an explicit `mu_init = 1.0` silently started the
1190    // solve at `MU_CEILING` — on every warm start, escalation or not,
1191    // and without printing anything, since the `wmu` token only fires
1192    // when μ goes up. `.max(mu_in)` keeps the floor semantics this
1193    // function documents without capping a barrier the caller chose on
1194    // purpose.
1195    if may_move && compl.is_finite() && compl > MU_ESCALATION_TRIGGER * mu_in {
1196        safe_mu(compl).max(mu_in)
1197    } else {
1198        mu_in
1199    }
1200}
1201
1202/// Clamp a candidate μ into the band a warm start may use, and reject
1203/// non-finite candidates outright.
1204fn safe_mu(mu: Number) -> Number {
1205    if !mu.is_finite() || mu <= 0.0 {
1206        return MU_CEILING;
1207    }
1208    mu.clamp(MU_FLOOR, MU_CEILING)
1209}
1210
1211/// Copy a vector's scalars out, whatever storage it uses. `None` for a
1212/// layout this module does not model, or one that was never written.
1213fn flatten(v: &dyn Vector) -> Option<Vec<Number>> {
1214    if let Some(d) = v.as_any().downcast_ref::<DenseVector>() {
1215        // `expanded_values`, not `values`: a block written with
1216        // `set(c)` is stored homogeneously and `values` asserts on it.
1217        return d.is_initialized().then(|| d.expanded_values());
1218    }
1219    if let Some(c) = v.as_any().downcast_ref::<CompoundVector>() {
1220        let mut out = Vec::with_capacity(v.dim() as usize);
1221        for i in 0..c.n_comps() {
1222            out.extend(flatten(c.comp(i))?);
1223        }
1224        return Some(out);
1225    }
1226    None
1227}
1228
1229/// Inverse of [`flatten`]. `false` when the layout or the length does
1230/// not match, in which case the caller must leave the block alone.
1231fn scatter(v: &mut dyn Vector, src: &[Number]) -> bool {
1232    if v.dim() as usize != src.len() {
1233        return false;
1234    }
1235    if v.as_any().is::<DenseVector>() {
1236        let d = v.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1237        d.values_mut().copy_from_slice(src);
1238        return true;
1239    }
1240    if v.as_any().is::<CompoundVector>() {
1241        let c = v.as_any_mut().downcast_mut::<CompoundVector>().unwrap();
1242        let mut off = 0usize;
1243        for i in 0..c.n_comps() {
1244            let comp = c.comp_mut(i);
1245            let n = comp.dim() as usize;
1246            if !scatter(comp, &src[off..off + n]) {
1247                return false;
1248            }
1249            off += n;
1250        }
1251        return true;
1252    }
1253    false
1254}
1255
1256/// `true` when every entry is exactly `0` — the signature of an
1257/// equality-multiplier block that no caller seeded. An uninitialized
1258/// block counts as zero: that is what the clamp step below fills it
1259/// with.
1260///
1261/// An **empty** block is zero vacuously, and saying so is what makes
1262/// [`reconstruct_eq_duals`] work on a model that has only equality
1263/// rows or only inequality rows. Returning `false` there — "this
1264/// zero-dimension block carries a seed" — short-circuited the
1265/// reconstruction on every single-block model and then reported the
1266/// result as `Accepted` (gh#606 review). The two [`any_dual_seeded`]
1267/// call sites already guard on `dim() > 0`, so they are unaffected.
1268fn is_identically_zero(v: &Rc<dyn Vector>) -> bool {
1269    if v.dim() == 0 {
1270        return true;
1271    }
1272    match flatten(&**v) {
1273        Some(vals) => vals.iter().all(|e| *e == 0.0),
1274        // Never written -> the clamp block collapses it to zero.
1275        None => true,
1276    }
1277}
1278
1279/// Pull a fresh starting iterate from the NLP (which routes to
1280/// `TNLP::get_starting_point` with `init_x` / `init_lambda` /
1281/// `init_z` all true), push the primals and slacks into the bound
1282/// interior using warm-start-specific `bound_push`/`bound_frac`, and
1283/// install the result on `data.curr`. Mirrors steps 1-4 of
1284/// `DefaultIterateInitializer::set_initial_iterates`, but with
1285/// upstream's warm-start option block governing the push.
1286fn seed_from_nlp(
1287    data: &IpoptDataHandle,
1288    nlp: &Rc<RefCell<dyn IpoptNlp>>,
1289    opts: &WarmStartOptions,
1290) -> bool {
1291    if !nlp.borrow_mut().prepare_warm_start() {
1292        return false;
1293    }
1294    let (n_x, n_s, n_yc, n_yd, n_zl, n_zu, n_vl, n_vu) = {
1295        let borrow = data.borrow();
1296        let c = borrow.curr.as_ref().unwrap();
1297        (
1298            c.x.dim(),
1299            c.s.dim(),
1300            c.y_c.dim(),
1301            c.y_d.dim(),
1302            c.z_l.dim(),
1303            c.z_u.dim(),
1304            c.v_l.dim(),
1305            c.v_u.dim(),
1306        )
1307    };
1308
1309    let mut x = DenseVectorSpace::new(n_x).make_new_dense();
1310    nlp.borrow_mut().get_starting_x(&mut x);
1311    {
1312        let nlp_ref = nlp.borrow();
1313        push_x_into_interior(
1314            &mut x,
1315            &*nlp_ref.px_l(),
1316            nlp_ref.x_l(),
1317            &*nlp_ref.px_u(),
1318            nlp_ref.x_u(),
1319            opts.bound_push,
1320            opts.bound_frac,
1321        );
1322    }
1323
1324    let mut s = DenseVectorSpace::new(n_s).make_new_dense();
1325    nlp.borrow_mut().eval_d(&x, &mut s);
1326    {
1327        let nlp_ref = nlp.borrow();
1328        push_x_into_interior(
1329            &mut s,
1330            &*nlp_ref.pd_l(),
1331            nlp_ref.d_l(),
1332            &*nlp_ref.pd_u(),
1333            nlp_ref.d_u(),
1334            opts.slack_bound_push,
1335            opts.slack_bound_frac,
1336        );
1337    }
1338
1339    let mut y_c = DenseVectorSpace::new(n_yc).make_new_dense();
1340    let mut y_d = DenseVectorSpace::new(n_yd).make_new_dense();
1341    y_c.set(0.0);
1342    y_d.set(0.0);
1343    nlp.borrow_mut().get_starting_y(&mut y_c, &mut y_d);
1344
1345    let mut z_l = DenseVectorSpace::new(n_zl).make_new_dense();
1346    let mut z_u = DenseVectorSpace::new(n_zu).make_new_dense();
1347    let mut v_l = DenseVectorSpace::new(n_vl).make_new_dense();
1348    let mut v_u = DenseVectorSpace::new(n_vu).make_new_dense();
1349    // Unseeded, not zero (gh#622): whatever `get_starting_z` declines
1350    // to write is a block the caller never seeded, and the clamp block
1351    // below resolves NaN to `bound_mult_init_val` where a literal 0
1352    // would pass for a supplied multiplier and be floored at
1353    // `warm_start_mult_bound_push` — 1e-9 under the pushes
1354    // `pounce.WarmStart` ships, which declares every bound inactive.
1355    // `OrigIpoptNlp::fetch_warm_start_snapshot` carries the same
1356    // marker and the full reasoning; this pre-fill is what survives if
1357    // the NLP declines the request outright.
1358    //
1359    // `v_l` / `v_u` keep the zero fill: `TNLP::get_starting_point` has
1360    // no field for the slack-bound multipliers, so they are not blocks
1361    // a caller left out — they were never on offer, and gh#606's
1362    // reconstruction is what fills them whenever any dual is seeded.
1363    z_l.set(Number::NAN);
1364    z_u.set(Number::NAN);
1365    v_l.set(0.0);
1366    v_u.set(0.0);
1367    nlp.borrow_mut()
1368        .get_starting_z(&mut z_l, &mut z_u, &mut v_l, &mut v_u);
1369    nlp.borrow_mut().finish_warm_start();
1370
1371    let iv = IteratesVector::new(
1372        Rc::new(x),
1373        Rc::new(s),
1374        Rc::new(y_c),
1375        Rc::new(y_d),
1376        Rc::new(z_l),
1377        Rc::new(z_u),
1378        Rc::new(v_l),
1379        Rc::new(v_u),
1380    );
1381    data.borrow_mut().set_curr(iv);
1382    true
1383}
1384
1385fn is_initialized(v: &Rc<dyn Vector>) -> bool {
1386    if v.dim() == 0 {
1387        return true;
1388    }
1389    v.as_any()
1390        .downcast_ref::<DenseVector>()
1391        .map(|d| d.is_initialized())
1392        .unwrap_or(true)
1393}
1394
1395/// Replace every NaN entry of `v` with `fill`, in place.
1396///
1397/// NaN in a user-supplied multiplier seed means "unseeded" (see the
1398/// `Problem.solve` contract), and has to be resolved before the
1399/// clamps: `element_wise_min`/`element_wise_max` would propagate it
1400/// into the iterate, poisoning the solve.
1401///
1402/// Both `Vector` storage layouts are handled. A dense block is
1403/// scanned directly; a compound block recurses into its components,
1404/// so the contract holds wherever the iterate's multiplier blocks
1405/// live — the seed path (`seed_from_nlp`) always builds dense
1406/// vectors, but the re-optimize path reuses whatever the previous
1407/// solve's spaces produced, and a debug-only guard would be compiled
1408/// out of exactly the release builds that ship.
1409fn resolve_nan_seeds(v: &mut dyn Vector, fill: f64) {
1410    // Type-test before taking the mutable borrow: `if let Some(d) =
1411    // v.as_any_mut()… else` would keep that borrow live across the
1412    // else arm.
1413    if v.as_any().is::<DenseVector>() {
1414        let d = v.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1415        for e in d.values_mut() {
1416            if e.is_nan() {
1417                *e = fill;
1418            }
1419        }
1420    } else if v.as_any().is::<CompoundVector>() {
1421        let c = v.as_any_mut().downcast_mut::<CompoundVector>().unwrap();
1422        for i in 0..c.n_comps() {
1423            resolve_nan_seeds(c.comp_mut(i), fill);
1424        }
1425    } else {
1426        // `DenseVector` and `CompoundVector` are the only `Vector`
1427        // implementations; a third one must be handled here, or NaN
1428        // rides the clamps into the iterate as a silent poison.
1429        debug_assert!(false, "resolve_nan_seeds: unhandled Vector implementation");
1430    }
1431}
1432
1433/// Clone `v` into a fresh owned vector and clamp every entry to
1434/// `[lo, hi]` componentwise. Empty vectors short-circuit. Vectors that
1435/// were never written to (the application's placeholder seed iterates
1436/// before any solve ran) collapse to a zero-initialized vector — `0`
1437/// is inside every well-formed warm-start clamp range, so this matches
1438/// upstream's behavior when a multiplier block has no carry-over
1439/// value.
1440fn clone_clamped(v: &Rc<dyn Vector>, lo: f64, hi: f64, nan_fill: f64) -> Rc<dyn Vector> {
1441    let n = v.dim();
1442    if n == 0 {
1443        return Rc::clone(v);
1444    }
1445    let mut out = v.make_new();
1446    let initialized = v
1447        .as_any()
1448        .downcast_ref::<DenseVector>()
1449        .map(|d| d.is_initialized())
1450        .unwrap_or(true);
1451    if initialized {
1452        out.copy(&**v);
1453        // NaN marks an unseeded entry; resolve it before the clamps
1454        // (element-wise min/max would just propagate it)
1455        resolve_nan_seeds(&mut *out, nan_fill);
1456    } else {
1457        out.set(0.0);
1458    }
1459    let mut cap_hi = v.make_new();
1460    cap_hi.set(hi);
1461    out.element_wise_min(&*cap_hi);
1462    let mut cap_lo = v.make_new();
1463    cap_lo.set(lo);
1464    out.element_wise_max(&*cap_lo);
1465    Rc::from(out)
1466}
1467
1468#[cfg(test)]
1469mod tests_nan_seed {
1470    use super::*;
1471    use pounce_linalg::compound_vector::CompoundVectorSpace;
1472    use pounce_linalg::dense_vector::DenseVectorSpace;
1473
1474    #[test]
1475    fn nan_entries_take_the_fill_before_clamping() {
1476        let space = DenseVectorSpace::new(3);
1477        let mut d = space.make_new_dense();
1478        d.values_mut().copy_from_slice(&[0.5, f64::NAN, 2e7]);
1479        let v: Rc<dyn Vector> = Rc::from(d);
1480        let out = clone_clamped(&v, 1e-3, 1e6, 7.0);
1481        let out = out.as_any().downcast_ref::<DenseVector>().unwrap();
1482        assert_eq!(out.values()[0], 0.5);
1483        assert_eq!(out.values()[1], 7.0); // unseeded -> fill
1484        assert_eq!(out.values()[2], 1e6); // then the cap applies
1485    }
1486
1487    /// The re-optimize path reuses the previous solve's vector spaces,
1488    /// which are compound for a blocked NLP. NaN has to resolve there
1489    /// too: a debug-only guard is compiled out of the release builds
1490    /// that ship, so an unresolved NaN would ride the clamps into the
1491    /// iterate and poison the solve.
1492    #[test]
1493    fn nan_resolves_inside_a_compound_vector() {
1494        let inner = DenseVectorSpace::new(2);
1495        let space = CompoundVectorSpace::new(2, 4);
1496        for icomp in 0..2 {
1497            let inner = Rc::clone(&inner);
1498            space.set_comp(icomp, 2, move || {
1499                let mut d = inner.make_new_dense();
1500                d.set(0.0);
1501                Box::new(d)
1502            });
1503        }
1504        let mut cv = CompoundVector::new(Rc::clone(&space));
1505        for (icomp, vals) in [[0.5, f64::NAN], [f64::NAN, 2e7]].into_iter().enumerate() {
1506            let c = cv.comp_mut(icomp as pounce_common::types::Index);
1507            let d = c.as_any_mut().downcast_mut::<DenseVector>().unwrap();
1508            d.values_mut().copy_from_slice(&vals);
1509        }
1510
1511        let v: Rc<dyn Vector> = Rc::from(cv);
1512        let out = clone_clamped(&v, 1e-3, 1e6, 7.0);
1513
1514        let out = out.as_any().downcast_ref::<CompoundVector>().unwrap();
1515        let flat: Vec<f64> = (0..out.n_comps())
1516            .flat_map(|i| {
1517                out.comp(i)
1518                    .as_any()
1519                    .downcast_ref::<DenseVector>()
1520                    .unwrap()
1521                    .values()
1522                    .to_vec()
1523            })
1524            .collect();
1525        assert_eq!(flat[0], 0.5);
1526        assert_eq!(flat[1], 7.0); // unseeded -> fill, not NaN
1527        assert_eq!(flat[2], 7.0);
1528        assert_eq!(flat[3], 1e6); // then the cap applies
1529    }
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534    use super::*;
1535    use pounce_linalg::dense_vector::DenseVectorSpace;
1536
1537    fn dense(n: i32, fill: f64) -> Rc<dyn Vector> {
1538        let space = DenseVectorSpace::new(n);
1539        let mut v = space.make_new_dense();
1540        v.set(fill);
1541        Rc::new(v)
1542    }
1543
1544    #[test]
1545    fn clamps_multipliers_to_cap() {
1546        let v = dense(3, 1e10);
1547        let out = clone_clamped(&v, 0.0, 1e6, 0.0);
1548        assert_eq!(out.amax(), 1e6);
1549        let v2 = dense(3, -1e10);
1550        let out2 = clone_clamped(&v2, -1e6, 1e6, 0.0);
1551        assert_eq!(out2.amax(), 1e6);
1552    }
1553
1554    #[test]
1555    fn clamps_bound_mults_nonneg() {
1556        let v = dense(3, -5.0);
1557        let out = clone_clamped(&v, 0.0, 1e6, 0.0);
1558        assert_eq!(out.amax(), 0.0);
1559    }
1560
1561    #[test]
1562    fn empty_vector_short_circuits() {
1563        let v = dense(0, 0.0);
1564        let out = clone_clamped(&v, 0.0, 1.0, 0.0);
1565        assert_eq!(out.dim(), 0);
1566    }
1567
1568    #[test]
1569    fn in_range_values_pass_through_untouched() {
1570        let v = dense(3, 0.5);
1571        let out = clone_clamped(&v, 0.0, 1.0, 0.0);
1572        assert!((out.max() - 0.5).abs() < 1e-15);
1573        assert!((out.min() - 0.5).abs() < 1e-15);
1574    }
1575
1576    #[test]
1577    fn mult_bound_push_floors_zero_bound_multipliers() {
1578        // A carried-in z = 0 (inactive bound in the previous solution)
1579        // must be floored at warm_start_mult_bound_push, matching
1580        // upstream's ElementWiseMax — the barrier needs z > 0.
1581        let v = dense(3, 0.0);
1582        let out = clone_clamped(&v, 1e-3, 1e6, 0.0);
1583        assert!((out.min() - 1e-3).abs() < 1e-18);
1584        // Values already above the floor pass through.
1585        let v2 = dense(3, 0.7);
1586        let out2 = clone_clamped(&v2, 1e-3, 1e6, 0.0);
1587        assert!((out2.max() - 0.7).abs() < 1e-15);
1588    }
1589
1590    #[test]
1591    fn uninitialized_source_collapses_to_zero() {
1592        // Application's placeholder seed iterate: vector allocated but
1593        // never written. `clone_clamped` must fall back to zero instead
1594        // of tripping the dense-vector "must be initialized" assert.
1595        let space = DenseVectorSpace::new(4);
1596        let v: Rc<dyn Vector> = Rc::new(space.make_new_dense());
1597        let out = clone_clamped(&v, 0.0, 1e6, 0.0);
1598        assert_eq!(out.amax(), 0.0);
1599    }
1600}
1601
1602/// Did the caller supply *any* dual information?
1603///
1604/// True when some equality multiplier is non-zero, or some bound
1605/// multiplier is strictly positive and finite. Both are the
1606/// signatures the per-block "unseeded" tests use, taken across every
1607/// block at once: an entry that is exactly `0` cannot have come from a
1608/// converged solve, and neither can a whole `y` block of zeros.
1609///
1610/// This is the gate on the reconstruction as a whole. Completing a
1611/// partial warm start is well-posed — the supplied blocks pin the
1612/// missing ones through stationarity. Manufacturing all of them from a
1613/// primal point alone is not: the result is the cold path's estimate
1614/// paired with the warm path's barrier, and it measured worse than the
1615/// constants it replaced.
1616fn any_dual_seeded(data: &IpoptDataHandle) -> bool {
1617    let Some(curr) = data.borrow().curr.clone() else {
1618        return false;
1619    };
1620    for y in [&curr.y_c, &curr.y_d] {
1621        if y.dim() > 0 && !is_identically_zero(y) {
1622            return true;
1623        }
1624    }
1625    for z in [&curr.z_l, &curr.z_u, &curr.v_l, &curr.v_u] {
1626        if z.dim() == 0 {
1627            continue;
1628        }
1629        if let Some(vals) = flatten(&**z) {
1630            if vals.iter().any(|v| v.is_finite() && *v > 0.0) {
1631                return true;
1632            }
1633        }
1634    }
1635    false
1636}