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}