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