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}