pounce_presolve/lib.rs
1//! Algorithmic NLP preprocessing exposed as a composable TNLP wrapper.
2//!
3//! Tracks pounce issue #20.
4//!
5//! * **Phase 0** — scaffolding, options table, no-op identity path.
6//! * **Phase 1** — Andersen-style bound tightening against linear rows.
7//! * **Phase 2** — redundant linear-constraint removal: rows whose
8//! activity interval is implied by the (possibly Phase-1-tightened)
9//! variable box are dropped from the problem the solver sees, then
10//! reinstated with `λ=0` when forwarding `eval_h` / `finalize_solution`
11//! to the inner TNLP.
12//!
13//! **Dual attribution (issue M24).** A row that was *itself* the
14//! reason a bound got tightened in Phase 1 — e.g. `x ≥ 2` tightening
15//! `x_l = 2` — then has an activity interval flush against its own
16//! bound, so Phase 2 sees it as redundant and drops it. If that bound is
17//! active at the solution the interior-point method reports the dual on
18//! the *variable bound* multiplier (`z_l`/`z_u`) against a bound that did
19//! not exist in the original problem. For a **singleton** row Phase 2
20//! dropped, `finalize_solution` hands that multiplier back to the row
21//! (`λ = (z_u − z_l)/a`, the bound's side zeroed), and
22//! `get_starting_point` does the inverse, so the duals agree with a
23//! no-presolve solve and a `.sol` round-trips; see `SingletonBound`. A
24//! declared bound tied with the row keeps the multiplier, row `λ = 0`
25//! (a degenerate pair: `presolve=no`'s interior point splits it roughly
26//! 1:1, and both are valid KKT points; the rule is "the declared bound is
27//! the incumbent", pinned by `tests/presolve_derived_bound_duals.rs`).
28//!
29//! **Bounds derived from multi-variable rows** (Phase 1 / FBBT, e.g.
30//! `x0 = 2·x1, x0 ≥ 4` ⇒ `x1 ≥ 2`, or `x0 = 0.5` then `x0 + x1 ≤ 2` ⇒
31//! `x1 ≤ 1.5`) are handled by a general postsolve recovery rather than
32//! per-derivation provenance: `finalize_solution` zeroes every multiplier
33//! on a *derived* side (presolve's bound strictly tighter than the
34//! declared one) that the original model does not have active at `x*`,
35//! and re-solves for the original problem's multipliers on the active
36//! rows and declared bounds the leftover stationarity residual reaches —
37//! a sign-constrained least squares, dense over that connected component,
38//! by a proximal-point iteration started from the reduced solve's own
39//! multipliers (`dual_recovery`). Where the original multipliers are
40//! unique this is exactly the `presolve=no` answer; where they are not it
41//! is a valid KKT multiplier set of the original problem. It runs only
42//! when a derived side carries a multiplier, and a component past the
43//! dense cap (600 multipliers / 5000 variables) or a solve that does not
44//! close stationarity leaves the reduced attribution in place with a
45//! warning. Inbound, `get_starting_point` moves a Phase-2-dropped row's
46//! supplied multiplier onto the reduced bounds of the variables it
47//! touches (`a_ij·λ_i`), so the reduced start is stationary where the
48//! supplied one was. A `.sol` round-trips through
49//! `warm_start_init_point=yes max_iter=0` under any warm-start push: the
50//! recovery above, the singleton hand-back, Phase 0's frames and
51//! `linear_eq_elim`'s sweep all re-derive multipliers from stationarity
52//! at the returned `x`, which the default push moved 1e-3 off its bounds,
53//! so they would be `O(push)` off the supplied duals. Instead each layer
54//! records the warm start it served and, when the solver hands back the
55//! served multipliers unchanged, reports the ones it was handed — its
56//! inbound map's exact inverse on that map's image (`warm_echo`). A cold
57//! solve records none, so its postsolve is the recovery alone.
58//! Primal point and objective are unaffected throughout.
59//! * **Phase 3** — structural LICQ check on the surviving equality
60//! rows. Verdict is published via [`PresolveTnlp::licq_verdict`].
61//! * **Phase 4** — bound-multiplier warm-start hints for variables
62//! whose bounds were tightened by Phase 1. Hints are emitted on
63//! `init_z` and exposed via [`PresolveTnlp::z_warm_starts`].
64//! * **Phase 5** — sensitivity-aware passthrough: projects
65//! user-supplied constraint metadata and scaling through the row
66//! reduction on the way in, and expands outer→inner on the way out
67//! in `finalize_metadata`.
68//! * **Phase 6** — linear-equality variable elimination
69//! (`presolve_linear_eq_reduction`, issue #487). The only phase that
70//! removes *columns*: singleton and two-variable linear equality rows
71//! determine variables, which are substituted away and recovered at
72//! `finalize_solution`. It lives in its own wrapper
73//! ([`linear_eq_elim::LinearEqElimTnlp`]) stacked **outside**
74//! [`PresolveTnlp`], so Phases 0–5 keep working in the variable space
75//! they were written against, and it is the outer layer that
76//! re-presents the reduced one. Off by default.
77
78#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
79
80use std::cell::RefCell;
81use std::rc::Rc;
82
83use pounce_common::exception::SolverException;
84use pounce_common::options_list::OptionsList;
85use pounce_common::reg_options::RegisteredOptions;
86use pounce_common::tolerance::is_negligible;
87use pounce_common::types::{Index, Number, lower_bound_present, upper_bound_present};
88use pounce_nlp::expression_provider::ExpressionProvider;
89use pounce_nlp::tnlp::{
90 BoundsInfo, IndexStyle, InfeasibilityProof, IpoptCq, IpoptData, IterStats, Linearity, MetaData,
91 NlpInfo, ScalingRequest, Solution, SparsityRequest, StartingPoint, TNLP,
92};
93
94pub mod auxiliary;
95pub mod block_solve;
96pub mod bound_tighten;
97pub mod btf;
98pub mod components;
99pub mod coupling;
100pub mod diagnostics;
101mod dual_recovery;
102pub mod dulmage_mendelsohn;
103pub mod fbbt;
104pub mod incidence;
105pub mod inequality_projection;
106pub mod licq;
107pub mod linear_eq_elim;
108pub mod linear_eq_plan;
109mod live_lift;
110pub mod matching;
111pub mod options;
112pub mod reduction_frame;
113pub mod redundant;
114pub mod trivial_elim;
115pub mod warm;
116mod warm_echo;
117
118pub use block_solve::{
119 BlockEquations, BlockSolveError, BlockSolveOptions, BlockSolveOutcome, BlockSolver,
120 DampedNewtonSolver,
121};
122pub use bound_tighten::{INF_BOUND, LinearRow, TightenReport, tighten_bounds};
123pub use btf::{BlockTriangularBlock, BlockTriangularForm};
124pub use components::{SquareComponent, SquareComponents};
125pub use coupling::{AuxiliaryCouplingClass, classify_block, objective_gradient_support};
126pub use diagnostics::{AuxiliaryPreprocessingDiagnostics, AuxiliaryRejectionReason};
127pub use dulmage_mendelsohn::{DMPart, DulmageMendelsohnPartition};
128pub use incidence::{EqualityIncidence, InequalityIncidence, ProbeView};
129pub use licq::{EqRow, LicqVerdict, licq_check};
130pub use linear_eq_elim::{FullSolution, LinearEqElimTnlp, recover_dropped_multipliers};
131pub use linear_eq_plan::{
132 ElimStep, EliminationPlan, LinearEqElimReport, PlanConfig, PlanInput, VarRecovery, build_plan,
133};
134pub use options::{AuxiliaryCouplingPolicy, LicqAction, PresolveOptions, register_options};
135pub use reduction_frame::{ReductionFrame, ReductionStack};
136pub use redundant::find_redundant_rows;
137pub use warm::{
138 PresolveFingerprint, PresolveMap, ProjectedWarm, WarmPoint, WarmProjectionReport, clamp_seed,
139 compute_fingerprint, project_warm_point, project_warm_point_full,
140};
141
142/// Errors that can arise while building a presolved TNLP.
143#[derive(Debug)]
144pub enum PresolveError {
145 OptionsError(SolverException),
146}
147
148impl std::fmt::Display for PresolveError {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 match self {
151 Self::OptionsError(e) => write!(f, "presolve options error: {e}"),
152 }
153 }
154}
155
156impl std::error::Error for PresolveError {}
157
158impl From<SolverException> for PresolveError {
159 fn from(e: SolverException) -> Self {
160 Self::OptionsError(e)
161 }
162}
163
164/// Stack Phase 6 on top of an already-built presolve wrapper, when the
165/// option asks for it.
166///
167/// Phase 6 goes *outside*, not inside: it is the one pass that changes the
168/// variable count, and Phases 0–5 are written against a fixed column
169/// space. Putting it on top also means it sees the row set Phase 2 already
170/// reduced, and its `finalize_solution` hands a full-length `x` back down
171/// before `PresolveTnlp` lifts the row duals — so each layer only has to
172/// undo its own reduction.
173fn stack_linear_eq_elim(
174 wrapped: Rc<RefCell<dyn TNLP>>,
175 opts: PresolveOptions,
176) -> Rc<RefCell<dyn TNLP>> {
177 if !opts.linear_eq_reduction {
178 return wrapped;
179 }
180 Rc::new(RefCell::new(LinearEqElimTnlp::new(wrapped, opts)))
181}
182
183/// Top-level entry: returns a TNLP wrapping `inner` with whatever
184/// presolve passes the option table has enabled. When the master
185/// switch is off, returns `inner` unchanged.
186pub fn wrap_with_presolve(
187 inner: Rc<RefCell<dyn TNLP>>,
188 opts: PresolveOptions,
189) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
190 if !opts.enabled || inner.borrow().is_presolve_wrapper() {
191 return Ok(inner);
192 }
193 let wrapped: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(PresolveTnlp::new(inner, opts)));
194 Ok(stack_linear_eq_elim(wrapped, opts))
195}
196
197/// Same as [`wrap_with_presolve`] but also installs an
198/// [`ExpressionProvider`] so passes like FBBT (issue #62) can see
199/// constraint expression trees. Callers who have the concrete inner
200/// TNLP type (`pounce-cli` with `NlTnlp`) should prefer this; the
201/// plain `wrap_with_presolve` leaves `presolve_fbbt` as a silent
202/// no-op.
203pub fn wrap_with_presolve_provider(
204 inner: Rc<RefCell<dyn TNLP>>,
205 expr_provider: Rc<RefCell<dyn ExpressionProvider>>,
206 opts: PresolveOptions,
207) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
208 if !opts.enabled || inner.borrow().is_presolve_wrapper() {
209 return Ok(inner);
210 }
211 let wrapped: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(
212 PresolveTnlp::with_expression_provider(inner, expr_provider, opts),
213 ));
214 Ok(stack_linear_eq_elim(wrapped, opts))
215}
216
217/// Convenience: read the `presolve_*` keys out of an `OptionsList`
218/// and call [`wrap_with_presolve`].
219pub fn wrap_from_options(
220 inner: Rc<RefCell<dyn TNLP>>,
221 options: &OptionsList,
222) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
223 let opts = PresolveOptions::from_options_list(options)?;
224 wrap_with_presolve(inner, opts)
225}
226
227/// Which accepting test the witness-refutation gate uses to decide that a
228/// sampled point satisfies a row.
229///
230/// The two forms exist because the question the witness answers is not always
231/// the same question. Both are *accepting* tests, so both must fail closed —
232/// when in doubt, accept the point, withdraw the verdict, keep the proof
233/// unclaimed.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235pub enum WitnessRule {
236 /// **Default, and the only rule for any path where the solve can run.**
237 ///
238 /// Accepts a row when the residual is negligible at the row's *live*
239 /// magnitude through the clamped form `tol * max(scale, 1)` — i.e. exactly
240 /// what the solver's own acceptance test would wave through. This is the
241 /// #380 rule: never certify what the solver itself would accept as
242 /// feasible, or the same model reports "proved infeasible" with presolve on
243 /// and `Solve_Succeeded` with it off.
244 #[default]
245 SolverAcceptance,
246 /// **Only for paths where the solve provably cannot run**, so no
247 /// `Solve_Succeeded` counterfactual exists to contradict (today: the
248 /// too-few-degrees-of-freedom gate, gh#391).
249 ///
250 /// Accepts a row when the residual is negligible relative to the row's
251 /// **declared** magnitude, `max(|g_l|, |g_u|)` over its *finite* bounds,
252 /// with no absolute clamp. Declared, not live: the live value moves with
253 /// the sampled point, while the bounds are the magnitude the modeller wrote
254 /// the row in — the same "declared, not live" pattern
255 /// `fbbt_infeasibility_survives_margin` uses for its per-row margin.
256 ///
257 /// Why the clamp has to go here: `tol * max(scale, 1)` reinstates an
258 /// absolute floor once the row's magnitude drops below 1, so multiplying
259 /// every row of an infeasible model by `1e-12` — which changes the feasible
260 /// set not at all — makes every point of the box "satisfy" every row and
261 /// withdraws a scale-free bound-propagation proof. That is the whole of
262 /// gh#391: `s*x == 0.2*s` with `s*x == 0.8*s` crosses by `0.6` at every `s`,
263 /// yet the verdict flipped at `s <= ~3e-8`.
264 ///
265 /// Why it is still safe: the clamp's job is to not demand more precision
266 /// than the solver promised, because a solver converges to *absolute*
267 /// residuals. On this path the solver never produces a point at all, so
268 /// there is no converged residual to be compatible with — the alternative
269 /// to the proof is a structural error, not a solution.
270 ///
271 /// The `b = 0` hazard is handled explicitly and fails closed: a homogeneous
272 /// row (`g_l = g_u = 0`, or a row with no finite bound at all) has no
273 /// declared magnitude, which would make the relative test unsatisfiable by
274 /// construction — `viol == scale` for any nonzero float noise — and a
275 /// genuinely feasible point would fail to refute. Such a row keeps
276 /// [`WitnessRule::SolverAcceptance`]'s clamped form, i.e. the absolute
277 /// floor.
278 DeclaredRowRelative,
279}
280
281/// Cached, reduced view of the problem after presolve passes have
282/// run. Exposed for inspection from integration tests.
283/// How far a model must be from satisfiable before an emptiness detection is
284/// promoted from a *detection* to a *proof*.
285///
286/// The rule: **never certify what the solver itself would accept as feasible.**
287/// POUNCE returns `Solve_Succeeded` for a violation up to its feasibility
288/// tolerance `tol`, so certifying anything smaller makes the same model report
289/// "proved infeasible" with presolve on and "solved" with it off — a
290/// contradiction no caller can reconcile.
291///
292/// Presolve's internal emptiness tests are far tighter than this: bound
293/// propagation uses a `1e-12` crossing margin and FBBT's tests are exact
294/// (`is_empty`, `new_lo > new_hi` — no margin at all). Those are right for
295/// deciding whether to keep propagating, and wrong for the word "proved".
296///
297/// Two models forced this. `x >= 0.1 + 0.2` with `x <= 0.3` is infeasible by
298/// `5.5e-17` — pure binary-float noise — yet was reported proved infeasible.
299/// And `x >= 1 + 1e-11` with `x <= 1` is solved to `Solve_Succeeded` on the
300/// default path while presolve called it proved infeasible.
301/// Try to *refute* a candidate infeasibility by finding a point that satisfies
302/// every constraint. Returns `true` when a witness is found — the verdict must
303/// then be withdrawn.
304///
305/// This is the check interval arithmetic cannot do for itself. FBBT and bound
306/// propagation reason about *over-approximated ranges*; at extreme coefficient
307/// scale those ranges lose the precision the conclusion rests on, and a
308/// feasible model can be reported empty. Evaluating the real constraints at a
309/// concrete point is exact by comparison — if the point satisfies them, the
310/// region demonstrably is not empty, whatever the intervals concluded.
311///
312/// Measured on a 400-instance feasible-by-construction sweep, Pyomo's
313/// `contrib.fbbt` — same tolerance idea, no refutation step — reports 57 false
314/// positives where POUNCE reports 3. This gate is where the difference comes
315/// from, and is the part worth keeping ahead of it.
316///
317/// Deliberately one-directional: it can only ever *withdraw* a verdict, never
318/// create one, so a failed evaluation or an unsampled witness costs nothing but
319/// a missed certification. It is also a single gate covering both certification
320/// paths — the previous two safeguards each landed on one path and not its
321/// twin, which is how a bound-propagation hole survived.
322#[allow(clippy::too_many_arguments)]
323fn witness_refutes_infeasibility(
324 inner: &Rc<RefCell<dyn TNLP>>,
325 n: usize,
326 m_in: usize,
327 x_l: &[Number],
328 x_u: &[Number],
329 g_l: &[Number],
330 g_u: &[Number],
331 tol: Number,
332 rule: WitnessRule,
333) -> bool {
334 if m_in == 0 || n == 0 {
335 return false;
336 }
337 let clamp = |v: Number, fallback: Number| -> Number {
338 if v.is_finite() && v.abs() < crate::bound_tighten::INF_BOUND {
339 v
340 } else {
341 fallback
342 }
343 };
344 let lo: Vec<Number> = (0..n).map(|j| clamp(x_l[j], -1.0)).collect();
345 let hi: Vec<Number> = (0..n).map(|j| clamp(x_u[j], 1.0)).collect();
346 let mid: Vec<Number> = (0..n).map(|j| 0.5 * (lo[j] + hi[j])).collect();
347
348 // The model's own starting point is the first candidate. It is a witness
349 // the modeller supplied, and at extreme coefficient scale it is not
350 // reproducible from the bounds: `0.5*(lo+hi)` and the modeller's
351 // `lo + 0.5*(hi-lo)` differ by an ulp, and against a 1e30 coefficient that
352 // ulp moves the row value by ~1e5 — enough to turn a satisfied row into a
353 // violated one. Sampling geometry alone therefore missed witnesses that
354 // `pounce check-x0` reports as exactly feasible.
355 let mut x0 = vec![0.0; n];
356 let mut z_l = vec![0.0; n];
357 let mut z_u = vec![0.0; n];
358 let mut lam = vec![0.0; m_in];
359 let have_x0 = inner.borrow_mut().get_starting_point(StartingPoint {
360 init_x: true,
361 x: &mut x0,
362 init_z: false,
363 z_l: &mut z_l,
364 z_u: &mut z_u,
365 init_lambda: false,
366 lambda: &mut lam,
367 });
368
369 let mut g = vec![0.0; m_in];
370 let mut candidates: Vec<&Vec<Number>> = Vec::with_capacity(4);
371 if have_x0 && x0.iter().all(|v| v.is_finite()) {
372 candidates.push(&x0);
373 }
374 candidates.extend([&mid, &lo, &hi]);
375 for x in candidates {
376 if !inner.borrow_mut().eval_g(x, true, &mut g) {
377 continue;
378 }
379 let feasible = (0..m_in).all(|i| {
380 let v = g[i];
381 if !v.is_finite() {
382 return false;
383 }
384 // An absent bound is stored as the sentinel (`±INF_BOUND`), not as
385 // an infinity, so every use of a bound has to ask whether it is
386 // real — and the question is **directional**. A lower bound is
387 // absent when it is at or below `-INF_BOUND`; an upper bound when
388 // it is at or above `+INF_BOUND`. This is the convention
389 // [`crate::bound_tighten`] already uses (`x_l[j] <= -INF_BOUND`,
390 // `x_u[j] >= INF_BOUND`).
391 //
392 // gh #396 is what the symmetric magnitude test (`|b| < INF_BOUND`)
393 // cost, and it went wrong in both directions at once on the same
394 // row. Property-test seed 223 carries
395 // `-1e30·x[0] <= -5.0000000000000007e20` over `x[0] ∈ [0, 1e-9]`,
396 // with a starting point landing *exactly* on that upper bound:
397 //
398 // * `g_l = -1e19` is the sentinel for an absent lower bound, but
399 // `viol` had no presence test at all, so it scored
400 // `g_l - v = 4.9e20` — a violation of a bound the row does not
401 // have, against a point whose true violation is zero.
402 // * `g_u = -5e20` is a genuine, finite upper bound, but
403 // `|−5e20| < 1e19` is false, so the magnitude test called it
404 // absent — discarding a real constraint.
405 //
406 // Either error alone sinks the witness. With no candidate able to
407 // pass, presolve certified a *feasible* model infeasible and
408 // reported `solve_result_num` 201, which asserts a proof.
409 let lo_present = |b: Number| b.is_finite() && b > -crate::bound_tighten::INF_BOUND;
410 let up_present = |b: Number| b.is_finite() && b < crate::bound_tighten::INF_BOUND;
411 // Only *real* bounds inform the declared magnitude. The sentinel
412 // would otherwise set the slack around 1e11 and make every row look
413 // satisfied — which withdrew even correct verdicts.
414 let declared = (if lo_present(g_l[i]) {
415 g_l[i].abs()
416 } else {
417 0.0
418 })
419 .max(if up_present(g_u[i]) {
420 g_u[i].abs()
421 } else {
422 0.0
423 });
424 let scale = v.abs().max(declared);
425 let lo_viol = if lo_present(g_l[i]) { g_l[i] - v } else { 0.0 };
426 let hi_viol = if up_present(g_u[i]) { v - g_u[i] } else { 0.0 };
427 let viol = lo_viol.max(hi_viol).max(0.0);
428 match rule {
429 // Accepting direction, so the shared clamped form: never
430 // stricter than the absolute `tol`.
431 WitnessRule::SolverAcceptance => is_negligible(viol, scale, tol),
432 // Pure relative against the *declared* magnitude — no clamp, so
433 // the test moves with the row under scaling. A row with no
434 // declared magnitude (homogeneous, or unbounded on both sides)
435 // has nothing to be relative to and keeps the absolute floor,
436 // which is the fail-closed direction here: it accepts the
437 // point, and accepting withdraws the verdict.
438 //
439 // The directional presence tests above make "no declared
440 // magnitude" mean what it says. Under the previous symmetric
441 // test a row could have a perfectly real bound of `-5e20` and
442 // still report `declared == 0`, dropping into the absolute
443 // floor by accident rather than by the reasoning above.
444 WitnessRule::DeclaredRowRelative if declared > 0.0 => {
445 viol.is_finite() && viol <= tol * declared
446 }
447 WitnessRule::DeclaredRowRelative => is_negligible(viol, scale, tol),
448 }
449 });
450 if feasible {
451 return true;
452 }
453 }
454 false
455}
456
457/// Whether the model's numbers are representable enough to base an
458/// infeasibility verdict on.
459///
460/// **Only the FBBT path consults this, deliberately.** Wiring the
461/// bound-propagation path to it was tried and measured: every genuinely
462/// infeasible model dropped from a certified verdict to the numerical one,
463/// because POUNCE stores an unbounded constraint bound as the finite sentinel
464/// `INF_BOUND` rather than `f64::INFINITY` — so `is_infinite()` is false, the
465/// `< INF_BOUND` test fails on equality, and any model with a one-sided
466/// constraint (nearly all of them) is rejected. Making the comparison inclusive
467/// admits the sentinel, but then it also admits `1e300`: under POUNCE's own
468/// convention `|v| >= INF_BOUND` *means* unbounded, so a bounds-magnitude test
469/// cannot separate "no bound" from "absurd finite value". A guard that cannot
470/// make that distinction should not be spread to a second path.
471///
472/// The overflow it does catch on the FBBT path comes from constraint
473/// *coefficients*, not bounds; a check on the Jacobian magnitudes would be the
474/// mechanism that generalises. See the commit history for the measured
475/// alternatives (1e150 -> 99/400, 1e20 -> 37/400, sentinel-inclusive -> 37/400,
476/// against a 3/400 baseline). Rejects NaN and finite magnitudes at or beyond the
477/// unbounded sentinel, where interval arithmetic overflows; genuine infinities
478/// stay admissible because they mean "no bound", not "a number".
479fn certificate_data_is_admissible(
480 x_l: &[Number],
481 x_u: &[Number],
482 g_l: &[Number],
483 g_u: &[Number],
484) -> bool {
485 let sane = |v: &Number| {
486 let v = *v;
487 !v.is_nan() && (v.is_infinite() || v.abs() < crate::bound_tighten::INF_BOUND)
488 };
489 x_l.iter().all(sane) && x_u.iter().all(sane) && g_l.iter().all(sane) && g_u.iter().all(sane)
490}
491
492fn certify_margin(tol: Number) -> Number {
493 // Guard against a degenerate or absurdly tight `tol`; below ~1e-12 the
494 // margin would drop into the float-noise band the whole check exists to
495 // exclude.
496 tol.max(1e-12)
497}
498
499/// Whether some variable's crossed bounds are crossed by more than the solver
500/// itself would accept as feasible — the condition for promoting a Phase-1
501/// detection to a certified proof.
502///
503/// The scale is the crossed pair's own magnitude, through `is_negligible`'s
504/// clamped accepting form — deliberately NOT the pure-relative proving form
505/// (`is_significant`). The #380 rule binds here: a crossing so small that the
506/// solver's acceptance test would wave a point through must never be certified,
507/// or the same model reports "proved infeasible" with presolve on and
508/// `Solve_Succeeded` with it off. The clamp hazard that rules the proving form
509/// out for *row residuals* does not arise for bound crossings: row scaling
510/// divides out of a propagated bound (`s*x >= 2*s` implies `x >= 2` at every
511/// `s`), so crossings do not shrink in the down-scaled direction. What the
512/// relative part fixes is the *up-scaled* model — a crossing of `6e-5` on
513/// bounds near `3e11` is float noise (relative `2e-16`), and the absolute
514/// margin used to certify it.
515fn crossing_is_certifiable(x_l: &[Number], x_u: &[Number], tol: Number) -> bool {
516 x_l.iter().zip(x_u).any(|(&l, &u)| {
517 // Both sides present before a crossing counts (gh #402) — otherwise an
518 // absent lower bound at the `-1e19` sentinel against a real upper bound
519 // of `-5e20` reads as a `5e20` crossing, far too large for
520 // `is_negligible` to dismiss, and certifies a feasible model infeasible.
521 lower_bound_present(l)
522 && upper_bound_present(u)
523 && l > u
524 && !is_negligible(l - u, l.abs().max(u.abs()), tol)
525 })
526}
527
528/// Re-run FBBT with every constraint's bounds widened by that row's own
529/// acceptance slack, `tol * max(|bound|, 1)` — the same clamped form
530/// `is_negligible` uses. Returns `true` only if the problem is *still*
531/// infeasible with that slack — i.e. the infeasibility is robust at the scale
532/// the row is written in, not a hair's-breadth modelling artifact.
533///
534/// Per-row rather than one absolute margin: an absolute `1e-8` widening is
535/// invisible on a row whose bounds sit near `5e13`, so an "infeasibility" of
536/// `3e3` there (eleven relative digits — a violation `pounce verify` accepts)
537/// would survive the probe and be certified, contradicting the verifier on the
538/// same model.
539///
540/// Sound in the conservative direction: a false `false` merely withholds the
541/// proof and leaves the previous behavior (let the IPM decide), while a false
542/// `true` would be a wrong "proved infeasible" — so the test is written to fail
543/// closed.
544/// The acceptance slack for one row: `tol * max(|bound|, 1)` over the row's
545/// *present* bounds.
546///
547/// Only a present bound carries magnitude information, and presence is
548/// **directional** (gh #402). The symmetric `|b| < INF_BOUND` test this used to
549/// run failed **open**: a row whose real bound is `-5e20` contributed `0.0`, so
550/// the margin collapsed to `tol * 1.0` on a row written at `5e20` scale — the
551/// infeasibility then survived an absurdly small widening and certified, which
552/// is precisely the direction `fbbt_infeasibility_survives_margin` exists to
553/// rule out. (The two `relaxed` maps in that function already used the
554/// directional form; its halves disagreed.)
555fn row_margin_for(g_l: Number, g_u: Number, tol: Number) -> Number {
556 let lo_mag = if lower_bound_present(g_l) {
557 g_l.abs()
558 } else {
559 0.0
560 };
561 let hi_mag = if upper_bound_present(g_u) {
562 g_u.abs()
563 } else {
564 0.0
565 };
566 tol * lo_mag.max(hi_mag).max(1.0)
567}
568
569#[allow(clippy::too_many_arguments)]
570fn fbbt_infeasibility_survives_margin(
571 provider: &dyn ExpressionProvider,
572 n: usize,
573 m_in: usize,
574 x_l: &[Number],
575 x_u: &[Number],
576 g_l: &[Number],
577 g_u: &[Number],
578 row_kept: &[bool],
579 cfg: &crate::fbbt::FbbtConfig,
580 tol: Number,
581) -> bool {
582 let row_margin = |i: usize| -> Number { row_margin_for(g_l[i], g_u[i], tol) };
583 let g_l_relaxed: Vec<Number> = g_l
584 .iter()
585 .enumerate()
586 .map(|(i, &v)| {
587 if v <= -crate::bound_tighten::INF_BOUND {
588 v
589 } else {
590 v - row_margin(i)
591 }
592 })
593 .collect();
594 let g_u_relaxed: Vec<Number> = g_u
595 .iter()
596 .enumerate()
597 .map(|(i, &v)| {
598 if v >= crate::bound_tighten::INF_BOUND {
599 v
600 } else {
601 v + row_margin(i)
602 }
603 })
604 .collect();
605 // Overflow guard. Interval arithmetic on absurd-but-finite magnitudes
606 // overflows to +/-inf, and `Interval::is_empty` treats a NaN endpoint as an
607 // empty range — so `1e300 * 1e300` reads as "this constraint cannot be
608 // satisfied" and would be certified as a *proof*. It is not: the model
609 // `x in [-1e300, 1e300], 1e300*x >= 1e300` is plainly feasible (`x = 1`),
610 // yet was reported proved infeasible while `presolve_fbbt=no` solved it.
611 // Widening the bounds cannot expose this — an overflow is unaffected by a
612 // 1e-9 relaxation — so the check has to be on the inputs.
613 //
614 // A genuine infinity is fine and common (`x >= 5` has `g_u = +inf`): those
615 // are the INF sentinel and mean "unbounded". What cannot be trusted is a
616 // *finite* magnitude at or beyond the sentinel, which is where the
617 // arithmetic stops being representable.
618 if !certificate_data_is_admissible(x_l, x_u, g_l, g_u) {
619 return false;
620 }
621 let mut probe_x_l = x_l.to_vec();
622 let mut probe_x_u = x_u.to_vec();
623 let report = crate::fbbt::run_fbbt(
624 provider,
625 n,
626 m_in,
627 &mut probe_x_l,
628 &mut probe_x_u,
629 &g_l_relaxed,
630 &g_u_relaxed,
631 Some(row_kept),
632 cfg,
633 );
634 report.infeasibility_witness.is_some()
635}
636
637pub struct CachedBounds {
638 pub x_l: Vec<Number>,
639 pub x_u: Vec<Number>,
640 /// Reduced (post-row-drop) constraint lower bounds.
641 pub g_l: Vec<Number>,
642 /// Reduced constraint upper bounds.
643 pub g_u: Vec<Number>,
644}
645
646/// TNLP wrapper that re-presents the inner problem after presolve.
647pub struct PresolveTnlp {
648 inner: Rc<RefCell<dyn TNLP>>,
649 /// Optional structural-expression handle on the inner TNLP for
650 /// passes (FBBT, issue #62) that need DAG-level access. Callers
651 /// who know the concrete inner type (e.g. `pounce-cli` with
652 /// `NlTnlp`) install this via
653 /// [`Self::with_expression_provider`]. Callers without (e.g.
654 /// callback-based bridges) leave it `None` and the expression-
655 /// hungry passes silently become no-ops.
656 expr_provider: Option<Rc<RefCell<dyn ExpressionProvider>>>,
657 opts: PresolveOptions,
658
659 /// Project served primal seeds into the presolved box. Enabled only by
660 /// `TnlpPresolveSession`. Direct wrapper users retain non-projecting
661 /// behavior.
662 project_seed: bool,
663
664 /// Which accepting test the final witness-refutation gate uses. Left at
665 /// [`WitnessRule::SolverAcceptance`] for every wrapper that is actually
666 /// solved through; raised to [`WitnessRule::DeclaredRowRelative`] only by
667 /// callers that have already established the solve cannot run (gh#391).
668 /// Deliberately *not* a `PresolveOptions` field: it is a property of the
669 /// call site, not a user-tunable knob, and no user should be able to switch
670 /// a live solve onto the stricter rule.
671 witness_rule: WitnessRule,
672
673 /// `None` until init has run; afterwards `Some(state)`.
674 state: Option<PresolveState>,
675
676 /// Full-space `(x, lambda)` forwarded to the inner TNLP at the last
677 /// `finalize_solution` — i.e. after row-drop multiplier recovery, in
678 /// the original `.nl` row order (length `info_inner.m`). `None` until
679 /// a solve finalizes. The CLI prefers this over its reduced-space
680 /// `on_converged` / counting capture so the `.sol` / JSON dual block
681 /// regains the original constraint count: the kept-row-space lambda
682 /// the solver produces otherwise mis-aligns against the `.nl`'s `m`.
683 finalized_full_solution: Option<(Vec<Number>, Vec<Number>)>,
684
685 /// `(variable, upper)` for every bound multiplier the last
686 /// `finalize_solution` handed back to the dropped singleton row that
687 /// implied the bound (M24). A caller whose bound-multiplier capture sits
688 /// outside this wrapper zeroes these entries so the `.sol` agrees with
689 /// the `lambda` in [`Self::finalized_full_solution`].
690 bound_multipliers_handed_to_rows: Vec<(usize, bool)>,
691
692 /// `(variable, z_l, z_u)` for every variable whose bound multipliers the
693 /// last `finalize_solution` changed from what the solver handed it — the
694 /// singleton hand-back above, Phase 0's fixed-variable zeroing, and the
695 /// derived-bound recovery (`dual_recovery`). A caller that captured the
696 /// solver's own `z` outside this wrapper overwrites these entries.
697 bound_multiplier_overrides: Vec<(usize, Number, Number)>,
698
699 /// `true` when the last `finalize_solution` changed any multiplier
700 /// beyond lifting it to the original row space (see
701 /// [`Self::postsolve_rewrote_duals`]).
702 duals_rewritten: bool,
703
704 /// What the derived-bound recovery did at the last `finalize_solution`.
705 derived_bound_recovery: Option<dual_recovery::Outcome>,
706 /// The inner evaluations the iteration made, for the live-iterate lift.
707 live_record: live_lift::EvalRecord,
708
709 /// The duals the last `get_starting_point` was handed and served, when
710 /// it served any: `finalize_solution` returns the handed ones when the
711 /// solver gives the served ones back unchanged (`warm_echo`).
712 served_warm_start: Option<warm_echo::ServedWarmStart>,
713
714 /// What the most recent starting-point calls actually changed while
715 /// projecting the inner seed into this wrapper's row space and box.
716 /// Reset by session callers before each solve.
717 starting_point_projection_report: WarmProjectionReport,
718}
719
720struct PresolveState {
721 info_inner: NlpInfo,
722 info_outer: NlpInfo,
723 bounds: CachedBounds,
724
725 /// Maps outer (reduced) row index → inner row index. Length
726 /// equals `info_outer.m`.
727 rows_kept: Vec<usize>,
728
729 /// For each outer nnz, the position in the inner nnz array.
730 jac_kept_idx: Vec<usize>,
731 /// Cached outer (reduced + renumbered) Jacobian sparsity, served
732 /// on `eval_jac_g(Structure)`.
733 jac_irow_outer: Vec<Index>,
734 jac_jcol_outer: Vec<Index>,
735
736 /// Phase 1 report.
737 tighten_report: TightenReport,
738 /// A proof that the feasible region is empty, when presolve derived one on
739 /// an *un-clamped* box. `None` unless one of the four certifiable sites in
740 /// `ensure_init` fired — in particular an infeasibility detected while a
741 /// Phase-0 auxiliary elimination is in force is deliberately NOT recorded
742 /// here, because a dropped row can fabricate one (the #53 / F6 hazard).
743 /// Such a detection is re-checked on the rolled-back box first, and only
744 /// the surviving result is certified.
745 certified_infeasible: Option<InfeasibilityProof>,
746 /// FBBT report (`None` when `presolve_fbbt` was off or the inner
747 /// TNLP did not expose an `ExpressionProvider`).
748 fbbt_report: Option<crate::fbbt::FbbtReport>,
749 /// Number of rows dropped by Phase 2.
750 n_dropped_rows: Index,
751 /// Phase 3 verdict (`None` if the LICQ check was disabled).
752 licq_verdict: Option<LicqVerdict>,
753 /// Phase 4: warm-start values for `z_l` per variable. Entry is
754 /// 0.0 where presolve did not tighten the lower bound, else
755 /// `bound_mult_init_val`. Same length as `bounds.x_l`.
756 z_l_warm: Vec<Number>,
757 /// Phase 4: warm-start values for `z_u` per variable.
758 z_u_warm: Vec<Number>,
759
760 /// Scratch buffers reused across eval_* calls.
761 scratch_g: Vec<Number>,
762 scratch_jac: Vec<Number>,
763 scratch_lambda: Vec<Number>,
764
765 /// Phase 0 (issue #53) diagnostics. Always present; defaulted to
766 /// zeros when the master switch is off.
767 aux_diagnostics: AuxiliaryPreprocessingDiagnostics,
768 /// Phase 0 postsolve stack. Empty until PR 7 wires real frames.
769 #[allow(dead_code)]
770 reduction_stack: ReductionStack,
771 /// Bounds Phase 1 created from a singleton linear row that Phase 2 then
772 /// dropped (M24): the multiplier the solver reports on such a bound
773 /// belongs to the row. See [`SingletonBound`].
774 singleton_bounds: Vec<SingletonBound>,
775 /// The model's declared box and row bounds, before any presolve pass:
776 /// postsolve judges a reported multiplier against these (M24, derived
777 /// bounds — see `dual_recovery`).
778 decl_x_l: Vec<Number>,
779 decl_x_u: Vec<Number>,
780 decl_g_l: Vec<Number>,
781 decl_g_u: Vec<Number>,
782 /// Linear rows Phase 2 dropped as redundant that are not handled as a
783 /// [`SingletonBound`], with their coefficients: on a warm start their
784 /// supplied multiplier's contribution moves onto the reduced bounds of
785 /// the variables they touch (the inverse of the `dual_recovery`
786 /// postsolve).
787 phase2_dropped_rows: Vec<(usize, Vec<(usize, Number)>)>,
788}
789
790/// A variable bound that exists only because a singleton linear row
791/// `lo ≤ a·x_j ≤ hi` implied it, and that row was dropped as redundant
792/// (issue M24).
793///
794/// The reduced problem has the bound and not the row, so the solver
795/// reports the dual on `z_l[j]` / `z_u[j]`, against a bound the model
796/// never declared. Postsolve hands it back to the row: the bound
797/// contributes `z_u − z_l` to stationarity at `j` and the row contributes
798/// `a·λ` (Ipopt's convention, `∇f + Jᵀλ − z_l + z_u = 0`), so
799/// `λ = (z_u − z_l)/a` with that side's `z` zeroed closes stationarity
800/// exactly as before. Recorded only where the implied bound is strictly
801/// tighter than the declared one; on a tie the multiplier stays on the
802/// declared bound (a valid split of a degenerate pair, the same posture
803/// `linear_eq_elim` takes for transferred bounds).
804#[derive(Debug, Clone, Copy, PartialEq)]
805struct SingletonBound {
806 /// Inner row index.
807 row: usize,
808 /// Variable index.
809 var: usize,
810 /// The row's coefficient on `var`; non-zero.
811 coeff: Number,
812 /// `true` for the variable's upper bound, `false` for its lower.
813 upper: bool,
814}
815
816/// Find the bounds Phase 1 took from singleton rows that Phase 2 dropped.
817///
818/// `kept_before` is the row mask Phase 2 started from (so rows Phase 0
819/// consumed, whose multipliers its frames recover, are excluded) and
820/// `kept_after` the mask it produced. `x_l` / `x_u` are the presolved box
821/// and `decl_l` / `decl_u` the declared one.
822fn find_singleton_bounds(
823 linear_row_map: &[Option<LinearRow>],
824 kept_before: &[bool],
825 kept_after: &[bool],
826 x_l: &[Number],
827 x_u: &[Number],
828 decl_l: &[Number],
829 decl_u: &[Number],
830) -> Vec<SingletonBound> {
831 let same = |a: Number, b: Number| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
832 let mut out = Vec::new();
833 // A side already claimed by an earlier row stays with that row.
834 let mut claimed: std::collections::BTreeSet<(usize, bool)> = Default::default();
835 for (row, lr) in linear_row_map.iter().enumerate() {
836 let Some(lr) = lr else { continue };
837 if !kept_before.get(row).copied().unwrap_or(false)
838 || kept_after.get(row).copied().unwrap_or(true)
839 {
840 continue;
841 }
842 let mut nz = lr.entries.iter().filter(|(_, a)| *a != 0.0);
843 let (Some(&(j, a)), None) = (nz.next(), nz.next()) else {
844 continue;
845 };
846 let j = j as usize;
847 if j >= x_l.len() || !a.is_finite() {
848 continue;
849 }
850 // The variable bounds `lo ≤ a·x ≤ hi` implies, by side.
851 let (imp_l, imp_u) = if a > 0.0 {
852 (
853 lower_bound_present(lr.lo).then(|| lr.lo / a),
854 upper_bound_present(lr.hi).then(|| lr.hi / a),
855 )
856 } else {
857 (
858 upper_bound_present(lr.hi).then(|| lr.hi / a),
859 lower_bound_present(lr.lo).then(|| lr.lo / a),
860 )
861 };
862 if let Some(b) = imp_l
863 && lower_bound_present(x_l[j])
864 && same(x_l[j], b)
865 && (!lower_bound_present(decl_l[j]) || x_l[j] > decl_l[j])
866 && claimed.insert((j, false))
867 {
868 out.push(SingletonBound {
869 row,
870 var: j,
871 coeff: a,
872 upper: false,
873 });
874 }
875 if let Some(b) = imp_u
876 && upper_bound_present(x_u[j])
877 && same(x_u[j], b)
878 && (!upper_bound_present(decl_u[j]) || x_u[j] < decl_u[j])
879 && claimed.insert((j, true))
880 {
881 out.push(SingletonBound {
882 row,
883 var: j,
884 coeff: a,
885 upper: true,
886 });
887 }
888 }
889 out
890}
891
892impl PresolveTnlp {
893 /// Build a presolve wrapper directly. Prefer
894 /// [`wrap_with_presolve`] in production code; this constructor
895 /// is exposed so integration tests can keep a typed handle for
896 /// accessors like [`Self::licq_verdict`].
897 pub fn new(inner: Rc<RefCell<dyn TNLP>>, opts: PresolveOptions) -> Self {
898 Self {
899 inner,
900 expr_provider: None,
901 opts,
902 project_seed: false,
903 witness_rule: WitnessRule::default(),
904 state: None,
905 finalized_full_solution: None,
906 bound_multipliers_handed_to_rows: Vec::new(),
907 bound_multiplier_overrides: Vec::new(),
908 duals_rewritten: false,
909 derived_bound_recovery: None,
910 served_warm_start: None,
911 live_record: live_lift::EvalRecord::default(),
912 starting_point_projection_report: WarmProjectionReport::default(),
913 }
914 }
915
916 /// Switch the final witness-refutation gate to
917 /// [`WitnessRule::DeclaredRowRelative`].
918 ///
919 /// **Only legitimate when the caller has already established that the solve
920 /// cannot run**, so the "never certify what the solver would accept"
921 /// counterfactual has nothing to compare against. Today that is the
922 /// too-few-degrees-of-freedom gate alone (gh#391). Calling this on a
923 /// wrapper that will be solved through reintroduces the #380 defect: a
924 /// small-magnitude model would report "proved infeasible" with presolve on
925 /// and `Solve_Succeeded` with it off.
926 pub fn probing_without_a_solve(mut self) -> Self {
927 self.witness_rule = WitnessRule::DeclaredRowRelative;
928 self
929 }
930
931 /// Enable projection of served warm-start primals into the presolved box.
932 pub fn set_project_seed(&mut self, project_seed: bool) {
933 self.project_seed = project_seed;
934 }
935
936 /// Build a presolve wrapper with an `ExpressionProvider` handle on
937 /// the same inner TNLP. The two handles should reference the
938 /// *same* object (typical pattern: clone an `Rc<RefCell<NlTnlp>>`
939 /// twice, once as `dyn TNLP` and once as `dyn ExpressionProvider`).
940 /// Required for `presolve_fbbt=yes` to fire — without a provider,
941 /// FBBT silently becomes a no-op.
942 pub fn with_expression_provider(
943 inner: Rc<RefCell<dyn TNLP>>,
944 expr_provider: Rc<RefCell<dyn ExpressionProvider>>,
945 opts: PresolveOptions,
946 ) -> Self {
947 Self {
948 inner,
949 expr_provider: Some(expr_provider),
950 opts,
951 project_seed: false,
952 witness_rule: WitnessRule::default(),
953 state: None,
954 finalized_full_solution: None,
955 bound_multipliers_handed_to_rows: Vec::new(),
956 bound_multiplier_overrides: Vec::new(),
957 duals_rewritten: false,
958 derived_bound_recovery: None,
959 served_warm_start: None,
960 live_record: live_lift::EvalRecord::default(),
961 starting_point_projection_report: WarmProjectionReport::default(),
962 }
963 }
964
965 /// FBBT report (`None` until init runs, or when FBBT was disabled
966 /// or the inner TNLP did not expose an `ExpressionProvider`).
967 pub fn fbbt_report(&self) -> Option<crate::fbbt::FbbtReport> {
968 self.state.as_ref().and_then(|s| s.fbbt_report.clone())
969 }
970
971 /// Phase 1 report (zeroed until init has run).
972 pub fn tighten_report(&self) -> TightenReport {
973 self.state
974 .as_ref()
975 .map(|s| s.tighten_report.clone())
976 .unwrap_or_default()
977 }
978
979 /// A proof that the feasible region is empty, if presolve derived one on
980 /// an un-clamped box. `None` means "not proved", not "feasible".
981 ///
982 /// Distinct from `tighten_report().infeasible` /
983 /// `fbbt_report().infeasibility_witness`, which are raw detections: those
984 /// also fire for a contradiction manufactured by a Phase-0 auxiliary
985 /// elimination, which is why they were never safe to act on. This returns
986 /// only detections that survive on the original box.
987 pub fn certified_infeasible(&self) -> Option<InfeasibilityProof> {
988 self.state.as_ref().and_then(|s| s.certified_infeasible)
989 }
990
991 /// Number of constraint rows dropped by Phase 2 (0 if presolve
992 /// has not yet run or no rows are redundant).
993 pub fn n_dropped_rows(&self) -> Index {
994 self.state.as_ref().map(|s| s.n_dropped_rows).unwrap_or(0)
995 }
996
997 /// The full-space `(x, lambda)` captured at the last
998 /// `finalize_solution`, lifted back to the original `.nl` row space
999 /// (length `info_inner.m`) with dropped-row multipliers recovered.
1000 /// `None` until a solve finalizes. The CLI consumes this so its
1001 /// `.sol` / JSON dual block carries the original constraint count
1002 /// rather than the reduced kept-row count. See the field docs.
1003 pub fn finalized_full_solution(&self) -> Option<(Vec<Number>, Vec<Number>)> {
1004 self.finalized_full_solution.clone()
1005 }
1006
1007 /// The bound multipliers the last `finalize_solution` moved onto a
1008 /// dropped singleton row's `lambda`, as `(variable, upper)` (M24). The
1009 /// inner TNLP already received them zeroed; this is for a caller that
1010 /// captured the solver's own `z` outside the wrapper.
1011 pub fn bound_multipliers_handed_to_rows(&self) -> &[(usize, bool)] {
1012 &self.bound_multipliers_handed_to_rows
1013 }
1014
1015 /// `(variable, z_l, z_u)` for every variable whose bound multipliers the
1016 /// last `finalize_solution` rewrote before forwarding them (M24: the
1017 /// singleton hand-back, Phase 0's fixed-variable zeroing, and the
1018 /// derived-bound recovery). The values are what the inner TNLP
1019 /// received, in its frame (Ipopt's internal convention, both `≥ 0`); a
1020 /// caller whose bound-multiplier capture sits outside this wrapper
1021 /// writes them over its own.
1022 pub fn bound_multiplier_overrides(&self) -> &[(usize, Number, Number)] {
1023 &self.bound_multiplier_overrides
1024 }
1025
1026 /// `true` when the last `finalize_solution` rewrote any multiplier
1027 /// beyond lifting it into the original row space — so a caller that
1028 /// holds its own reduced-space capture must take
1029 /// [`Self::finalized_full_solution`]'s `lambda` even when no row was
1030 /// dropped.
1031 pub fn postsolve_rewrote_duals(&self) -> bool {
1032 self.duals_rewritten
1033 }
1034
1035 /// How many multipliers the last `finalize_solution` found on derived
1036 /// bounds the original model does not have active, and whether it
1037 /// recovered the original problem's multipliers in their place
1038 /// (`Some((n_spurious, recovered))`; `None` when there were none). See
1039 /// the module docs, "Dual attribution".
1040 pub fn derived_bound_dual_recovery(&self) -> Option<(usize, bool)> {
1041 self.derived_bound_recovery
1042 .as_ref()
1043 .filter(|o| o.n_spurious > 0 || o.rolled_back)
1044 .map(|o| (o.n_spurious, !o.rolled_back))
1045 }
1046
1047 /// Which sides of each variable's box presolve derived: its bound there
1048 /// is strictly tighter than the declared one, or the declared one is
1049 /// absent. `None` before init.
1050 fn derived_sides(&self) -> Option<(Vec<bool>, Vec<bool>)> {
1051 let s = self.state.as_ref()?;
1052 let tighter = |p: Number, d: Number, lower: bool| -> bool {
1053 let present = if lower {
1054 lower_bound_present(p)
1055 } else {
1056 upper_bound_present(p)
1057 };
1058 if !present {
1059 return false;
1060 }
1061 let declared = if lower {
1062 lower_bound_present(d)
1063 } else {
1064 upper_bound_present(d)
1065 };
1066 if !declared {
1067 return true;
1068 }
1069 let gap = if lower { p - d } else { d - p };
1070 gap > 1e-9 * d.abs().max(1.0)
1071 };
1072 let n = s.bounds.x_l.len();
1073 let dl = (0..n)
1074 .map(|j| tighter(s.bounds.x_l[j], s.decl_x_l[j], true))
1075 .collect();
1076 let du = (0..n)
1077 .map(|j| tighter(s.bounds.x_u[j], s.decl_x_u[j], false))
1078 .collect();
1079 Some((dl, du))
1080 }
1081
1082 /// Evaluate the original problem's `∇f` and Jacobian triplets
1083 /// (0-based) at `x`.
1084 fn original_derivatives(
1085 &mut self,
1086 x: &[Number],
1087 ) -> Option<(Vec<Number>, Vec<(usize, usize, Number)>)> {
1088 let (n, nnz, one_based) = {
1089 let s = self.state.as_ref()?;
1090 (
1091 s.info_inner.n as usize,
1092 s.info_inner.nnz_jac_g as usize,
1093 matches!(s.info_inner.index_style, IndexStyle::Fortran),
1094 )
1095 };
1096 let mut grad_f = vec![0.0; n];
1097 if !self.inner.borrow_mut().eval_grad_f(x, true, &mut grad_f) {
1098 return None;
1099 }
1100 let mut irow = vec![0 as Index; nnz];
1101 let mut jcol = vec![0 as Index; nnz];
1102 let mut vals = vec![0.0; nnz];
1103 if nnz > 0 {
1104 let mut inner = self.inner.borrow_mut();
1105 if !inner.eval_jac_g(
1106 None,
1107 false,
1108 SparsityRequest::Structure {
1109 irow: &mut irow,
1110 jcol: &mut jcol,
1111 },
1112 ) || !inner.eval_jac_g(
1113 Some(x),
1114 false,
1115 SparsityRequest::Values { values: &mut vals },
1116 ) {
1117 return None;
1118 }
1119 }
1120 let off: Index = if one_based { 1 } else { 0 };
1121 let jac = (0..nnz)
1122 .filter_map(|k| {
1123 let i = irow[k] - off;
1124 let j = jcol[k] - off;
1125 (i >= 0 && j >= 0).then_some((i as usize, j as usize, vals[k]))
1126 })
1127 .collect();
1128 Some((grad_f, jac))
1129 }
1130
1131 /// The postsolve half of the derived-bound rule (M24; see
1132 /// `dual_recovery`). Evaluates the original problem only when some
1133 /// derived side carries a multiplier, so a solve that never leans on a
1134 /// derived bound is untouched.
1135 fn recover_derived_bound_duals(
1136 &mut self,
1137 x: &[Number],
1138 g: &[Number],
1139 lambda: &mut [Number],
1140 z_l: &mut [Number],
1141 z_u: &mut [Number],
1142 ) -> Option<dual_recovery::Outcome> {
1143 let (derived_l, derived_u) = self.derived_sides()?;
1144 let n = x.len();
1145 if z_l.len() != n || z_u.len() != n || derived_l.len() != n {
1146 return None;
1147 }
1148 let touched = (0..n).any(|j| {
1149 (derived_l[j] && z_l[j].is_finite() && z_l[j] != 0.0)
1150 || (derived_u[j] && z_u[j].is_finite() && z_u[j] != 0.0)
1151 });
1152 if !touched {
1153 return None;
1154 }
1155 let (grad_f, jac) = self.original_derivatives(x)?;
1156 let s = self.state.as_ref()?;
1157 let view = dual_recovery::KktView {
1158 x,
1159 g,
1160 grad_f: &grad_f,
1161 jac: &jac,
1162 x_l: &s.decl_x_l,
1163 x_u: &s.decl_x_u,
1164 g_l: &s.decl_g_l,
1165 g_u: &s.decl_g_u,
1166 derived_l: &derived_l,
1167 derived_u: &derived_u,
1168 };
1169 let out = dual_recovery::reattribute(&view, lambda, z_l, z_u);
1170 if out.rolled_back {
1171 tracing::warn!(
1172 target: "pounce_presolve",
1173 "presolve postsolve: multipliers on derived bounds could not be \
1174 re-attributed to the original problem (component past the dense cap \
1175 or residual not reduced); the reported duals keep the reduced \
1176 problem's attribution"
1177 );
1178 } else if out.n_spurious > 0 {
1179 tracing::debug!(
1180 target: "pounce_presolve",
1181 n_spurious = out.n_spurious,
1182 n_unknowns = out.n_unknowns,
1183 residual_before = out.residual_before,
1184 residual_after = out.residual_after,
1185 "presolve postsolve: re-attributed derived-bound multipliers"
1186 );
1187 }
1188 Some(out)
1189 }
1190
1191 /// The original problem's duals when the solver handed back the warm
1192 /// start this wrapper served without moving its duals: the ones the
1193 /// caller supplied, `(lambda, z_l, z_u)` (see `warm_echo`). Every
1194 /// re-derivation postsolve would otherwise do reads `∇f` at the returned
1195 /// `x`, which the interior-point start pushed off the bounds the supplied
1196 /// duals belong to. An entry the solver had to fill (a bound multiplier
1197 /// supplied as 0 or not at all) is reported as filled when the model
1198 /// declares that bound — what `presolve=no` reports — and as 0 on a
1199 /// bound only presolve has. `None` on any other return.
1200 fn echoed_warm_start(
1201 &self,
1202 sol: &Solution<'_>,
1203 ) -> Option<(Vec<Number>, Vec<Number>, Vec<Number>)> {
1204 let w = self.served_warm_start.as_ref()?;
1205 if !w.echoed_by(sol.z_l, sol.z_u, sol.lambda) {
1206 return None;
1207 }
1208 let s = self.state.as_ref()?;
1209 let n = w.z_l_in.len();
1210 if w.z_u_in.len() != n || s.decl_x_l.len() != n || s.bounds.x_l.len() != n {
1211 return None;
1212 }
1213 let side = |z_in: &[Number], z_out: &[Number], got: &[Number], lower: bool| {
1214 (0..n)
1215 .map(|j| {
1216 let mut z = warm_echo::as_reported_z(z_in[j]);
1217 let declared = if lower {
1218 lower_bound_present(s.decl_x_l[j])
1219 } else {
1220 upper_bound_present(s.decl_x_u[j])
1221 };
1222 // A variable presolve fixed is not one the solver saw:
1223 // whatever it reports there is not a filled seed.
1224 let pinned =
1225 s.bounds.x_l[j] == s.bounds.x_u[j] && s.decl_x_l[j] != s.decl_x_u[j];
1226 if !warm_echo::is_seed(z_out[j]) && declared && !pinned && got[j].is_finite() {
1227 z += got[j];
1228 }
1229 z
1230 })
1231 .collect::<Vec<_>>()
1232 };
1233 Some((
1234 w.lambda_in
1235 .iter()
1236 .map(|&l| warm_echo::as_reported_lambda(l))
1237 .collect(),
1238 side(&w.z_l_in, &w.z_l_out, sol.z_l, true),
1239 side(&w.z_u_in, &w.z_u_out, sol.z_u, false),
1240 ))
1241 }
1242
1243 /// Record what `finalize_solution` is about to hand the inner TNLP
1244 /// (overrides against the solver's `z`, whether any dual was rewritten,
1245 /// the full-space `(x, λ)`), then hand it down.
1246 #[allow(clippy::too_many_arguments)]
1247 fn forward_finalized(
1248 &mut self,
1249 sol: Solution<'_>,
1250 g_full: &[Number],
1251 lambda_full: &[Number],
1252 lambda_lifted: &[Number],
1253 z_l_full: &[Number],
1254 z_u_full: &[Number],
1255 ip_data: &IpoptData,
1256 ip_cq: &IpoptCq,
1257 ) {
1258 self.bound_multiplier_overrides = (0..z_l_full.len().min(sol.z_l.len()))
1259 .filter(|&j| {
1260 z_l_full[j].to_bits() != sol.z_l[j].to_bits()
1261 || z_u_full.get(j).map(|z| z.to_bits()) != sol.z_u.get(j).map(|z| z.to_bits())
1262 })
1263 .map(|j| (j, z_l_full[j], z_u_full.get(j).copied().unwrap_or(0.0)))
1264 .collect();
1265 self.duals_rewritten = !self.bound_multiplier_overrides.is_empty()
1266 || lambda_full
1267 .iter()
1268 .zip(lambda_lifted)
1269 .any(|(a, b)| a.to_bits() != b.to_bits());
1270 // Stash the full-space (x, lambda) the inner TNLP is about to
1271 // receive so the CLI can prefer it over its reduced-space
1272 // capture when sizing the `.sol` / JSON dual block.
1273 self.finalized_full_solution = Some((sol.x.to_vec(), lambda_full.to_vec()));
1274 self.inner.borrow_mut().finalize_solution(
1275 Solution {
1276 status: sol.status,
1277 x: sol.x,
1278 z_l: z_l_full,
1279 z_u: z_u_full,
1280 g: g_full,
1281 lambda: lambda_full,
1282 obj_value: sol.obj_value,
1283 },
1284 ip_data,
1285 ip_cq,
1286 );
1287 }
1288
1289 /// Clear the record of transformations applied by
1290 /// [`TNLP::get_starting_point`]. A persistent caller resets this before
1291 /// each solve, then reads [`Self::starting_point_projection_report`].
1292 pub fn reset_starting_point_projection_report(&mut self) {
1293 self.starting_point_projection_report = WarmProjectionReport::default();
1294 }
1295
1296 /// Projection effects observed while this wrapper actually served the
1297 /// current solve's starting point.
1298 pub fn starting_point_projection_report(&self) -> WarmProjectionReport {
1299 self.starting_point_projection_report
1300 }
1301
1302 /// Cached reduced bounds, if presolve has run.
1303 pub fn cached_bounds(&self) -> Option<&CachedBounds> {
1304 self.state.as_ref().map(|s| &s.bounds)
1305 }
1306
1307 /// Phase 3 verdict — `Some` only if the LICQ check was enabled
1308 /// and presolve has run.
1309 pub fn licq_verdict(&self) -> Option<&LicqVerdict> {
1310 self.state.as_ref().and_then(|s| s.licq_verdict.as_ref())
1311 }
1312
1313 /// Phase 4 warm-start hints `(z_l, z_u)`. Each entry is 0.0 if
1314 /// no hint is set for that variable, else the configured
1315 /// `bound_mult_init_val`. `None` until init has run.
1316 pub fn z_warm_starts(&self) -> Option<(&[Number], &[Number])> {
1317 self.state
1318 .as_ref()
1319 .map(|s| (&s.z_l_warm[..], &s.z_u_warm[..]))
1320 }
1321
1322 /// Phase 0 (issue #53) summary. Returns a zero-valued struct
1323 /// until init has run; afterwards, populated by
1324 /// [`auxiliary::run_auxiliary_phase0`]. PR 1 always returns
1325 /// zeros even with the master switch on.
1326 pub fn auxiliary_diagnostics(&self) -> AuxiliaryPreprocessingDiagnostics {
1327 self.state
1328 .as_ref()
1329 .map(|s| s.aux_diagnostics.clone())
1330 .unwrap_or_default()
1331 }
1332
1333 /// Snapshot the transformation for warm-start mapping. Runs init first.
1334 /// `None` when init fails. See [`warm`] for the contract.
1335 pub fn transformation(&mut self) -> Option<PresolveMap> {
1336 let s = self.ensure_init()?;
1337 let mut fixed: std::collections::BTreeMap<usize, Number> =
1338 std::collections::BTreeMap::new();
1339 for frame in s.reduction_stack.iter_bottom_up() {
1340 for (k, &i) in frame.fixed_vars.iter().enumerate() {
1341 if let Some(&v) = frame.fixed_values.get(k) {
1342 fixed.insert(i, v);
1343 }
1344 }
1345 }
1346 let (fixed_vars, fixed_values): (Vec<usize>, Vec<Number>) = fixed.into_iter().unzip();
1347 Some(PresolveMap {
1348 n_inner: s.info_inner.n.max(0) as usize,
1349 m_inner: s.info_inner.m.max(0) as usize,
1350 m_outer: s.info_outer.m.max(0) as usize,
1351 rows_kept: s.rows_kept.clone(),
1352 x_l: s.bounds.x_l.clone(),
1353 x_u: s.bounds.x_u.clone(),
1354 fixed_vars,
1355 fixed_values,
1356 })
1357 }
1358
1359 /// Fingerprint what this transformation was computed from. A session
1360 /// rebuilds the wrapper when this moves. `None` means "always rebuild".
1361 pub fn fingerprint(&mut self) -> Option<PresolveFingerprint> {
1362 crate::warm::compute_fingerprint(&self.inner, &self.opts)
1363 }
1364
1365 /// Drop the cached transformation, the next query recomputes it. Needed
1366 /// after fingerprint-invisible changes (FBBT tapes).
1367 pub fn invalidate(&mut self) {
1368 self.state = None;
1369 self.served_warm_start = None;
1370 self.reset_starting_point_projection_report();
1371 }
1372
1373 /// Lazy initialization: pull inner dims, bounds, linearity tags,
1374 /// Jacobian, and starting point; run Phase 1 + Phase 2 passes;
1375 /// cache everything needed to translate later eval_* calls.
1376 fn ensure_init(&mut self) -> Option<&PresolveState> {
1377 if self.state.is_some() {
1378 return self.state.as_ref();
1379 }
1380
1381 let info_inner = self.inner.borrow_mut().get_nlp_info()?;
1382 let n = info_inner.n as usize;
1383 let m_in = info_inner.m as usize;
1384 let nnz_in = info_inner.nnz_jac_g as usize;
1385
1386 // Inner bounds.
1387 let mut x_l = vec![0.0; n];
1388 let mut x_u = vec![0.0; n];
1389 let mut g_l_inner = vec![0.0; m_in];
1390 let mut g_u_inner = vec![0.0; m_in];
1391 {
1392 let mut inner = self.inner.borrow_mut();
1393 if !inner.get_bounds_info(BoundsInfo {
1394 x_l: &mut x_l,
1395 x_u: &mut x_u,
1396 g_l: &mut g_l_inner,
1397 g_u: &mut g_u_inner,
1398 }) {
1399 return None;
1400 }
1401 }
1402
1403 // Jacobian sparsity.
1404 let mut jac_irow_inner = vec![0 as Index; nnz_in];
1405 let mut jac_jcol_inner = vec![0 as Index; nnz_in];
1406 if nnz_in > 0 {
1407 let mut inner = self.inner.borrow_mut();
1408 if !inner.eval_jac_g(
1409 None,
1410 false,
1411 SparsityRequest::Structure {
1412 irow: &mut jac_irow_inner,
1413 jcol: &mut jac_jcol_inner,
1414 },
1415 ) {
1416 return None;
1417 }
1418 }
1419
1420 // Linearity tags (presolve is dormant without them).
1421 let mut linearity = vec![Linearity::NonLinear; m_in];
1422 let have_linearity = if m_in > 0 {
1423 self.inner
1424 .borrow_mut()
1425 .get_constraints_linearity(&mut linearity)
1426 } else {
1427 true
1428 };
1429
1430 // Per-variable linearity (H11): lets Phase-0 objective-coupling
1431 // classification distinguish a genuinely objective-free variable from
1432 // one that is merely zero-gradient at the single probe point. The
1433 // objective-scoped query is preferred — it is exactly the set the
1434 // guard needs. The global tags are only a fallback: they are a
1435 // conservative superset (a variable nonlinear only in a *constraint*
1436 // is tagged NonLinear too), which keeps the guard sound but blocks
1437 // legitimate eliminations of objective-free blocks (the gas-network
1438 // case). Most TNLPs decline both (default `false`), in which case
1439 // Phase 0 falls back to the probe gradient alone.
1440 let mut var_linearity = vec![Linearity::NonLinear; n];
1441 let have_var_linearity = {
1442 let mut inner = self.inner.borrow_mut();
1443 inner.get_objective_variables_linearity(&mut var_linearity)
1444 || inner.get_variables_linearity(&mut var_linearity)
1445 };
1446
1447 // Probe point for Jacobian values (linear rows have constant
1448 // Jacobians; this `x` is only needed because some inner
1449 // TNLPs assert on receipt).
1450 let mut x_probe = vec![0.0; n];
1451 let mut z_l_probe = vec![0.0; n];
1452 let mut z_u_probe = vec![0.0; n];
1453 let mut lambda_probe = vec![0.0; m_in];
1454 let started = self.inner.borrow_mut().get_starting_point(StartingPoint {
1455 init_x: true,
1456 x: &mut x_probe,
1457 init_z: false,
1458 z_l: &mut z_l_probe,
1459 z_u: &mut z_u_probe,
1460 init_lambda: false,
1461 lambda: &mut lambda_probe,
1462 });
1463 if !started {
1464 return None;
1465 }
1466
1467 // Jacobian values at the probe.
1468 let mut jac_values_inner = vec![0.0; nnz_in];
1469 if nnz_in > 0 {
1470 let ok = self.inner.borrow_mut().eval_jac_g(
1471 Some(&x_probe),
1472 true,
1473 SparsityRequest::Values {
1474 values: &mut jac_values_inner,
1475 },
1476 );
1477 if !ok {
1478 return None;
1479 }
1480 }
1481
1482 // Build LinearRow list from the inner Jacobian + linearity.
1483 let one_based = matches!(info_inner.index_style, IndexStyle::Fortran);
1484 let mut by_row: Vec<Vec<(Index, Number)>> = vec![Vec::new(); m_in];
1485 for k in 0..nnz_in {
1486 let i = if one_based {
1487 (jac_irow_inner[k] - 1) as usize
1488 } else {
1489 jac_irow_inner[k] as usize
1490 };
1491 let j = if one_based {
1492 jac_jcol_inner[k] - 1
1493 } else {
1494 jac_jcol_inner[k]
1495 };
1496 if i < m_in && (j as usize) < n {
1497 by_row[i].push((j, jac_values_inner[k]));
1498 }
1499 }
1500 let linear_row_map: Vec<Option<LinearRow>> = (0..m_in)
1501 .map(|i| {
1502 if have_linearity && matches!(linearity[i], Linearity::Linear) {
1503 Some(LinearRow {
1504 entries: by_row[i].clone(),
1505 lo: g_l_inner[i],
1506 hi: g_u_inner[i],
1507 })
1508 } else {
1509 None
1510 }
1511 })
1512 .collect();
1513 // NOTE: `linear_rows` is materialised AFTER Phase 0 so it
1514 // can filter out rows Phase 0 dropped — propagating bounds
1515 // through aux-eliminated rows lets tighten_bounds derive
1516 // contradictions (see issue #53 PR review).
1517
1518 // Snapshot inner bounds before Phase 1 mutates them; needed
1519 // for Phase 4 warm-start hints AND for rolling back Phase 0
1520 // if its clamps later prove infeasible against the kept
1521 // linear rows.
1522 let inner_x_l = x_l.clone();
1523 let inner_x_u = x_u.clone();
1524
1525 // Phase 0 (issue #53): auxiliary-equality preprocessing.
1526 // PR 8 wires the real pipeline (incidence → matching → DM →
1527 // BTF → classify → linear block solve → frame). Variables it
1528 // fixes are clamped via x_l/x_u; rows it drops are recorded
1529 // in `row_kept_inner` so the existing remapping logic below
1530 // picks them up.
1531 let mut row_kept_inner: Vec<bool> = vec![true; m_in];
1532 let mut reduction_stack = ReductionStack::default();
1533 let aux_diagnostics = if self.opts.auxiliary && m_in > 0 {
1534 // Probe extra quantities Phase 0 needs.
1535 let mut g_at_probe = vec![0.0; m_in];
1536 let g_ok = self
1537 .inner
1538 .borrow_mut()
1539 .eval_g(&x_probe, true, &mut g_at_probe);
1540 if !g_ok {
1541 return None;
1542 }
1543 let mut grad_f_probe = vec![0.0; n];
1544 let grad_ok = self
1545 .inner
1546 .borrow_mut()
1547 .eval_grad_f(&x_probe, false, &mut grad_f_probe);
1548 if !grad_ok {
1549 return None;
1550 }
1551 // Plug everything into the orchestrator.
1552 let linearity_for_phase0: Vec<Linearity> = if have_linearity {
1553 linearity.clone()
1554 } else {
1555 vec![Linearity::NonLinear; m_in]
1556 };
1557 let probe_view = auxiliary::Phase0Probe {
1558 n_vars: n,
1559 n_rows: m_in,
1560 jac_irow: &jac_irow_inner,
1561 jac_jcol: &jac_jcol_inner,
1562 jac_values: &jac_values_inner,
1563 g_l: &g_l_inner,
1564 g_u: &g_u_inner,
1565 g_at_probe: &g_at_probe,
1566 linearity: &linearity_for_phase0,
1567 one_based,
1568 eq_tol: 1e-12,
1569 x_probe: &x_probe,
1570 grad_f: &grad_f_probe,
1571 var_linearity: if have_var_linearity {
1572 Some(&var_linearity)
1573 } else {
1574 None
1575 },
1576 x_l: &x_l,
1577 x_u: &x_u,
1578 };
1579 // Adapter: wrap `self.inner` so the orchestrator can
1580 // call eval_g / eval_jac_g for nonlinear blocks.
1581 struct TnlpCallbackAdapter {
1582 inner: Rc<RefCell<dyn TNLP>>,
1583 }
1584 impl auxiliary::Phase0TnlpCallback for TnlpCallbackAdapter {
1585 fn eval_g_full(&mut self, x: &[Number], g: &mut [Number]) -> bool {
1586 self.inner.borrow_mut().eval_g(x, true, g)
1587 }
1588 fn eval_jac_g_values(&mut self, x: &[Number], values: &mut [Number]) -> bool {
1589 self.inner.borrow_mut().eval_jac_g(
1590 Some(x),
1591 true,
1592 SparsityRequest::Values { values },
1593 )
1594 }
1595 }
1596 let mut adapter = TnlpCallbackAdapter {
1597 inner: Rc::clone(&self.inner),
1598 };
1599 let mut large_solver = block_solve::RelaxedNewtonSolver;
1600 let plan = auxiliary::run_auxiliary_phase0(
1601 &self.opts,
1602 &probe_view,
1603 Some(&mut adapter),
1604 Some(&mut large_solver),
1605 );
1606 if let Some(frame) = plan.frame {
1607 // Clamp fixed variables.
1608 for (k, &i) in frame.fixed_vars.iter().enumerate() {
1609 x_l[i] = frame.fixed_values[k];
1610 x_u[i] = frame.fixed_values[k];
1611 }
1612 // Drop dropped rows.
1613 for &r in &frame.dropped_rows {
1614 row_kept_inner[r] = false;
1615 }
1616 reduction_stack.push(frame);
1617 }
1618 // When `presolve_auxiliary_diagnostics=yes`, emit the
1619 // summary through tracing (pounce#71).
1620 if self.opts.auxiliary_diagnostics {
1621 tracing::info!(target: "pounce::presolve", "{}", plan.diagnostics);
1622 }
1623 plan.diagnostics
1624 } else {
1625 AuxiliaryPreprocessingDiagnostics::default()
1626 };
1627
1628 // Build `linear_rows` excluding rows Phase 0 dropped. This
1629 // is the headline fix from the #53 PR review: propagating
1630 // bounds through an aux-dropped row lets tighten_bounds
1631 // derive `x_l[j] > x_u[j]` for an aux-clamped variable and
1632 // then hand corrupted bounds to the IPM.
1633 let mut linear_rows: Vec<LinearRow> = linear_row_map
1634 .iter()
1635 .enumerate()
1636 .filter_map(|(i, r)| if row_kept_inner[i] { r.clone() } else { None })
1637 .collect();
1638
1639 // Phase 1: bound tightening using linear rows.
1640 let mut tighten_report = TightenReport::default();
1641 // Set only where a contradiction is derived on an un-clamped box; see
1642 // the field doc on `PresolveState::certified_infeasible`.
1643 let mut certified_infeasible: Option<InfeasibilityProof> = None;
1644 if self.opts.bound_tightening && !linear_rows.is_empty() {
1645 tighten_report = tighten_bounds(
1646 &linear_rows,
1647 &mut x_l,
1648 &mut x_u,
1649 self.opts.max_passes,
1650 1e-12,
1651 );
1652 }
1653
1654 // Defence in depth: if Phase 1 still flags infeasibility AND
1655 // Phase 0 made changes, those changes are presumed to blame
1656 // (aux solved a block to a point inconsistent with bounds
1657 // from kept rows). Roll back Phase 0 — restore bounds, undo
1658 // row drops, clear the reduction stack — and re-run Phase 1
1659 // on the un-filtered linear rows. Without this guard,
1660 // `report.infeasible` was previously never inspected and
1661 // corrupted bounds reached the IPM (#53 PR review).
1662 if tighten_report.infeasible && !reduction_stack.is_empty() {
1663 tracing::warn!(
1664 target: "pounce::presolve",
1665 "auxiliary-equality elimination produced bounds inconsistent \
1666 with kept linear rows; rolling back the elimination for this solve."
1667 );
1668 x_l.copy_from_slice(&inner_x_l);
1669 x_u.copy_from_slice(&inner_x_u);
1670 for kept in row_kept_inner.iter_mut() {
1671 *kept = true;
1672 }
1673 reduction_stack = ReductionStack::default();
1674 // Re-run tighten on the unfiltered linear rows now that
1675 // the aux clamps are gone. Rebuild `linear_rows` to the
1676 // full set too, so Phase 2's redundancy mask stays aligned
1677 // with the now all-kept `row_kept_inner` (C1).
1678 let full_linear_rows: Vec<LinearRow> =
1679 linear_row_map.iter().filter_map(|r| r.clone()).collect();
1680 tighten_report = TightenReport::default();
1681 if self.opts.bound_tightening && !full_linear_rows.is_empty() {
1682 tighten_report = tighten_bounds(
1683 &full_linear_rows,
1684 &mut x_l,
1685 &mut x_u,
1686 self.opts.max_passes,
1687 1e-12,
1688 );
1689 }
1690 linear_rows = full_linear_rows;
1691 }
1692
1693 // M25: a *genuine* Phase-1 infeasibility (empty feasible box) must not
1694 // reach the IPM as crossed bounds `x_l > x_u`. `tighten_pass` returns
1695 // the moment it detects `x_l[j] > x_u[j]`, leaving those crossed
1696 // bounds in place; the rollback above only fires when Phase 0 made
1697 // changes (`!reduction_stack.is_empty()`), so an infeasibility found
1698 // with an empty reduction stack — or one that survives the rollback
1699 // re-tighten — would otherwise hand a degenerate box to the solver,
1700 // which reports an invalid-problem error instead of a clean
1701 // infeasibility verdict. Presolve has no channel to certify
1702 // infeasibility, so — mirroring the aux rollback above and the FBBT
1703 // handling below — restore the original inner box (always a valid box)
1704 // and let the IPM run on it and certify infeasibility itself. The
1705 // `tighten_report.infeasible` flag is preserved and surfaced via
1706 // `tighten_report()` for diagnostics.
1707 if tighten_report.infeasible {
1708 // Measure the crossing *before* the restore below overwrites it.
1709 let crossing = (0..n).map(|j| x_l[j] - x_u[j]).fold(0.0_f64, f64::max);
1710 // Reaching here implies the tightening ran on an un-clamped box:
1711 // the aux rollback above either did not fire (empty reduction
1712 // stack ⇒ Phase 0 changed nothing) or fired and recomputed
1713 // `tighten_report` on the restored box. Either way this is a
1714 // genuine contradiction in the user's model, not a presolve
1715 // artifact, so it is certifiable. The crossed bounds are still
1716 // discarded — a degenerate box must not reach the solver — but the
1717 // *verdict* now survives.
1718 // Phase 1 flags a crossing above its own `1e-12` test, which is
1719 // far below what the solver treats as feasible. Certify only a
1720 // crossing the solver's own acceptance would call a real violation
1721 // at that variable's scale (see `crossing_is_certifiable`), so a
1722 // model POUNCE would solve to `Solve_Succeeded` can never be
1723 // called proved infeasible.
1724 let robust = crossing_is_certifiable(&x_l, &x_u, certify_margin(self.opts.certify_tol));
1725 x_l.copy_from_slice(&inner_x_l);
1726 x_u.copy_from_slice(&inner_x_u);
1727 if robust {
1728 certified_infeasible = Some(InfeasibilityProof::BoundPropagation);
1729 }
1730 tracing::warn!(
1731 target: "pounce::presolve",
1732 crossing,
1733 certified = robust,
1734 "Phase 1 bound tightening found the feasible region empty; its \
1735 crossed bounds are being discarded."
1736 );
1737 }
1738
1739 // Phase 1 declares infeasibility only when the crossing exceeds its
1740 // `1e-12` margin, so a *sub-margin* crossing survives the block above:
1741 // `infeasible` is false, nothing is restored, and `x_l[j] > x_u[j]`
1742 // by a hair is handed to the solver, which rejects it as
1743 // `Invalid_Problem_Definition`. The model that surfaced this is
1744 // `x >= 0.1 + 0.2` with `x <= 0.3` — in binary floating point the two
1745 // differ by `5.5e-17`, so the box arrives crossed by that much and a
1746 // model POUNCE otherwise solves cleanly (`presolve=no` gives
1747 // `Solve_Succeeded`, the LP route gives "Optimal Solution Found")
1748 // failed with an invalid-problem error the moment presolve was on.
1749 //
1750 // Collapse those to a point. The crossing is below the margin at which
1751 // the tightening itself is willing to call the region empty, so the
1752 // honest reading is a single feasible value, not an empty set — and it
1753 // keeps every route's answer consistent.
1754 // The collapsed point must stay inside the *declared* box. The crossing
1755 // exists because tightening moved a bound past its partner, so the
1756 // midpoint of the crossed pair lies strictly outside the original range
1757 // — for `x >= 0.1 + 0.2`, `x <= 0.3` it lands on `0.30000000000000004`,
1758 // above the user's `x <= 0.3`. Returning that would silently violate a
1759 // declared bound, which is worse than the error it replaces and defeats
1760 // `honor_original_bounds`. Clamping to the original box keeps the
1761 // reported point admissible.
1762 //
1763 // Only *negligible* crossings collapse — `is_negligible` at the
1764 // crossed pair's own magnitude, the exact complement of
1765 // `crossing_is_certifiable`, so every crossing is either collapsed
1766 // here or certifiable there, never both. A crossing the solver itself
1767 // would call a real violation is not float noise; it is a
1768 // user-declared empty box (`x in [5, 3]`) on a variable no linear row
1769 // ever propagated into, which Phase 1 therefore never flagged.
1770 // Collapsing that would turn `Invalid_Problem_Definition` into
1771 // `Solve_Succeeded` at a point the model excludes — a wrong answer
1772 // replacing a correct error. Those stay crossed so the solver rejects
1773 // them exactly as it does with presolve off.
1774 let collapse_tol = certify_margin(self.opts.certify_tol);
1775 for j in 0..n {
1776 if x_l[j] > x_u[j]
1777 && is_negligible(
1778 x_l[j] - x_u[j],
1779 x_l[j].abs().max(x_u[j].abs()),
1780 collapse_tol,
1781 )
1782 {
1783 let mid = (0.5 * (x_l[j] + x_u[j]))
1784 .max(inner_x_l[j])
1785 .min(inner_x_u[j]);
1786 x_l[j] = mid;
1787 x_u[j] = mid;
1788 }
1789 }
1790
1791 // Phase 1b — FBBT (issue #62). Runs interval arithmetic over
1792 // each nonlinear constraint's expression DAG to tighten
1793 // variable bounds further. No-op when (a) `presolve_fbbt` is
1794 // off, (b) the inner TNLP did not supply an
1795 // `ExpressionProvider`, or (c) the problem has zero
1796 // constraints. Honors `fbbt_tol`, `fbbt_max_iter`, and
1797 // `fbbt_max_constraints`.
1798 let mut fbbt_report: Option<crate::fbbt::FbbtReport> = None;
1799 if self.opts.fbbt && m_in > 0 {
1800 if let Some(provider) = self.expr_provider.as_ref() {
1801 let cfg = crate::fbbt::FbbtConfig {
1802 tol: self.opts.fbbt_tol,
1803 max_iter: self.opts.fbbt_max_iter.max(1) as usize,
1804 max_constraints: self.opts.fbbt_max_constraints.max(0) as usize,
1805 };
1806 // H12: snapshot the bounds before FBBT. The `FbbtReport`
1807 // contract states that on detected infeasibility the
1808 // variable bounds are "undefined and must not be trusted"
1809 // — FBBT may have partially tightened several variables
1810 // before a later constraint proved the box empty. Pass the
1811 // Phase-0 `row_kept_inner` mask so propagation skips any row
1812 // an auxiliary elimination dropped (over the aux-clamped box
1813 // a dropped row can fabricate a spurious infeasibility — the
1814 // same #53 hazard Phase 1 guards against with filtered rows).
1815 let fbbt_x_l_pre = x_l.clone();
1816 let fbbt_x_u_pre = x_u.clone();
1817 let provider_borrow = provider.borrow();
1818 let mut report = crate::fbbt::run_fbbt(
1819 &*provider_borrow,
1820 n,
1821 m_in,
1822 &mut x_l,
1823 &mut x_u,
1824 &g_l_inner,
1825 &g_u_inner,
1826 Some(&row_kept_inner),
1827 &cfg,
1828 );
1829 drop(provider_borrow);
1830 if report.infeasibility_witness.is_some() && !reduction_stack.is_empty() {
1831 // F6: an FBBT infeasibility found while a Phase-0
1832 // auxiliary elimination is in force may be an *artifact*
1833 // of that elimination — an aux clamp can make a kept
1834 // *nonlinear* row infeasible over the clamped box even
1835 // though the original problem is feasible. Restoring only
1836 // the pre-FBBT box (the `else` branch below) leaves the
1837 // aux clamps in place, so the IPM would then cleanly
1838 // certify infeasibility of a problem presolve itself broke
1839 // — a wrong "infeasible" verdict on a feasible original.
1840 // Mirror the Phase-1 aux rollback (the `tighten_report`
1841 // branch above): undo Phase 0 entirely — restore the inner
1842 // box, re-keep every dropped row, clear the reduction
1843 // stack, rebuild the full linear-row set — then re-run
1844 // Phase 1 and FBBT on the un-clamped box. Only an
1845 // infeasibility that *survives* on the un-clamped box is
1846 // genuine; presolve still cannot certify it, so it then
1847 // falls through to the same "discard FBBT bounds, let the
1848 // IPM certify" handling.
1849 tracing::warn!(
1850 target: "pounce::presolve",
1851 witness = report.infeasibility_witness,
1852 "FBBT reported a constraint infeasibility while auxiliary \
1853 elimination was active; rolling back the elimination and \
1854 re-running FBBT on the un-clamped box to avoid certifying \
1855 a presolve-induced infeasibility on a feasible original."
1856 );
1857 x_l.copy_from_slice(&inner_x_l);
1858 x_u.copy_from_slice(&inner_x_u);
1859 for kept in row_kept_inner.iter_mut() {
1860 *kept = true;
1861 }
1862 reduction_stack = ReductionStack::default();
1863 // Rebuild `linear_rows` to the full set so Phase 2's
1864 // redundancy mask stays aligned with the now all-kept
1865 // `row_kept_inner` (C1), mirroring the Phase-1 rollback.
1866 let full_linear_rows: Vec<LinearRow> =
1867 linear_row_map.iter().filter_map(|r| r.clone()).collect();
1868 tighten_report = TightenReport::default();
1869 if self.opts.bound_tightening && !full_linear_rows.is_empty() {
1870 tighten_report = tighten_bounds(
1871 &full_linear_rows,
1872 &mut x_l,
1873 &mut x_u,
1874 self.opts.max_passes,
1875 1e-12,
1876 );
1877 }
1878 linear_rows = full_linear_rows;
1879 // M25: a genuine Phase-1 infeasibility on the un-clamped
1880 // box must not reach the IPM as crossed bounds.
1881 if tighten_report.infeasible {
1882 // Measure the crossing before the restore wipes it.
1883 let crossing = (0..n).map(|j| x_l[j] - x_u[j]).fold(0.0_f64, f64::max);
1884 // Derived on the rolled-back, un-clamped box, so the
1885 // aux elimination cannot be to blame — certifiable when
1886 // the crossing is real at the variable's own scale.
1887 let robust = crossing_is_certifiable(
1888 &x_l,
1889 &x_u,
1890 certify_margin(self.opts.certify_tol),
1891 );
1892 x_l.copy_from_slice(&inner_x_l);
1893 x_u.copy_from_slice(&inner_x_u);
1894 if robust {
1895 certified_infeasible = Some(InfeasibilityProof::BoundPropagation);
1896 }
1897 tracing::warn!(
1898 target: "pounce::presolve",
1899 crossing,
1900 certified = robust,
1901 "Phase 1 bound tightening on the rolled-back (un-clamped) \
1902 box found the feasible region empty; its crossed bounds \
1903 are being discarded."
1904 );
1905 }
1906 // Re-run FBBT on the un-clamped, all-kept box.
1907 let rerun_x_l_pre = x_l.clone();
1908 let rerun_x_u_pre = x_u.clone();
1909 let provider_borrow = provider.borrow();
1910 report = crate::fbbt::run_fbbt(
1911 &*provider_borrow,
1912 n,
1913 m_in,
1914 &mut x_l,
1915 &mut x_u,
1916 &g_l_inner,
1917 &g_u_inner,
1918 Some(&row_kept_inner),
1919 &cfg,
1920 );
1921 drop(provider_borrow);
1922 if let Some(witness) = report.infeasibility_witness {
1923 // Survives without the aux clamp: a genuine
1924 // infeasibility of the original problem, so it is
1925 // certifiable *if* it is more than a hair's breadth —
1926 // see `certify_margin`. FBBT's own bounds are
1927 // still undefined per the `FbbtReport` contract and are
1928 // discarded either way.
1929 let provider_borrow = provider.borrow();
1930 let robust = fbbt_infeasibility_survives_margin(
1931 &*provider_borrow,
1932 n,
1933 m_in,
1934 &inner_x_l,
1935 &inner_x_u,
1936 &g_l_inner,
1937 &g_u_inner,
1938 &row_kept_inner,
1939 &cfg,
1940 certify_margin(self.opts.certify_tol),
1941 );
1942 drop(provider_borrow);
1943 if robust {
1944 certified_infeasible =
1945 Some(InfeasibilityProof::IntervalArithmetic { witness });
1946 }
1947 tracing::warn!(
1948 target: "pounce::presolve",
1949 witness,
1950 certified = robust,
1951 "FBBT still reports infeasibility on the un-clamped box; \
1952 treating it as genuine."
1953 );
1954 x_l.copy_from_slice(&rerun_x_l_pre);
1955 x_u.copy_from_slice(&rerun_x_u_pre);
1956 }
1957 } else if let Some(witness) = report.infeasibility_witness {
1958 // No aux elimination active (empty reduction stack): the
1959 // witnessed infeasibility cannot be a Phase-0 artifact, so
1960 // it is certifiable *if* it is more than a hair's breadth —
1961 // see `certify_margin`. FBBT's tightened bounds are
1962 // undefined per the `FbbtReport` contract and are discarded
1963 // either way.
1964 let provider_borrow = provider.borrow();
1965 let robust = fbbt_infeasibility_survives_margin(
1966 &*provider_borrow,
1967 n,
1968 m_in,
1969 &inner_x_l,
1970 &inner_x_u,
1971 &g_l_inner,
1972 &g_u_inner,
1973 &row_kept_inner,
1974 &cfg,
1975 certify_margin(self.opts.certify_tol),
1976 );
1977 drop(provider_borrow);
1978 if robust {
1979 certified_infeasible =
1980 Some(InfeasibilityProof::IntervalArithmetic { witness });
1981 }
1982 tracing::warn!(
1983 target: "pounce::presolve",
1984 witness,
1985 certified = robust,
1986 "FBBT reported a constraint's feasible range empty; its \
1987 tightened bounds are undefined and are being discarded."
1988 );
1989 x_l.copy_from_slice(&fbbt_x_l_pre);
1990 x_u.copy_from_slice(&fbbt_x_u_pre);
1991 }
1992 fbbt_report = Some(report);
1993 }
1994 }
1995
1996 // Final admissibility gate for the infeasibility verdict, covering
1997 // BOTH certification paths in one place.
1998 //
1999 // Interval reasoning over-approximates; at extreme coefficient scale it
2000 // can report a feasible region empty. Before the verdict escapes, try
2001 // to refute it by evaluating the real constraints at concrete points in
2002 // the declared box. A witness means the region is demonstrably not
2003 // empty, whatever the intervals concluded.
2004 //
2005 // Placed here, once, rather than beside each detection: the two
2006 // previous safeguards were each added to one path and not its twin,
2007 // and a hole survived both times.
2008 //
2009 // Which accepting test decides "satisfies" is the call site's choice,
2010 // not an option — see `WitnessRule`. Every wrapper that is solved
2011 // through uses the solver's own acceptance; only a caller that has
2012 // established the solve cannot run may raise it to the declared,
2013 // scale-relative form.
2014 if certified_infeasible.is_some()
2015 && witness_refutes_infeasibility(
2016 &self.inner,
2017 n,
2018 m_in,
2019 &inner_x_l,
2020 &inner_x_u,
2021 &g_l_inner,
2022 &g_u_inner,
2023 self.opts.certify_tol,
2024 self.witness_rule,
2025 )
2026 {
2027 tracing::warn!(
2028 target: "pounce::presolve",
2029 "presolve flagged the feasible region empty, but a point in the \
2030 declared box satisfies every constraint — withdrawing the \
2031 verdict and letting the solver decide."
2032 );
2033 certified_infeasible = None;
2034 }
2035
2036 // Phase 4: any variable whose lower (upper) bound moved
2037 // strictly inward is a candidate for a bound-multiplier warm
2038 // start. Zero entries leave that bound's multiplier on the
2039 // global default (`bound_mult_init_val` from upstream).
2040 let warm_tol: Number = 1e-12;
2041 let (z_l_warm, z_u_warm) = if self.opts.warm_z_bounds {
2042 let v0 = self.opts.bound_mult_init_val;
2043 let mut zl = vec![0.0; n];
2044 let mut zu = vec![0.0; n];
2045 for i in 0..n {
2046 if x_l[i] > inner_x_l[i] + warm_tol {
2047 zl[i] = v0;
2048 }
2049 if x_u[i] < inner_x_u[i] - warm_tol {
2050 zu[i] = v0;
2051 }
2052 }
2053 (zl, zu)
2054 } else {
2055 (vec![0.0; n], vec![0.0; n])
2056 };
2057
2058 // Phase 2: detect redundant linear rows in the (possibly
2059 // tightened) box. Non-linear rows are never dropped. The
2060 // `row_kept_inner` mask was initialised above by Phase 0.
2061 let mut n_dropped_rows: Index = 0;
2062 let kept_before_phase2 = row_kept_inner.clone();
2063 if self.opts.redundant_constraint_removal {
2064 let redundant_mask = find_redundant_rows(&linear_rows, &x_l, &x_u, 1e-9);
2065 // `redundant_mask` aligns with the *kept* linear rows
2066 // (`linear_rows`); map it back onto inner rows, skipping
2067 // rows Phase 0 already dropped (C1).
2068 n_dropped_rows =
2069 apply_redundant_verdicts(&linear_row_map, &redundant_mask, &mut row_kept_inner);
2070 }
2071
2072 let singleton_bounds = find_singleton_bounds(
2073 &linear_row_map,
2074 &kept_before_phase2,
2075 &row_kept_inner,
2076 &x_l,
2077 &x_u,
2078 &inner_x_l,
2079 &inner_x_u,
2080 );
2081 let phase2_dropped_rows: Vec<(usize, Vec<(usize, Number)>)> = linear_row_map
2082 .iter()
2083 .enumerate()
2084 .filter(|(i, _)| {
2085 kept_before_phase2.get(*i).copied().unwrap_or(false)
2086 && !row_kept_inner.get(*i).copied().unwrap_or(true)
2087 && !singleton_bounds.iter().any(|sb| sb.row == *i)
2088 })
2089 .filter_map(|(i, lr)| {
2090 lr.as_ref().map(|lr| {
2091 (
2092 i,
2093 lr.entries
2094 .iter()
2095 .filter(|(_, a)| *a != 0.0 && a.is_finite())
2096 .map(|&(j, a)| (j as usize, a))
2097 .collect(),
2098 )
2099 })
2100 })
2101 .collect();
2102
2103 // Phase 3: structural LICQ check on the kept equality rows.
2104 let licq_verdict = if self.opts.licq_check {
2105 let eq_tol: Number = 1e-12;
2106 let mut eq_rows: Vec<EqRow> = Vec::new();
2107 for (i, &kept) in row_kept_inner.iter().enumerate() {
2108 if !kept {
2109 continue;
2110 }
2111 if (g_u_inner[i] - g_l_inner[i]).abs() > eq_tol {
2112 continue;
2113 }
2114 use std::collections::BTreeSet;
2115 let mut cols: BTreeSet<Index> = BTreeSet::new();
2116 for &(j, v) in &by_row[i] {
2117 if v != 0.0 {
2118 cols.insert(j);
2119 }
2120 }
2121 eq_rows.push(EqRow {
2122 cols: cols.into_iter().collect(),
2123 });
2124 }
2125 Some(licq_check(&eq_rows, info_inner.n))
2126 } else {
2127 None
2128 };
2129
2130 // Build outer row mapping.
2131 let mut rows_kept: Vec<usize> = Vec::with_capacity(m_in);
2132 let mut row_inner_to_outer = vec![usize::MAX; m_in];
2133 for (i, &kept) in row_kept_inner.iter().enumerate() {
2134 if kept {
2135 row_inner_to_outer[i] = rows_kept.len();
2136 rows_kept.push(i);
2137 }
2138 }
2139 let m_out = rows_kept.len();
2140
2141 // Build outer Jacobian sparsity: keep entries whose row is
2142 // kept, renumber rows.
2143 let mut jac_kept_idx = Vec::new();
2144 let mut jac_irow_outer = Vec::new();
2145 let mut jac_jcol_outer = Vec::new();
2146 for k in 0..nnz_in {
2147 let i_inner = if one_based {
2148 (jac_irow_inner[k] - 1) as usize
2149 } else {
2150 jac_irow_inner[k] as usize
2151 };
2152 if i_inner >= m_in {
2153 continue;
2154 }
2155 if !row_kept_inner[i_inner] {
2156 continue;
2157 }
2158 let outer = row_inner_to_outer[i_inner];
2159 let outer_row_index = if one_based {
2160 (outer as Index) + 1
2161 } else {
2162 outer as Index
2163 };
2164 jac_irow_outer.push(outer_row_index);
2165 jac_jcol_outer.push(jac_jcol_inner[k]);
2166 jac_kept_idx.push(k);
2167 }
2168 let nnz_out = jac_kept_idx.len();
2169
2170 // Reduced g_l, g_u in outer ordering.
2171 let g_l: Vec<Number> = rows_kept.iter().map(|&i| g_l_inner[i]).collect();
2172 let g_u: Vec<Number> = rows_kept.iter().map(|&i| g_u_inner[i]).collect();
2173
2174 let info_outer = NlpInfo {
2175 n: info_inner.n,
2176 m: m_out as Index,
2177 nnz_jac_g: nnz_out as Index,
2178 // Linear rows contribute zero to the Hessian, so dropping
2179 // them does not change `nnz_h_lag`. We carry the inner
2180 // sparsity through unchanged.
2181 nnz_h_lag: info_inner.nnz_h_lag,
2182 index_style: info_inner.index_style,
2183 };
2184
2185 self.state = Some(PresolveState {
2186 info_inner,
2187 info_outer,
2188 bounds: CachedBounds { x_l, x_u, g_l, g_u },
2189 rows_kept,
2190 jac_kept_idx,
2191 jac_irow_outer,
2192 jac_jcol_outer,
2193 tighten_report,
2194 certified_infeasible,
2195 fbbt_report,
2196 n_dropped_rows,
2197 licq_verdict,
2198 z_l_warm,
2199 z_u_warm,
2200 scratch_g: vec![0.0; m_in],
2201 scratch_jac: vec![0.0; nnz_in],
2202 scratch_lambda: vec![0.0; m_in],
2203 aux_diagnostics,
2204 reduction_stack,
2205 singleton_bounds,
2206 decl_x_l: inner_x_l,
2207 decl_x_u: inner_x_u,
2208 decl_g_l: g_l_inner,
2209 decl_g_u: g_u_inner,
2210 phase2_dropped_rows,
2211 });
2212 self.state.as_ref()
2213 }
2214}
2215
2216/// Map `find_redundant_rows`' verdict mask back onto `row_kept_inner`,
2217/// returning the number of rows newly dropped.
2218///
2219/// The mask is aligned to the **kept** linear rows in inner-row order
2220/// — i.e. it has exactly one entry per `(i)` where `linear_row_map[i]`
2221/// is `Some` **and** `row_kept_inner[i]` is still `true` (that is the
2222/// filter `linear_rows` was built with). The mapping iterator must
2223/// therefore advance only on rows that are both linear and still kept.
2224///
2225/// Regression guard for C1: the original loop advanced the mask on
2226/// every `Some` row regardless of `row_kept_inner`, so once Phase 0 had
2227/// dropped any linear row every later verdict landed on its
2228/// predecessor's row — silently deleting a binding constraint (and
2229/// keeping a redundant one).
2230fn apply_redundant_verdicts(
2231 linear_row_map: &[Option<LinearRow>],
2232 redundant_mask: &[bool],
2233 row_kept_inner: &mut [bool],
2234) -> Index {
2235 let mut mask = redundant_mask.iter();
2236 let mut n_dropped: Index = 0;
2237 for (i, lr) in linear_row_map.iter().enumerate() {
2238 if lr.is_some() && row_kept_inner[i] {
2239 if *mask.next().unwrap_or(&false) {
2240 row_kept_inner[i] = false;
2241 n_dropped += 1;
2242 }
2243 }
2244 }
2245 n_dropped
2246}
2247
2248// Inside this impl every `.expect("inited")` is invariant-protected
2249// by the preceding `ensure_init` (which is the only way state ever
2250// becomes `Some`).
2251#[allow(clippy::expect_used)]
2252impl TNLP for PresolveTnlp {
2253 fn is_presolve_wrapper(&self) -> bool {
2254 true
2255 }
2256
2257 fn presolve_infeasibility_proof(&self) -> Option<InfeasibilityProof> {
2258 self.certified_infeasible()
2259 }
2260
2261 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2262 let s = self.ensure_init()?;
2263 Some(s.info_outer)
2264 }
2265
2266 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2267 let Some(s) = self.ensure_init() else {
2268 return false;
2269 };
2270 b.x_l.copy_from_slice(&s.bounds.x_l);
2271 b.x_u.copy_from_slice(&s.bounds.x_u);
2272 b.g_l.copy_from_slice(&s.bounds.g_l);
2273 b.g_u.copy_from_slice(&s.bounds.g_u);
2274 true
2275 }
2276
2277 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2278 // n is unchanged by presolve; lambda warm-start is masked to
2279 // kept rows only (caller already sized `sp.lambda` to m_out).
2280 self.served_warm_start = None;
2281 let Some(_) = self.ensure_init() else {
2282 return false;
2283 };
2284 // For now, ask the inner TNLP for its starting point in full
2285 // and project lambda down. Most users don't warm-start
2286 // duals, so this hits the no-op path.
2287 let m_in = self.state.as_ref().expect("inited").info_inner.m as usize;
2288 // Start from the caller's buffers, not zeros: `OrigIpoptNlp` fills
2289 // them with NaN to mean "unseeded" (gh#622), and an inner TNLP that
2290 // supplies no bound multipliers must leave that marker standing. A
2291 // zero fill turned a primal-only warm start into "z = 0 supplied".
2292 let mut z_l_full = sp.z_l.to_vec();
2293 let mut z_u_full = sp.z_u.to_vec();
2294 let mut lambda_full = vec![0.0; m_in];
2295 let ok = self.inner.borrow_mut().get_starting_point(StartingPoint {
2296 init_x: sp.init_x,
2297 x: sp.x,
2298 init_z: sp.init_z,
2299 z_l: &mut z_l_full,
2300 z_u: &mut z_u_full,
2301 init_lambda: sp.init_lambda,
2302 lambda: &mut lambda_full,
2303 });
2304 if !ok {
2305 return false;
2306 }
2307 // What the caller handed, before any seeding below (`warm_echo`).
2308 let handed = (sp.init_z || sp.init_lambda)
2309 .then(|| (z_l_full.clone(), z_u_full.clone(), lambda_full.clone()));
2310 let s = self.state.as_ref().expect("inited");
2311 // M24, inbound: a dropped singleton row's supplied multiplier is the
2312 // bound multiplier of the bound presolve made from it — the inverse
2313 // of the hand-back in `finalize_solution`, so a `.sol` round-trips.
2314 if sp.init_z && sp.init_lambda {
2315 for sb in &s.singleton_bounds {
2316 let Some(&lam) = lambda_full.get(sb.row) else {
2317 continue;
2318 };
2319 // Contribution to stationarity at the variable: `+a·λ`,
2320 // which the bound carries as `+z_u` or `−z_l`.
2321 let c = sb.coeff * lam;
2322 if !c.is_finite() {
2323 continue;
2324 }
2325 let (side, z) = if sb.upper {
2326 (&mut z_u_full, c)
2327 } else {
2328 (&mut z_l_full, -c)
2329 };
2330 if z > 0.0
2331 && let Some(dst) = side.get_mut(sb.var)
2332 {
2333 *dst = if dst.is_finite() { *dst + z } else { z };
2334 }
2335 }
2336 // M24, derived bounds, inbound: a multi-variable row Phase 2
2337 // dropped is not in the reduced problem, so its supplied
2338 // multiplier's contribution to stationarity, `a_ij·λ_i` at each
2339 // variable it touches, can only be carried there by the reduced
2340 // bound on that variable — the derived bound the row helped
2341 // imply. Seed it so the reduced start is as stationary as the
2342 // supplied one; `finalize_solution`'s recovery moves it back.
2343 let n = z_l_full.len();
2344 let mut net = vec![0.0; n];
2345 for (row, entries) in &s.phase2_dropped_rows {
2346 let Some(&lam) = lambda_full.get(*row) else {
2347 continue;
2348 };
2349 if !lam.is_finite() || lam == 0.0 {
2350 continue;
2351 }
2352 for &(j, a) in entries {
2353 if let Some(v) = net.get_mut(j) {
2354 *v += a * lam;
2355 }
2356 }
2357 }
2358 for (j, &c) in net.iter().enumerate() {
2359 if !c.is_finite() || c == 0.0 {
2360 continue;
2361 }
2362 // `+c` in stationarity is carried as `+z_u` or `−z_l`.
2363 let (side, z, present) = if c > 0.0 {
2364 (&mut z_u_full, c, upper_bound_present(s.bounds.x_u[j]))
2365 } else {
2366 (&mut z_l_full, -c, lower_bound_present(s.bounds.x_l[j]))
2367 };
2368 if present && let Some(dst) = side.get_mut(j) {
2369 *dst = if dst.is_finite() { *dst + z } else { z };
2370 }
2371 }
2372 }
2373 sp.z_l.copy_from_slice(&z_l_full);
2374 sp.z_u.copy_from_slice(&z_u_full);
2375 let mut x_fixed_overridden_count = None;
2376 let mut x_clamped_count = None;
2377 // Project the served primal into the reduced box the solver
2378 // sees, mirroring `warm::project_warm_point`.
2379 if self.project_seed && sp.init_x {
2380 let mut n_fixed = 0;
2381 for frame in s.reduction_stack.iter_bottom_up() {
2382 for (k, &i) in frame.fixed_vars.iter().enumerate() {
2383 if let (Some(dst), Some(&v)) = (sp.x.get_mut(i), frame.fixed_values.get(k)) {
2384 if *dst != v {
2385 n_fixed += 1;
2386 }
2387 *dst = v;
2388 }
2389 }
2390 }
2391 let mut n_clamped = 0;
2392 for (i, v) in sp.x.iter_mut().enumerate() {
2393 if let (Some(&lo), Some(&hi)) = (s.bounds.x_l.get(i), s.bounds.x_u.get(i))
2394 && let Some(c) = crate::warm::clamp_seed(*v, lo, hi)
2395 {
2396 *v = c;
2397 n_clamped += 1;
2398 }
2399 }
2400 x_fixed_overridden_count = Some(n_fixed);
2401 x_clamped_count = Some(n_clamped);
2402 }
2403 // Phase 4: overlay presolve hints onto any zero/unset
2404 // entries. User-supplied warm-start values always win.
2405 if sp.init_z && self.opts.warm_z_bounds {
2406 for (i, &hint) in s.z_l_warm.iter().enumerate() {
2407 if hint > 0.0 && sp.z_l[i] <= 0.0 {
2408 sp.z_l[i] = hint;
2409 }
2410 }
2411 for (i, &hint) in s.z_u_warm.iter().enumerate() {
2412 if hint > 0.0 && sp.z_u[i] <= 0.0 {
2413 sp.z_u[i] = hint;
2414 }
2415 }
2416 }
2417 for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
2418 sp.lambda[outer] = lambda_full[i_inner];
2419 }
2420 let n_dropped_rows = m_in.saturating_sub(s.rows_kept.len());
2421 let dropped_dual_l1 = sp.init_lambda.then(|| {
2422 let mut kept = vec![false; m_in];
2423 for &inner in &s.rows_kept {
2424 if inner < kept.len() {
2425 kept[inner] = true;
2426 }
2427 }
2428 lambda_full
2429 .iter()
2430 .enumerate()
2431 .filter(|(i, _)| !kept[*i])
2432 .map(|(_, value)| value.abs())
2433 .sum()
2434 });
2435 self.starting_point_projection_report.n_dropped_rows = n_dropped_rows;
2436 if let Some(value) = dropped_dual_l1 {
2437 self.starting_point_projection_report.dropped_dual_l1 = value;
2438 }
2439 if let Some(value) = x_fixed_overridden_count {
2440 self.starting_point_projection_report
2441 .x_fixed_overridden_count = value;
2442 }
2443 if let Some(value) = x_clamped_count {
2444 self.starting_point_projection_report.x_clamped_count = value;
2445 }
2446 // Record what was served, so `finalize_solution` can recognise it
2447 // coming back unchanged. An entry the solver ignores (`init_z` /
2448 // `init_lambda` off) is recorded as NaN, i.e. "not a seed".
2449 let served = handed.map(|(z_l_in, z_u_in, lambda_in)| warm_echo::ServedWarmStart {
2450 z_l_in,
2451 z_u_in,
2452 lambda_in,
2453 z_l_out: warm_echo::served_bound_multipliers(
2454 sp.init_z,
2455 sp.z_l,
2456 &s.bounds.x_l,
2457 &s.bounds.x_u,
2458 ),
2459 z_u_out: warm_echo::served_bound_multipliers(
2460 sp.init_z,
2461 sp.z_u,
2462 &s.bounds.x_l,
2463 &s.bounds.x_u,
2464 ),
2465 lambda_out: warm_echo::served_row_multipliers(sp.init_lambda, sp.lambda),
2466 });
2467 self.served_warm_start = served;
2468 true
2469 }
2470
2471 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
2472 self.inner.borrow_mut().eval_f(x, new_x)
2473 }
2474
2475 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
2476 let ok = self.inner.borrow_mut().eval_grad_f(x, new_x, grad_f);
2477 if ok && self.live_record.enabled(&self.inner) {
2478 self.live_record.grad_f(x, grad_f);
2479 }
2480 ok
2481 }
2482
2483 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
2484 let Some(_) = self.ensure_init() else {
2485 return false;
2486 };
2487 let s = self.state.as_mut().expect("inited");
2488 if !self.inner.borrow_mut().eval_g(x, new_x, &mut s.scratch_g) {
2489 return false;
2490 }
2491 if self.live_record.enabled(&self.inner) {
2492 self.live_record.g(x, &s.scratch_g);
2493 }
2494 for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
2495 g[outer] = s.scratch_g[i_inner];
2496 }
2497 true
2498 }
2499
2500 fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
2501 let Some(_) = self.ensure_init() else {
2502 return false;
2503 };
2504 match mode {
2505 SparsityRequest::Structure { irow, jcol } => {
2506 let s = self.state.as_ref().expect("inited");
2507 irow.copy_from_slice(&s.jac_irow_outer);
2508 jcol.copy_from_slice(&s.jac_jcol_outer);
2509 true
2510 }
2511 SparsityRequest::Values { values } => {
2512 let s = self.state.as_mut().expect("inited");
2513 if !self.inner.borrow_mut().eval_jac_g(
2514 x,
2515 new_x,
2516 SparsityRequest::Values {
2517 values: &mut s.scratch_jac,
2518 },
2519 ) {
2520 return false;
2521 }
2522 if let Some(x) = x
2523 && self.live_record.enabled(&self.inner)
2524 {
2525 self.live_record.jac(x, &s.scratch_jac);
2526 }
2527 for (outer_k, &inner_k) in s.jac_kept_idx.iter().enumerate() {
2528 values[outer_k] = s.scratch_jac[inner_k];
2529 }
2530 true
2531 }
2532 }
2533 }
2534
2535 fn eval_h(
2536 &mut self,
2537 x: Option<&[Number]>,
2538 new_x: bool,
2539 obj_factor: Number,
2540 lambda: Option<&[Number]>,
2541 new_lambda: bool,
2542 mode: SparsityRequest<'_>,
2543 ) -> bool {
2544 let Some(_) = self.ensure_init() else {
2545 return false;
2546 };
2547 // Hessian sparsity is untouched: linear rows (the only ones
2548 // we drop) contribute zero. Forward `lambda` after expanding
2549 // outer → inner with zeros at dropped rows.
2550 let lambda_full_opt = if let Some(lam) = lambda {
2551 let s = self.state.as_mut().expect("inited");
2552 for v in s.scratch_lambda.iter_mut() {
2553 *v = 0.0;
2554 }
2555 for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
2556 s.scratch_lambda[i_inner] = lam[outer];
2557 }
2558 Some(&s.scratch_lambda[..])
2559 } else {
2560 None
2561 };
2562 // Re-borrow inner after dropping the state borrow.
2563 let lam_ref: Option<&[Number]> = lambda_full_opt;
2564 // SAFETY: `lam_ref` borrows from `self.state`'s scratch; the
2565 // call to `inner.borrow_mut()` does not touch `self.state`.
2566 self.inner
2567 .borrow_mut()
2568 .eval_h(x, new_x, obj_factor, lam_ref, new_lambda, mode)
2569 }
2570
2571 fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
2572 let Some(_) = self.ensure_init() else {
2573 // Init failed earlier — best effort: just forward as-is.
2574 self.inner
2575 .borrow_mut()
2576 .finalize_solution(sol, ip_data, ip_cq);
2577 return;
2578 };
2579 // Reconstruct inner-sized g and lambda.
2580 let (g_full, mut lambda_full, n_inner, m_inner, nnz_inner, one_based) = {
2581 let s = self.state.as_mut().expect("inited");
2582 // Recompute g at sol.x — the solver gave us reduced g.
2583 let ok_g = self
2584 .inner
2585 .borrow_mut()
2586 .eval_g(sol.x, true, &mut s.scratch_g);
2587 if !ok_g {
2588 // L45: the final constraint re-eval failed, so `scratch_g` is
2589 // unreliable — it holds whatever the failing call left behind
2590 // (partial garbage, or a stale value from an earlier iterate).
2591 // Don't forward that. Rebuild g from the solver's own
2592 // (trustworthy) reduced `sol.g`, mapped back to the kept inner
2593 // rows exactly as the multiplier mapping below does; rows
2594 // dropped by presolve are left at 0 (no reliable value exists
2595 // for them once the re-eval has failed).
2596 for v in s.scratch_g.iter_mut() {
2597 *v = 0.0;
2598 }
2599 for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
2600 if i_inner < s.scratch_g.len() && outer < sol.g.len() {
2601 s.scratch_g[i_inner] = sol.g[outer];
2602 }
2603 }
2604 }
2605 for v in s.scratch_lambda.iter_mut() {
2606 *v = 0.0;
2607 }
2608 for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
2609 s.scratch_lambda[i_inner] = sol.lambda[outer];
2610 }
2611 (
2612 s.scratch_g.clone(),
2613 s.scratch_lambda.clone(),
2614 s.info_inner.n as usize,
2615 s.info_inner.m as usize,
2616 s.info_inner.nnz_jac_g as usize,
2617 matches!(s.info_inner.index_style, IndexStyle::Fortran),
2618 )
2619 };
2620
2621 // A warm start the solver handed back without moving its duals: the
2622 // original problem's duals are the ones the caller supplied, not a
2623 // re-derivation from stationarity at the pushed `x` (`warm_echo`).
2624 if let Some((lambda_e, z_l_e, z_u_e)) = self.echoed_warm_start(&sol) {
2625 tracing::debug!(
2626 target: "pounce_presolve",
2627 "presolve postsolve: the solver returned the served warm start unchanged; \
2628 reporting the supplied duals"
2629 );
2630 self.bound_multipliers_handed_to_rows.clear();
2631 self.derived_bound_recovery = None;
2632 self.forward_finalized(
2633 sol,
2634 &g_full,
2635 &lambda_e,
2636 &lambda_full,
2637 &z_l_e,
2638 &z_u_e,
2639 ip_data,
2640 ip_cq,
2641 );
2642 return;
2643 }
2644
2645 // Phase-0 (issue #53) multiplier recovery for dropped rows.
2646 // Walk the reduction stack top-down; for each frame, compute
2647 // the full-space ∇f and J at sol.x, then solve the k×k LU
2648 // system to recover λ for the frame's dropped rows. Splice
2649 // the result into `lambda_full` at the dropped indices.
2650 let frames: Vec<reduction_frame::ReductionFrame> = {
2651 let s = self.state.as_ref().expect("inited");
2652 s.reduction_stack.iter_top_down().cloned().collect()
2653 };
2654 // Bound multipliers forwarded to the inner finalize. At each frame's
2655 // Phase-0 aux-fixed variables these are zeroed once the variable's
2656 // dropped-row multipliers are recovered below:
2657 // `recover_dropped_multipliers` folds the entire stationarity residual
2658 // into λ under the documented assumption `z_l = z_u = 0` there, so
2659 // forwarding the IPM's clamp multipliers unchanged would double-count
2660 // that contribution and break `∇f − Jᵀλ − z_l + z_u = 0`.
2661 let mut z_l_full = sol.z_l.to_vec();
2662 let mut z_u_full = sol.z_u.to_vec();
2663 if !frames.is_empty() && m_inner > 0 {
2664 let mut grad_f = vec![0.0; n_inner];
2665 let ok_grad = self
2666 .inner
2667 .borrow_mut()
2668 .eval_grad_f(sol.x, true, &mut grad_f);
2669 // Sparsity + values for the full inner Jacobian.
2670 let mut jac_irow_inner = vec![0 as Index; nnz_inner];
2671 let mut jac_jcol_inner = vec![0 as Index; nnz_inner];
2672 let ok_struct = if nnz_inner > 0 {
2673 self.inner.borrow_mut().eval_jac_g(
2674 None,
2675 false,
2676 SparsityRequest::Structure {
2677 irow: &mut jac_irow_inner,
2678 jcol: &mut jac_jcol_inner,
2679 },
2680 )
2681 } else {
2682 true
2683 };
2684 let mut jac_values = vec![0.0; nnz_inner];
2685 let ok_vals = if nnz_inner > 0 {
2686 self.inner.borrow_mut().eval_jac_g(
2687 Some(sol.x),
2688 false,
2689 SparsityRequest::Values {
2690 values: &mut jac_values,
2691 },
2692 )
2693 } else {
2694 true
2695 };
2696 if ok_grad && ok_struct && ok_vals {
2697 // `recover_dropped_multipliers` reads the Jacobian only at the
2698 // frames' fixed-var columns, so materialize just those columns
2699 // (the union across all frames) instead of the full
2700 // `m_inner × n_inner` dense block — O(m·k) rather than O(m·n),
2701 // where k = total distinct fixed vars is tiny next to n. The
2702 // old full densification cost ~80 GB at 100k×100k (issue M26).
2703 let mut orig_to_compact = vec![usize::MAX; n_inner];
2704 let mut n_cols = 0usize;
2705 for frame in &frames {
2706 for &c in &frame.fixed_vars {
2707 if c < n_inner && orig_to_compact[c] == usize::MAX {
2708 orig_to_compact[c] = n_cols;
2709 n_cols += 1;
2710 }
2711 }
2712 }
2713 let mut jac_cols = vec![0.0; m_inner * n_cols];
2714 for k in 0..nnz_inner {
2715 let i = if one_based {
2716 (jac_irow_inner[k] as isize - 1) as usize
2717 } else {
2718 jac_irow_inner[k] as usize
2719 };
2720 let j = if one_based {
2721 (jac_jcol_inner[k] as isize - 1) as usize
2722 } else {
2723 jac_jcol_inner[k] as usize
2724 };
2725 if i < m_inner && j < n_inner {
2726 let cc = orig_to_compact[j];
2727 if cc != usize::MAX {
2728 jac_cols[i * n_cols + cc] = jac_values[k];
2729 }
2730 }
2731 }
2732 for frame in &frames {
2733 if let Ok(lam_dropped) = frame.recover_dropped_multipliers_cols(
2734 &grad_f,
2735 &jac_cols,
2736 n_cols,
2737 &orig_to_compact,
2738 &lambda_full,
2739 ) {
2740 for (idx, &r) in frame.dropped_rows.iter().enumerate() {
2741 lambda_full[r] = lam_dropped[idx];
2742 }
2743 // The recovered λ now carries this frame's fixed-var
2744 // stationarity; drop the IPM's clamp multipliers there.
2745 for &i in &frame.fixed_vars {
2746 if i < z_l_full.len() {
2747 z_l_full[i] = 0.0;
2748 }
2749 if i < z_u_full.len() {
2750 z_u_full[i] = 0.0;
2751 }
2752 }
2753 }
2754 }
2755 }
2756 }
2757 let lambda_lifted = lambda_full.clone();
2758 // M24: a bound that exists only because a dropped singleton row
2759 // implied it hands its multiplier back to that row.
2760 self.bound_multipliers_handed_to_rows.clear();
2761 {
2762 let s = self.state.as_ref().expect("inited");
2763 for sb in &s.singleton_bounds {
2764 let side = if sb.upper {
2765 &mut z_u_full
2766 } else {
2767 &mut z_l_full
2768 };
2769 let (Some(z), Some(lam)) = (side.get_mut(sb.var), lambda_full.get_mut(sb.row))
2770 else {
2771 continue;
2772 };
2773 if !z.is_finite() || *z == 0.0 {
2774 continue;
2775 }
2776 let signed = if sb.upper { *z } else { -*z };
2777 *lam += signed / sb.coeff;
2778 *z = 0.0;
2779 self.bound_multipliers_handed_to_rows
2780 .push((sb.var, sb.upper));
2781 }
2782 }
2783 // M24, derived bounds: a multiplier the solver put on a bound
2784 // presolve derived from rows, where the original model has no
2785 // active bound, is not a multiplier of the original problem.
2786 // Recover the original problem's in its place (`dual_recovery`).
2787 self.derived_bound_recovery = self.recover_derived_bound_duals(
2788 sol.x,
2789 &g_full,
2790 &mut lambda_full,
2791 &mut z_l_full,
2792 &mut z_u_full,
2793 );
2794 self.forward_finalized(
2795 sol,
2796 &g_full,
2797 &lambda_full,
2798 &lambda_lifted,
2799 &z_l_full,
2800 &z_u_full,
2801 ip_data,
2802 ip_cq,
2803 );
2804 }
2805
2806 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
2807 let Some(_) = self.ensure_init() else {
2808 return false;
2809 };
2810 // Variable count is unchanged by presolve, so var metadata
2811 // flows through. Constraint metadata is per-inner-row; if we
2812 // dropped rows, subset the per-row vectors to kept rows.
2813 let mut inner_var = MetaData::default();
2814 let mut inner_con = MetaData::default();
2815 if !self
2816 .inner
2817 .borrow_mut()
2818 .get_var_con_metadata(&mut inner_var, &mut inner_con)
2819 {
2820 return false;
2821 }
2822 *var = inner_var;
2823 let s = self.state.as_ref().expect("inited");
2824 let m_in = s.info_inner.m as usize;
2825 *con = project_con_metadata(&inner_con, &s.rows_kept, m_in);
2826 true
2827 }
2828
2829 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
2830 let Some(_) = self.ensure_init() else {
2831 return false;
2832 };
2833 let s = self.state.as_ref().expect("inited");
2834 let m_in = s.info_inner.m as usize;
2835 // Allocate inner-sized g_scaling and forward.
2836 let mut inner_g = vec![1.0; m_in];
2837 let mut use_x = false;
2838 let mut use_g = false;
2839 let mut obj_scaling = 1.0;
2840 let inner_x_scaling_len = req.x_scaling.len();
2841 let mut inner_x = vec![1.0; inner_x_scaling_len];
2842 let ok = self
2843 .inner
2844 .borrow_mut()
2845 .get_scaling_parameters(ScalingRequest {
2846 obj_scaling: &mut obj_scaling,
2847 use_x_scaling: &mut use_x,
2848 x_scaling: &mut inner_x,
2849 use_g_scaling: &mut use_g,
2850 g_scaling: &mut inner_g,
2851 });
2852 if !ok {
2853 return false;
2854 }
2855 *req.obj_scaling = obj_scaling;
2856 *req.use_x_scaling = use_x;
2857 *req.use_g_scaling = use_g;
2858 req.x_scaling.copy_from_slice(&inner_x);
2859 for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
2860 req.g_scaling[outer] = inner_g[i_inner];
2861 }
2862 true
2863 }
2864
2865 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
2866 self.inner.borrow_mut().get_variables_linearity(types)
2867 }
2868
2869 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
2870 self.inner
2871 .borrow_mut()
2872 .get_objective_variables_linearity(types)
2873 }
2874
2875 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
2876 let Some(_) = self.ensure_init() else {
2877 return false;
2878 };
2879 let m_in = self.state.as_ref().expect("inited").info_inner.m as usize;
2880 let mut full = vec![Linearity::NonLinear; m_in];
2881 if !self.inner.borrow_mut().get_constraints_linearity(&mut full) {
2882 return false;
2883 }
2884 let s = self.state.as_ref().expect("inited");
2885 for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
2886 types[outer] = full[i_inner];
2887 }
2888 true
2889 }
2890
2891 fn get_number_of_nonlinear_variables(&mut self) -> Index {
2892 self.inner.borrow_mut().get_number_of_nonlinear_variables()
2893 }
2894
2895 fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
2896 self.inner
2897 .borrow_mut()
2898 .get_list_of_nonlinear_variables(pos_nonlin_vars)
2899 }
2900
2901 fn wants_intermediate_callback(&self) -> bool {
2902 self.inner.borrow().wants_intermediate_callback()
2903 }
2904
2905 fn wants_current_iterate(&self) -> bool {
2906 self.inner.borrow().wants_current_iterate()
2907 }
2908
2909 fn intermediate_callback(
2910 &mut self,
2911 stats: IterStats,
2912 ip_data: &IpoptData,
2913 ip_cq: &IpoptCq,
2914 ) -> bool {
2915 // The live iterate arrives in this layer's (row-dropped) space;
2916 // hand the inner TNLP its own, lifted by `finalize_solution`'s
2917 // rule. The lift runs that function, so the state it records about
2918 // the *final* solution is put back afterwards.
2919 let lifted = match self.get_nlp_info() {
2920 Some(info) => {
2921 // Every field `finalize_solution` writes, including the
2922 // derived-bound dual recovery's, so a lift never leaves a
2923 // live iterate's record where the final solution's belongs.
2924 let saved = (
2925 self.finalized_full_solution.take(),
2926 std::mem::take(&mut self.bound_multipliers_handed_to_rows),
2927 std::mem::take(&mut self.bound_multiplier_overrides),
2928 self.duals_rewritten,
2929 self.derived_bound_recovery.take(),
2930 );
2931 // Columns are the inner ones; outer row `k` is inner
2932 // `rows_kept[k]`.
2933 let cols: Vec<usize> = (0..info.n as usize).collect();
2934 let rows: Vec<usize> = self
2935 .state
2936 .as_ref()
2937 .map(|s| s.rows_kept.clone())
2938 .unwrap_or_else(|| (0..info.m as usize).collect());
2939 let lifted = live_lift::lift(
2940 self,
2941 |s| &mut s.inner,
2942 (info.n as usize, info.m as usize),
2943 // Every column is kept, so no declared bound is needed.
2944 live_lift::LiftMaps {
2945 cols: &cols,
2946 rows: &rows,
2947 declared_x_l: &[],
2948 declared_x_u: &[],
2949 record: self.live_record.clone(),
2950 },
2951 );
2952 (
2953 self.finalized_full_solution,
2954 self.bound_multipliers_handed_to_rows,
2955 self.bound_multiplier_overrides,
2956 self.duals_rewritten,
2957 self.derived_bound_recovery,
2958 ) = saved;
2959 lifted
2960 }
2961 None => None,
2962 };
2963 let _lifted = lifted.map(pounce_nlp::live_iterate::UserIterateGuard::install);
2964 self.inner
2965 .borrow_mut()
2966 .intermediate_callback(stats, ip_data, ip_cq)
2967 }
2968
2969 fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
2970 let Some(_) = self.ensure_init() else {
2971 self.inner.borrow_mut().finalize_metadata(var, con);
2972 return;
2973 };
2974 let s = self.state.as_ref().expect("inited");
2975 let m_in = s.info_inner.m as usize;
2976 let con_full = expand_con_metadata(con, &s.rows_kept, m_in);
2977 self.inner.borrow_mut().finalize_metadata(var, &con_full);
2978 }
2979}
2980
2981/// Subset every per-row vector of `inner` to the rows in `rows_kept`.
2982///
2983/// Per-row-ness is inferred from `v.len() == m_in`, because `MetaData`
2984/// (mirroring upstream Ipopt's untyped `*MetaDataMapType`) carries no
2985/// per-key arity tag — so the length is the only signal available. By
2986/// the TNLP contract every vector in the *constraint* `MetaData` bucket
2987/// is per-constraint (length `m_in`), so this is exact for any
2988/// conforming inner TNLP (e.g. `nl_reader`'s per-row `con_names`).
2989///
2990/// L46 caveat: a vector that is semantically *global* yet happens to
2991/// have length `m_in` would be wrongly subset, and a genuinely per-row
2992/// vector whose length differs from `m_in` (a contract violation) is
2993/// passed through unchanged. Neither can be distinguished from the data
2994/// alone; fully resolving it needs an arity-aware metadata API. The
2995/// fast path `m_in == rows_kept.len()` (no rows dropped) is identity,
2996/// so the hazard only exists when presolve actually drops a row.
2997fn project_con_metadata(inner: &MetaData, rows_kept: &[usize], m_in: usize) -> MetaData {
2998 let mut out = MetaData::default();
2999 for (k, v) in &inner.strings {
3000 out.strings.insert(
3001 k.clone(),
3002 if v.len() == m_in {
3003 rows_kept.iter().map(|&i| v[i].clone()).collect()
3004 } else {
3005 v.clone()
3006 },
3007 );
3008 }
3009 for (k, v) in &inner.integers {
3010 out.integers.insert(
3011 k.clone(),
3012 if v.len() == m_in {
3013 rows_kept.iter().map(|&i| v[i]).collect()
3014 } else {
3015 v.clone()
3016 },
3017 );
3018 }
3019 for (k, v) in &inner.numerics {
3020 out.numerics.insert(
3021 k.clone(),
3022 if v.len() == m_in {
3023 rows_kept.iter().map(|&i| v[i]).collect()
3024 } else {
3025 v.clone()
3026 },
3027 );
3028 }
3029 out
3030}
3031
3032/// Expand every per-(outer-row) vector back to `m_in` rows by
3033/// inserting empty / 0 / 0.0 defaults at dropped rows. The inverse of
3034/// [`project_con_metadata`]; per-row-ness is inferred from
3035/// `v.len() == m_out` and carries the same L46 caveat (see that
3036/// function): a coincidentally-`m_out`-length global vector is wrongly
3037/// expanded, inherent to the untyped `MetaData` API.
3038fn expand_con_metadata(outer: &MetaData, rows_kept: &[usize], m_in: usize) -> MetaData {
3039 let m_out = rows_kept.len();
3040 let mut full = MetaData::default();
3041 for (k, v) in &outer.strings {
3042 let mut buf: Vec<String> = vec![String::new(); m_in];
3043 if v.len() == m_out {
3044 for (outer_i, val) in v.iter().enumerate() {
3045 buf[rows_kept[outer_i]] = val.clone();
3046 }
3047 full.strings.insert(k.clone(), buf);
3048 } else {
3049 full.strings.insert(k.clone(), v.clone());
3050 }
3051 }
3052 for (k, v) in &outer.integers {
3053 let mut buf: Vec<Index> = vec![0; m_in];
3054 if v.len() == m_out {
3055 for (outer_i, &val) in v.iter().enumerate() {
3056 buf[rows_kept[outer_i]] = val;
3057 }
3058 full.integers.insert(k.clone(), buf);
3059 } else {
3060 full.integers.insert(k.clone(), v.clone());
3061 }
3062 }
3063 for (k, v) in &outer.numerics {
3064 let mut buf: Vec<Number> = vec![0.0; m_in];
3065 if v.len() == m_out {
3066 for (outer_i, &val) in v.iter().enumerate() {
3067 buf[rows_kept[outer_i]] = val;
3068 }
3069 full.numerics.insert(k.clone(), buf);
3070 } else {
3071 full.numerics.insert(k.clone(), v.clone());
3072 }
3073 }
3074 full
3075}
3076
3077/// Re-export for callers that already imported
3078/// `pounce_presolve::register_options` directly.
3079pub fn register(reg: &RegisteredOptions) -> Result<(), SolverException> {
3080 register_options(reg)
3081}
3082
3083#[cfg(test)]
3084mod tests {
3085 use super::*;
3086
3087 /// **gh #402.** A crossing between an absent bound and a real one past the
3088 /// *opposite* sentinel is not a crossing, and must never certify.
3089 ///
3090 /// The test was a bare `l > u`, so `x_l = -1e19` (absent) against
3091 /// `x_u = -5e20` (real) read as a `5e20` gap — far too large for
3092 /// `is_negligible` to dismiss — and certified a feasible model as *proved*
3093 /// infeasible, the strongest claim POUNCE makes.
3094 #[test]
3095 fn a_sentinel_against_a_real_bound_never_certifies_a_crossing() {
3096 let tol = 1e-8;
3097 assert!(
3098 !crossing_is_certifiable(&[-1e19], &[-5e20], tol),
3099 "`x <= -5e20` with no lower bound is feasible, not a crossed box"
3100 );
3101 assert!(
3102 !crossing_is_certifiable(&[5e20], &[1e19], tol),
3103 "`x >= 5e20` with no upper bound is feasible too"
3104 );
3105 // A genuine crossing between two present bounds still certifies.
3106 assert!(crossing_is_certifiable(&[5.0], &[3.0], tol));
3107 // And a sub-tolerance one still does not (the #380 rule).
3108 assert!(!crossing_is_certifiable(&[1.0 + 1e-13], &[1.0], tol));
3109 }
3110
3111 /// **gh #402.** The acceptance slack must track the row's own scale, even
3112 /// when the row's real bound lies past the opposite sentinel.
3113 ///
3114 /// The old symmetric test contributed `0.0` for a bound of `-5e20`, so the
3115 /// margin collapsed to `tol * 1.0` — a widening of `1e-8` on a row written
3116 /// at `5e20`. Any infeasibility survives that, which is the fail-open
3117 /// direction this margin exists to prevent.
3118 #[test]
3119 fn the_row_margin_tracks_a_bound_past_the_opposite_sentinel() {
3120 let tol = 1e-8;
3121 // `g <= -5e20`, no lower bound. Magnitude is 5e20, not 1.
3122 let m = row_margin_for(-1e19, -5e20, tol);
3123 assert!(
3124 (m - tol * 5e20).abs() <= tol * 5e20 * 1e-12,
3125 "margin should be tol*5e20 = {}, got {m}",
3126 tol * 5e20
3127 );
3128 // Absent on both sides -> the floor of 1.
3129 assert_eq!(row_margin_for(-1e19, 1e19, tol), tol);
3130 // An ordinary two-sided row uses the larger magnitude.
3131 assert_eq!(row_margin_for(-2.0, 7.0, tol), tol * 7.0);
3132 }
3133
3134 struct Probe;
3135 impl TNLP for Probe {
3136 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
3137 Some(NlpInfo {
3138 n: 1,
3139 m: 0,
3140 nnz_jac_g: 0,
3141 nnz_h_lag: 1,
3142 index_style: IndexStyle::C,
3143 })
3144 }
3145 fn get_bounds_info(&mut self, _b: BoundsInfo<'_>) -> bool {
3146 true
3147 }
3148 fn get_starting_point(&mut self, _sp: StartingPoint<'_>) -> bool {
3149 true
3150 }
3151 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
3152 Some(0.0)
3153 }
3154 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
3155 true
3156 }
3157 fn eval_g(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
3158 true
3159 }
3160 fn eval_jac_g(
3161 &mut self,
3162 _x: Option<&[Number]>,
3163 _new_x: bool,
3164 _mode: SparsityRequest<'_>,
3165 ) -> bool {
3166 true
3167 }
3168 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
3169 }
3170
3171 struct NoopProvider;
3172 impl ExpressionProvider for NoopProvider {}
3173
3174 #[test]
3175 fn c1_redundancy_mask_realigned_after_phase0_drop() {
3176 // Regression for C1. `find_redundant_rows` returns a verdict
3177 // mask aligned to the *kept* linear rows (inner-row order).
3178 // When Phase 0 has dropped an earlier linear row, the mapping
3179 // back to inner rows must skip already-dropped rows; advancing
3180 // the mask iterator on them shifts every later verdict onto its
3181 // predecessor's row.
3182 let lr = |lo: Number| {
3183 Some(LinearRow {
3184 entries: vec![(0, 1.0)],
3185 lo,
3186 hi: lo,
3187 })
3188 };
3189 // Three linear rows; Phase 0 dropped inner row 0.
3190 let linear_row_map = vec![lr(0.0), lr(1.0), lr(2.0)];
3191 let row_kept = vec![false, true, true];
3192 // `find_redundant_rows` ran over the *kept* rows [row1, row2]
3193 // and flagged the SECOND kept row (inner row 2) redundant; the
3194 // first kept row (inner row 1) is binding.
3195 let mask = vec![false, true];
3196
3197 // Correct mapping: only inner row 2 is dropped.
3198 let mut kept_new = row_kept.clone();
3199 let n = apply_redundant_verdicts(&linear_row_map, &mask, &mut kept_new);
3200 assert_eq!(
3201 kept_new,
3202 vec![false, true, false],
3203 "verdict must land on inner row 2, not its predecessor"
3204 );
3205 assert_eq!(n, 1);
3206
3207 // Document the pre-fix misalignment this test guards against:
3208 // the old loop advanced the mask on every `Some` row regardless
3209 // of `row_kept_inner`, dropping inner row 1 (binding!) and
3210 // keeping inner row 2 (redundant) — a silent wrong answer.
3211 let mut buggy = row_kept.clone();
3212 let mut it = mask.iter();
3213 for (i, l) in linear_row_map.iter().enumerate() {
3214 if l.is_some() && *it.next().unwrap_or(&false) {
3215 buggy[i] = false;
3216 }
3217 }
3218 assert_eq!(buggy, vec![false, false, true]);
3219 assert_ne!(buggy, kept_new);
3220 }
3221
3222 #[test]
3223 fn disabled_returns_inner_unchanged() {
3224 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
3225 let opts = PresolveOptions {
3226 enabled: false,
3227 ..PresolveOptions::defaults()
3228 };
3229 let wrapped = wrap_with_presolve(Rc::clone(&inner), opts).unwrap();
3230 assert!(Rc::ptr_eq(&inner, &wrapped));
3231 }
3232
3233 #[test]
3234 fn enabled_wraps_and_forwards() {
3235 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
3236 let opts = PresolveOptions {
3237 enabled: true,
3238 ..PresolveOptions::defaults()
3239 };
3240 let wrapped = wrap_with_presolve(Rc::clone(&inner), opts).unwrap();
3241 assert!(!Rc::ptr_eq(&inner, &wrapped));
3242 let info = wrapped.borrow_mut().get_nlp_info().unwrap();
3243 assert_eq!(info.n, 1);
3244 assert_eq!(info.m, 0);
3245 }
3246
3247 #[test]
3248 fn provider_wrapper_does_not_nest_an_existing_presolve_wrapper() {
3249 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
3250 let opts = PresolveOptions {
3251 enabled: true,
3252 ..PresolveOptions::defaults()
3253 };
3254 let manual = wrap_with_presolve(inner, opts.clone()).unwrap();
3255 let provider: Rc<RefCell<dyn ExpressionProvider>> = Rc::new(RefCell::new(NoopProvider));
3256 let wrapped = wrap_with_presolve_provider(manual.clone(), provider, opts).unwrap();
3257
3258 assert!(Rc::ptr_eq(&manual, &wrapped));
3259 }
3260
3261 #[test]
3262 fn register_options_roundtrip() {
3263 let reg = RegisteredOptions::default();
3264 register_options(®).unwrap();
3265 let opt = reg.get_option("presolve").expect("presolve registered");
3266 assert_eq!(opt.name, "presolve");
3267 }
3268
3269 #[test]
3270 fn auxiliary_phase0_noop_when_disabled() {
3271 // Master switch off → Phase 0 returns zero diagnostics and
3272 // does not perturb the inner problem's dimensions.
3273 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
3274 let opts = PresolveOptions {
3275 enabled: true,
3276 auxiliary: false,
3277 ..PresolveOptions::defaults()
3278 };
3279 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3280 let info = wrapped.get_nlp_info().expect("init ok");
3281 assert_eq!(info.n, 1);
3282 assert_eq!(info.m, 0);
3283 let diag = wrapped.auxiliary_diagnostics();
3284 assert_eq!(diag.blocks_eliminated, 0);
3285 assert_eq!(diag.vars_eliminated, 0);
3286 assert_eq!(diag.rows_eliminated, 0);
3287 }
3288
3289 #[test]
3290 fn auxiliary_phase0_noop_when_enabled_no_algos_yet() {
3291 // For a 1-var, 0-row TNLP there are no candidate blocks; the
3292 // orchestrator skips its work regardless of master-switch state.
3293 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
3294 let opts = PresolveOptions {
3295 enabled: true,
3296 auxiliary: true,
3297 auxiliary_coupling: AuxiliaryCouplingPolicy::Aggressive,
3298 ..PresolveOptions::defaults()
3299 };
3300 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3301 let info = wrapped.get_nlp_info().expect("init ok");
3302 assert_eq!(info.n, 1);
3303 assert_eq!(info.m, 0);
3304 let diag = wrapped.auxiliary_diagnostics();
3305 assert_eq!(diag.blocks_eliminated, 0);
3306 assert!(diag.rejection_reasons.is_empty());
3307 }
3308
3309 /// 2-variable, 2-equality TNLP that PR 8's orchestrator should
3310 /// reduce: `x + y = 3, x - y = 1` → unique solution `(2, 1)`.
3311 /// Zero objective gradient → PureEquality, eligible under Safe.
3312 struct TwoVarSquareEq;
3313 impl TNLP for TwoVarSquareEq {
3314 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
3315 Some(NlpInfo {
3316 n: 2,
3317 m: 2,
3318 nnz_jac_g: 4,
3319 nnz_h_lag: 0,
3320 index_style: IndexStyle::C,
3321 })
3322 }
3323 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
3324 for v in b.x_l.iter_mut() {
3325 *v = -1e19;
3326 }
3327 for v in b.x_u.iter_mut() {
3328 *v = 1e19;
3329 }
3330 b.g_l[0] = 3.0;
3331 b.g_u[0] = 3.0;
3332 b.g_l[1] = 1.0;
3333 b.g_u[1] = 1.0;
3334 true
3335 }
3336 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
3337 if sp.init_x {
3338 sp.x[0] = 0.0;
3339 sp.x[1] = 0.0;
3340 }
3341 true
3342 }
3343 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
3344 Some(0.0)
3345 }
3346 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3347 for v in g.iter_mut() {
3348 *v = 0.0;
3349 }
3350 true
3351 }
3352 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3353 g[0] = x[0] + x[1];
3354 g[1] = x[0] - x[1];
3355 true
3356 }
3357 fn eval_jac_g(
3358 &mut self,
3359 _x: Option<&[Number]>,
3360 _new_x: bool,
3361 mode: SparsityRequest<'_>,
3362 ) -> bool {
3363 match mode {
3364 SparsityRequest::Structure { irow, jcol } => {
3365 irow.copy_from_slice(&[0, 0, 1, 1]);
3366 jcol.copy_from_slice(&[0, 1, 0, 1]);
3367 }
3368 SparsityRequest::Values { values } => {
3369 values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0]);
3370 }
3371 }
3372 true
3373 }
3374 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
3375 types[0] = Linearity::Linear;
3376 types[1] = Linearity::Linear;
3377 true
3378 }
3379 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
3380 }
3381
3382 #[test]
3383 fn phase0_via_tnlp_eliminates_square_block() {
3384 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
3385 let opts = PresolveOptions {
3386 enabled: true,
3387 auxiliary: true,
3388 auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
3389 ..PresolveOptions::defaults()
3390 };
3391 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3392 wrapped.set_project_seed(true);
3393 let info = wrapped.get_nlp_info().expect("init ok");
3394 // Variable count unchanged (clamp, not reduce).
3395 assert_eq!(info.n, 2);
3396 // Both equality rows dropped.
3397 assert_eq!(info.m, 0);
3398
3399 let diag = wrapped.auxiliary_diagnostics();
3400 assert_eq!(diag.blocks_eliminated, 1);
3401 assert_eq!(diag.vars_eliminated, 2);
3402 assert_eq!(diag.rows_eliminated, 2);
3403
3404 let bounds = wrapped.cached_bounds().expect("inited");
3405 assert!((bounds.x_l[0] - 2.0).abs() < 1e-12);
3406 assert!((bounds.x_u[0] - 2.0).abs() < 1e-12);
3407 assert!((bounds.x_l[1] - 1.0).abs() < 1e-12);
3408 assert!((bounds.x_u[1] - 1.0).abs() < 1e-12);
3409 }
3410
3411 /// [`TwoVarSquareEq`] with linearity tags layered on: the *global*
3412 /// tags report both variables `NonLinear` (as a TNLP whose equality
3413 /// rows are nonlinear would — the gas-network shape), while the
3414 /// objective-scoped tags, when offered, report both `Linear`
3415 /// (matching the zero objective gradient).
3416 struct SquareEqWithTags {
3417 inner: TwoVarSquareEq,
3418 offer_obj_tags: bool,
3419 }
3420 impl TNLP for SquareEqWithTags {
3421 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
3422 self.inner.get_nlp_info()
3423 }
3424 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
3425 self.inner.get_bounds_info(b)
3426 }
3427 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
3428 self.inner.get_starting_point(sp)
3429 }
3430 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
3431 self.inner.eval_f(x, new_x)
3432 }
3433 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
3434 self.inner.eval_grad_f(x, new_x, g)
3435 }
3436 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
3437 self.inner.eval_g(x, new_x, g)
3438 }
3439 fn eval_jac_g(
3440 &mut self,
3441 x: Option<&[Number]>,
3442 new_x: bool,
3443 mode: SparsityRequest<'_>,
3444 ) -> bool {
3445 self.inner.eval_jac_g(x, new_x, mode)
3446 }
3447 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
3448 self.inner.get_constraints_linearity(types)
3449 }
3450 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
3451 types[0] = Linearity::NonLinear;
3452 types[1] = Linearity::NonLinear;
3453 true
3454 }
3455 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
3456 if !self.offer_obj_tags {
3457 return false;
3458 }
3459 types[0] = Linearity::Linear;
3460 types[1] = Linearity::Linear;
3461 true
3462 }
3463 fn finalize_solution(&mut self, sol: Solution<'_>, d: &IpoptData, q: &IpoptCq) {
3464 self.inner.finalize_solution(sol, d, q)
3465 }
3466 }
3467
3468 /// Regression for the gas-network CI failure: objective-scoped tags
3469 /// must take precedence over the global ones, so a variable that is
3470 /// nonlinear only in *constraints* does not poison `obj_support` and
3471 /// block a legitimate Phase-0 elimination. Pre-fix (global tags only)
3472 /// both variables were unioned into the objective support and the
3473 /// Safe policy kept both rows.
3474 #[test]
3475 fn phase0_objective_scoped_tags_dont_block_constraint_nonlinear_elimination() {
3476 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SquareEqWithTags {
3477 inner: TwoVarSquareEq,
3478 offer_obj_tags: true,
3479 }));
3480 let opts = PresolveOptions {
3481 enabled: true,
3482 auxiliary: true,
3483 auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
3484 ..PresolveOptions::defaults()
3485 };
3486 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3487 let info = wrapped.get_nlp_info().expect("init ok");
3488 assert_eq!(
3489 info.m, 0,
3490 "objective-free block must still be eliminated when only the \
3491 global tags say NonLinear"
3492 );
3493 assert_eq!(wrapped.auxiliary_diagnostics().vars_eliminated, 2);
3494 }
3495
3496 /// When the TNLP offers only the global tags, the fallback stays
3497 /// conservative: constraint nonlinearity is treated as possible
3498 /// objective coupling and the block is kept (sound, just suboptimal).
3499 #[test]
3500 fn phase0_global_tags_fallback_remains_conservative() {
3501 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SquareEqWithTags {
3502 inner: TwoVarSquareEq,
3503 offer_obj_tags: false,
3504 }));
3505 let opts = PresolveOptions {
3506 enabled: true,
3507 auxiliary: true,
3508 auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
3509 ..PresolveOptions::defaults()
3510 };
3511 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3512 let info = wrapped.get_nlp_info().expect("init ok");
3513 assert_eq!(
3514 info.m, 2,
3515 "global-only tags fall back to the conservative union"
3516 );
3517 assert_eq!(wrapped.auxiliary_diagnostics().vars_eliminated, 0);
3518 }
3519
3520 #[test]
3521 fn phase0_via_tnlp_disabled_is_pass_through() {
3522 // Same inner TNLP, but presolve_auxiliary=no. Orchestrator
3523 // is byte-identical to today; both rows remain.
3524 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
3525 let opts = PresolveOptions {
3526 enabled: true,
3527 auxiliary: false,
3528 ..PresolveOptions::defaults()
3529 };
3530 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3531 let info = wrapped.get_nlp_info().expect("init ok");
3532 assert_eq!(info.n, 2);
3533 assert_eq!(info.m, 2);
3534 let diag = wrapped.auxiliary_diagnostics();
3535 assert_eq!(diag.blocks_eliminated, 0);
3536 }
3537
3538 /// Smoke test: turning on `presolve_auxiliary_diagnostics` still
3539 /// produces a correct elimination. We can't easily capture
3540 /// stderr from inside a #[test], but we can verify the option
3541 /// path doesn't break the orchestrator.
3542 #[test]
3543 fn phase0_via_tnlp_diagnostics_flag_does_not_break_solve() {
3544 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
3545 let opts = PresolveOptions {
3546 enabled: true,
3547 auxiliary: true,
3548 auxiliary_diagnostics: true,
3549 ..PresolveOptions::defaults()
3550 };
3551 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3552 let info = wrapped.get_nlp_info().expect("init ok");
3553 assert_eq!(info.m, 0);
3554 let diag = wrapped.auxiliary_diagnostics();
3555 assert_eq!(diag.blocks_eliminated, 1);
3556 }
3557
3558 /// Regression for the #60 PR review blocker. The original
3559 /// `TwoVarSquareEq` TNLP, when wrapped with BOTH
3560 /// `presolve_auxiliary=yes` AND `presolve_bound_tightening=yes`
3561 /// (the new defaults), used to:
3562 /// (1) let aux clamp x_l[0..2] = x_u[0..2] = solved values;
3563 /// (2) let `tighten_bounds` re-propagate the (dropped)
3564 /// equality rows over the clamped bounds, derive a
3565 /// contradiction, and set `tighten_report.infeasible`;
3566 /// (3) hand `x_l > x_u` to the IPM (because `infeasible` was
3567 /// never inspected), crashing it with
3568 /// `Invalid Problem Definition`.
3569 /// The fix: build `linear_rows` AFTER Phase 0, filtered by
3570 /// `row_kept_inner`, so dropped rows don't propagate. With this
3571 /// test we just confirm the wrapper init still succeeds and the
3572 /// final bounds remain self-consistent.
3573 #[test]
3574 fn phase0_via_tnlp_no_infeasible_with_default_bound_tightening() {
3575 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
3576 let opts = PresolveOptions {
3577 enabled: true,
3578 auxiliary: true,
3579 // Both phases on — this is the combination that crashed
3580 // on `gaslib11_steady.nl` before the fix.
3581 bound_tightening: true,
3582 ..PresolveOptions::defaults()
3583 };
3584 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3585 let info = wrapped.get_nlp_info().expect("init ok");
3586 assert_eq!(info.m, 0); // aux dropped both rows
3587 let bounds = wrapped.cached_bounds().expect("inited");
3588 // x_l ≤ x_u must hold for every variable.
3589 for i in 0..(info.n as usize) {
3590 assert!(
3591 bounds.x_l[i] <= bounds.x_u[i] + 1e-12,
3592 "x_l[{i}] = {} > x_u[{i}] = {}",
3593 bounds.x_l[i],
3594 bounds.x_u[i]
3595 );
3596 }
3597 // tighten_report must NOT have flagged infeasibility.
3598 let rpt = wrapped.tighten_report();
3599 assert!(!rpt.infeasible, "Phase 1 falsely flagged infeasibility");
3600 }
3601
3602 /// Same model as [`TwoVarSquareEq`] but records the bound multipliers
3603 /// `z_l`/`z_u` that `finalize_solution` forwards to the inner TNLP, so a
3604 /// test can assert what the reported KKT point carries.
3605 struct RecordingTwoVar {
3606 rec: Rc<RefCell<Option<(Vec<Number>, Vec<Number>)>>>,
3607 }
3608 impl TNLP for RecordingTwoVar {
3609 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
3610 Some(NlpInfo {
3611 n: 2,
3612 m: 2,
3613 nnz_jac_g: 4,
3614 nnz_h_lag: 0,
3615 index_style: IndexStyle::C,
3616 })
3617 }
3618 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
3619 for v in b.x_l.iter_mut() {
3620 *v = -1e19;
3621 }
3622 for v in b.x_u.iter_mut() {
3623 *v = 1e19;
3624 }
3625 b.g_l[0] = 3.0;
3626 b.g_u[0] = 3.0;
3627 b.g_l[1] = 1.0;
3628 b.g_u[1] = 1.0;
3629 true
3630 }
3631 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
3632 if sp.init_x {
3633 sp.x[0] = 0.0;
3634 sp.x[1] = 0.0;
3635 }
3636 true
3637 }
3638 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
3639 Some(0.0)
3640 }
3641 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3642 for v in g.iter_mut() {
3643 *v = 0.0;
3644 }
3645 true
3646 }
3647 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3648 g[0] = x[0] + x[1];
3649 g[1] = x[0] - x[1];
3650 true
3651 }
3652 fn eval_jac_g(
3653 &mut self,
3654 _x: Option<&[Number]>,
3655 _new_x: bool,
3656 mode: SparsityRequest<'_>,
3657 ) -> bool {
3658 match mode {
3659 SparsityRequest::Structure { irow, jcol } => {
3660 irow.copy_from_slice(&[0, 0, 1, 1]);
3661 jcol.copy_from_slice(&[0, 1, 0, 1]);
3662 }
3663 SparsityRequest::Values { values } => {
3664 values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0]);
3665 }
3666 }
3667 true
3668 }
3669 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
3670 types[0] = Linearity::Linear;
3671 types[1] = Linearity::Linear;
3672 true
3673 }
3674 fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
3675 *self.rec.borrow_mut() = Some((sol.z_l.to_vec(), sol.z_u.to_vec()));
3676 }
3677 }
3678
3679 /// Regression for H10. Phase 0 clamps `x_l = x_u = v` at the two
3680 /// eliminated variables, so the IPM reports large bound multipliers
3681 /// there. `recover_dropped_multipliers` attributes the *whole*
3682 /// stationarity residual to the recovered row multipliers λ under the
3683 /// documented assumption `z_l = z_u = 0` at those variables; if
3684 /// `finalize_solution` forwards the clamp multipliers unchanged the
3685 /// contribution is double-counted and the reported point violates
3686 /// `∇f − Jᵀλ − z_l + z_u = 0`. The fix zeros `z_l`/`z_u` at every
3687 /// frame's `fixed_vars` once their λ has been recovered.
3688 #[test]
3689 fn phase0_finalize_zeroes_bound_multipliers_at_fixed_vars() {
3690 let rec = Rc::new(RefCell::new(None));
3691 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(RecordingTwoVar {
3692 rec: Rc::clone(&rec),
3693 }));
3694 let opts = PresolveOptions {
3695 enabled: true,
3696 auxiliary: true,
3697 auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
3698 ..PresolveOptions::defaults()
3699 };
3700 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3701 wrapped.set_project_seed(true);
3702 let info = wrapped.get_nlp_info().expect("init ok");
3703 assert_eq!(info.n, 2, "variable count unchanged (clamp, not reduce)");
3704 assert_eq!(info.m, 0, "both equality rows dropped by Phase 0");
3705
3706 let (mut x0, mut z_l0, mut z_u0) = (vec![0.0; 2], vec![0.0; 2], vec![0.0; 2]);
3707 let mut lambda0 = Vec::new();
3708 wrapped.reset_starting_point_projection_report();
3709 assert!(wrapped.get_starting_point(StartingPoint {
3710 init_x: true,
3711 x: &mut x0,
3712 init_z: false,
3713 z_l: &mut z_l0,
3714 z_u: &mut z_u0,
3715 init_lambda: true,
3716 lambda: &mut lambda0,
3717 }));
3718 assert_eq!(x0, vec![2.0, 1.0], "wrapper serves aux-fixed values");
3719 let start_report = wrapped.starting_point_projection_report();
3720 assert_eq!(
3721 start_report.x_fixed_overridden_count, 2,
3722 "report = {start_report:?}"
3723 );
3724 assert_eq!(start_report.x_clamped_count, 0, "report = {start_report:?}");
3725 assert_eq!(start_report.n_dropped_rows, 2, "report = {start_report:?}");
3726
3727 // The reduced solve returns the clamped point (2, 1); the IPM places
3728 // large bound multipliers on the now-fixed variables, and the reduced
3729 // problem has no rows (so empty g / lambda).
3730 let x = [2.0, 1.0];
3731 let z_l = [7.0, 0.0];
3732 let z_u = [0.0, 3.0];
3733 let g: [Number; 0] = [];
3734 let lambda: [Number; 0] = [];
3735 let sol = Solution {
3736 status: pounce_nlp::alg_types::SolverReturn::Success,
3737 x: &x,
3738 z_l: &z_l,
3739 z_u: &z_u,
3740 g: &g,
3741 lambda: &lambda,
3742 obj_value: 0.0,
3743 };
3744 wrapped.finalize_solution(sol, &IpoptData::default(), &IpoptCq::default());
3745
3746 let (got_zl, got_zu) = rec.borrow().clone().expect("inner finalize ran");
3747 // fixed_vars = {0, 1}: both must be zeroed. Pre-fix the inner sees the
3748 // forwarded clamp multipliers [7,0] / [0,3] verbatim.
3749 assert_eq!(
3750 got_zl,
3751 vec![0.0, 0.0],
3752 "z_l must be zeroed at aux-fixed vars (H10)"
3753 );
3754 assert_eq!(
3755 got_zu,
3756 vec![0.0, 0.0],
3757 "z_u must be zeroed at aux-fixed vars (H10)"
3758 );
3759 }
3760
3761 /// min x^2 s.t. x + y = 3, x/y in [0, 5]. The row tightens both
3762 /// upper bounds 5 -> 3; the seed sits at the stale corner [5, 5].
3763 struct TightenSeed {
3764 x0: Vec<Number>,
3765 }
3766
3767 impl TNLP for TightenSeed {
3768 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
3769 Some(NlpInfo {
3770 n: 2,
3771 m: 1,
3772 nnz_jac_g: 2,
3773 nnz_h_lag: 0,
3774 index_style: IndexStyle::C,
3775 })
3776 }
3777 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
3778 b.x_l.copy_from_slice(&[0.0, 0.0]);
3779 b.x_u.copy_from_slice(&[5.0, 5.0]);
3780 b.g_l[0] = 3.0;
3781 b.g_u[0] = 3.0;
3782 true
3783 }
3784 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
3785 if sp.init_x {
3786 sp.x.copy_from_slice(&self.x0);
3787 }
3788 true
3789 }
3790 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
3791 Some(x[0] * x[0])
3792 }
3793 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3794 g[0] = 2.0 * x[0];
3795 g[1] = 0.0;
3796 true
3797 }
3798 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3799 g[0] = x[0] + x[1];
3800 true
3801 }
3802 fn eval_jac_g(
3803 &mut self,
3804 _x: Option<&[Number]>,
3805 _new_x: bool,
3806 mode: SparsityRequest<'_>,
3807 ) -> bool {
3808 match mode {
3809 SparsityRequest::Structure { irow, jcol } => {
3810 irow.copy_from_slice(&[0, 0]);
3811 jcol.copy_from_slice(&[0, 1]);
3812 }
3813 SparsityRequest::Values { values } => {
3814 values.copy_from_slice(&[1.0, 1.0]);
3815 }
3816 }
3817 true
3818 }
3819 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
3820 types.fill(Linearity::Linear);
3821 true
3822 }
3823 fn finalize_solution(&mut self, _s: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
3824 }
3825
3826 /// The report is recorded by the same call that projects the served seed.
3827 #[test]
3828 fn served_warm_primal_records_its_projection() {
3829 let inner: Rc<RefCell<dyn TNLP>> =
3830 Rc::new(RefCell::new(TightenSeed { x0: vec![5.0, 5.0] }));
3831 let opts = PresolveOptions {
3832 enabled: true,
3833 ..PresolveOptions::defaults()
3834 };
3835 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3836 wrapped.set_project_seed(true);
3837
3838 let (mut x_l, mut x_u) = (vec![0.0; 2], vec![0.0; 2]);
3839 let (mut g_l, mut g_u) = (vec![0.0; 1], vec![0.0; 1]);
3840 assert!(wrapped.get_bounds_info(BoundsInfo {
3841 x_l: &mut x_l,
3842 x_u: &mut x_u,
3843 g_l: &mut g_l,
3844 g_u: &mut g_u,
3845 }));
3846 assert_eq!(x_u, vec![3.0, 3.0], "row x + y = 3 tightens x_u to 3");
3847
3848 let (mut x, mut z_l, mut z_u) = (vec![0.0; 2], vec![0.0; 2], vec![0.0; 2]);
3849 let mut lambda = vec![0.0; 1];
3850 assert!(wrapped.get_starting_point(StartingPoint {
3851 init_x: true,
3852 x: &mut x,
3853 init_z: false,
3854 z_l: &mut z_l,
3855 z_u: &mut z_u,
3856 init_lambda: false,
3857 lambda: &mut lambda,
3858 }));
3859 assert_eq!(
3860 x,
3861 vec![3.0, 3.0],
3862 "served seed must be clamped into the tightened box"
3863 );
3864
3865 let report = wrapped.starting_point_projection_report();
3866 assert_eq!(report.x_clamped_count, 2, "report = {report:?}");
3867 assert_eq!(report.x_fixed_overridden_count, 0, "report = {report:?}");
3868 }
3869
3870 /// Same model as [`RecordingTwoVar`] but (a) records the `g` vector that
3871 /// `finalize_solution` forwards to the inner TNLP and (b) can be told to
3872 /// *fail* `eval_g` (returning `false` after scribbling a sentinel), so a
3873 /// test can check what happens when the final constraint re-eval fails.
3874 struct GFailRecordingVar {
3875 rec_g: Rc<RefCell<Option<Vec<Number>>>>,
3876 fail_g: Rc<RefCell<bool>>,
3877 }
3878 impl TNLP for GFailRecordingVar {
3879 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
3880 Some(NlpInfo {
3881 n: 2,
3882 m: 2,
3883 nnz_jac_g: 4,
3884 nnz_h_lag: 0,
3885 index_style: IndexStyle::C,
3886 })
3887 }
3888 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
3889 for v in b.x_l.iter_mut() {
3890 *v = -1e19;
3891 }
3892 for v in b.x_u.iter_mut() {
3893 *v = 1e19;
3894 }
3895 b.g_l[0] = 3.0;
3896 b.g_u[0] = 3.0;
3897 b.g_l[1] = 1.0;
3898 b.g_u[1] = 1.0;
3899 true
3900 }
3901 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
3902 if sp.init_x {
3903 sp.x[0] = 0.0;
3904 sp.x[1] = 0.0;
3905 }
3906 true
3907 }
3908 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
3909 Some(0.0)
3910 }
3911 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3912 for v in g.iter_mut() {
3913 *v = 0.0;
3914 }
3915 true
3916 }
3917 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3918 if *self.fail_g.borrow() {
3919 // Simulate a failing evaluator that leaves garbage behind.
3920 for v in g.iter_mut() {
3921 *v = 999.0;
3922 }
3923 return false;
3924 }
3925 g[0] = x[0] + x[1];
3926 g[1] = x[0] - x[1];
3927 true
3928 }
3929 fn eval_jac_g(
3930 &mut self,
3931 _x: Option<&[Number]>,
3932 _new_x: bool,
3933 mode: SparsityRequest<'_>,
3934 ) -> bool {
3935 match mode {
3936 SparsityRequest::Structure { irow, jcol } => {
3937 irow.copy_from_slice(&[0, 0, 1, 1]);
3938 jcol.copy_from_slice(&[0, 1, 0, 1]);
3939 }
3940 SparsityRequest::Values { values } => {
3941 values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0]);
3942 }
3943 }
3944 true
3945 }
3946 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
3947 types[0] = Linearity::Linear;
3948 types[1] = Linearity::Linear;
3949 true
3950 }
3951 fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
3952 *self.rec_g.borrow_mut() = Some(sol.g.to_vec());
3953 }
3954 }
3955
3956 /// L45: when the final constraint re-eval in `finalize_solution` fails,
3957 /// the forwarded `g` must NOT be the garbage/stale buffer the failing
3958 /// `eval_g` left behind. Both rows here are Phase-0 dropped (reduced
3959 /// `m = 0`, empty `sol.g`), so the trustworthy fallback yields all-zeros
3960 /// — never the `999.0` sentinel.
3961 #[test]
3962 fn finalize_does_not_forward_stale_g_when_eval_g_fails() {
3963 let rec_g = Rc::new(RefCell::new(None));
3964 let fail_g = Rc::new(RefCell::new(false));
3965 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(GFailRecordingVar {
3966 rec_g: Rc::clone(&rec_g),
3967 fail_g: Rc::clone(&fail_g),
3968 }));
3969 let opts = PresolveOptions {
3970 enabled: true,
3971 auxiliary: true,
3972 auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
3973 ..PresolveOptions::defaults()
3974 };
3975 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
3976 let info = wrapped.get_nlp_info().expect("init ok");
3977 assert_eq!(info.m, 0, "both equality rows dropped by Phase 0");
3978
3979 // Now make the final re-eval fail.
3980 *fail_g.borrow_mut() = true;
3981 let x = [2.0, 1.0];
3982 let z_l = [0.0, 0.0];
3983 let z_u = [0.0, 0.0];
3984 let g: [Number; 0] = [];
3985 let lambda: [Number; 0] = [];
3986 let sol = Solution {
3987 status: pounce_nlp::alg_types::SolverReturn::Success,
3988 x: &x,
3989 z_l: &z_l,
3990 z_u: &z_u,
3991 g: &g,
3992 lambda: &lambda,
3993 obj_value: 0.0,
3994 };
3995 wrapped.finalize_solution(sol, &IpoptData::default(), &IpoptCq::default());
3996
3997 let got_g = rec_g.borrow().clone().expect("inner finalize ran");
3998 // Fail-first: pre-fix the ignored `eval_g` return forwards the
3999 // sentinel-filled `scratch_g` (`[999, 999]`) verbatim.
4000 assert_eq!(
4001 got_g,
4002 vec![0.0, 0.0],
4003 "failed eval_g must not forward stale/garbage constraint values",
4004 );
4005 }
4006
4007 /// gh #396's shape, reduced to its essentials: one `<=`-only row whose
4008 /// genuine upper bound is *more negative* than the `-INF_BOUND` sentinel
4009 /// that stands in for its absent lower bound, and a starting point sitting
4010 /// exactly on that upper bound.
4011 ///
4012 /// `-1e30 · x <= -5e20` over `x ∈ [0, 1e-9]`, with `x0 = 5e-10`, so
4013 /// `g(x0) == g_u` exactly. True violation: zero.
4014 struct SentinelLowerBoundRow;
4015 impl TNLP for SentinelLowerBoundRow {
4016 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4017 Some(NlpInfo {
4018 n: 1,
4019 m: 1,
4020 nnz_jac_g: 1,
4021 nnz_h_lag: 0,
4022 index_style: IndexStyle::C,
4023 })
4024 }
4025 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4026 b.x_l[0] = 0.0;
4027 b.x_u[0] = 1e-9;
4028 b.g_l[0] = -1e19; // absent — the sentinel
4029 b.g_u[0] = -5e20; // real, and beyond the sentinel's magnitude
4030 true
4031 }
4032 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4033 if sp.init_x {
4034 sp.x[0] = 5e-10;
4035 }
4036 true
4037 }
4038 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
4039 Some(0.0)
4040 }
4041 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4042 g[0] = 0.0;
4043 true
4044 }
4045 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4046 g[0] = -1e30 * x[0];
4047 true
4048 }
4049 fn eval_jac_g(
4050 &mut self,
4051 _x: Option<&[Number]>,
4052 _new_x: bool,
4053 mode: SparsityRequest<'_>,
4054 ) -> bool {
4055 match mode {
4056 SparsityRequest::Structure { irow, jcol } => {
4057 irow[0] = 0;
4058 jcol[0] = 0;
4059 }
4060 SparsityRequest::Values { values } => {
4061 values[0] = -1e30;
4062 }
4063 }
4064 true
4065 }
4066 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
4067 types[0] = Linearity::Linear;
4068 true
4069 }
4070 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
4071 }
4072
4073 /// **gh #396.** The absent-bound sentinel must not manufacture a violation,
4074 /// and a genuine bound beyond the sentinel's magnitude must not be
4075 /// discarded.
4076 ///
4077 /// Both errors landed on this one row pre-fix. `viol` had no presence test,
4078 /// so it scored `g_l - v = 4.9e20` against a lower bound the row does not
4079 /// have; and the magnitude test `|b| < INF_BOUND` called the row's *real*
4080 /// upper bound `-5e20` absent. Either alone sinks the witness, and with no
4081 /// candidate able to pass, presolve certified a feasible model infeasible
4082 /// with `solve_result_num` 201 — a claimed proof.
4083 #[test]
4084 fn absent_bound_sentinel_does_not_manufacture_a_violation() {
4085 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SentinelLowerBoundRow));
4086 assert!(
4087 witness_refutes_infeasibility(
4088 &inner,
4089 1,
4090 1,
4091 &[0.0],
4092 &[1e-9],
4093 &[-1e19],
4094 &[-5e20],
4095 1e-8,
4096 WitnessRule::SolverAcceptance,
4097 ),
4098 "x0 = 5e-10 puts the row exactly on its only real bound — that is a \
4099 witness, and the -1e19 sentinel standing in for the absent lower \
4100 bound must not turn it into a 4.9e20 violation"
4101 );
4102 }
4103
4104 /// The other direction, and the reason the fix is a *directional* presence
4105 /// test rather than dropping the terms: a point that genuinely violates a
4106 /// real bound must still fail, so correct verdicts survive.
4107 ///
4108 /// Tightening the ceiling to `-5.1e20` over `x ∈ [4e-10, 5e-10]` puts every
4109 /// candidate — including `x0` — strictly above it, so there is genuinely no
4110 /// witness and the verdict must stand.
4111 ///
4112 /// This one does *not* reproduce #396: pre-fix it also returned `false`,
4113 /// via the phantom lower-bound violation rather than the real upper-bound
4114 /// one. It is here to pin the direction the fix could plausibly have broken
4115 /// — making `up_present` admit the bound must not make real violations
4116 /// invisible.
4117 #[test]
4118 fn a_real_bound_beyond_the_sentinel_still_blocks_the_witness() {
4119 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SentinelLowerBoundRow));
4120 assert!(
4121 !witness_refutes_infeasibility(
4122 &inner,
4123 1,
4124 1,
4125 &[4e-10],
4126 &[5e-10],
4127 &[-1e19],
4128 &[-5.1e20],
4129 1e-8,
4130 WitnessRule::SolverAcceptance,
4131 ),
4132 "no point in [4e-10, 5e-10] satisfies -1e30*x <= -5.1e20, so there \
4133 is no witness — a real bound of magnitude 5.1e20 must not be \
4134 discarded as the infinity sentinel"
4135 );
4136 }
4137
4138 /// `min x` over `x ∈ [decl_l, 10]` with one linear row `a·x` bounded so
4139 /// that it reads `x ≥ 2` for either sign of `a` — the M24 shape: Phase 1
4140 /// turns the row into `x_l = 2` and Phase 2 drops it as redundant.
4141 struct SingletonLowerRow {
4142 a: Number,
4143 decl_l: Number,
4144 }
4145 impl TNLP for SingletonLowerRow {
4146 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4147 Some(NlpInfo {
4148 n: 1,
4149 m: 1,
4150 nnz_jac_g: 1,
4151 nnz_h_lag: 0,
4152 index_style: IndexStyle::C,
4153 })
4154 }
4155 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4156 b.x_l[0] = self.decl_l;
4157 b.x_u[0] = 10.0;
4158 if self.a > 0.0 {
4159 b.g_l[0] = 2.0 * self.a;
4160 b.g_u[0] = 1e19;
4161 } else {
4162 b.g_l[0] = -1e19;
4163 b.g_u[0] = 2.0 * self.a;
4164 }
4165 true
4166 }
4167 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4168 if sp.init_x {
4169 sp.x[0] = 5.0;
4170 }
4171 if sp.init_lambda {
4172 // The row multiplier a `presolve=no` solve reports:
4173 // `∇f + a·λ = 0` with `∇f = 1`.
4174 sp.lambda[0] = -1.0 / self.a;
4175 }
4176 true
4177 }
4178 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
4179 Some(x[0])
4180 }
4181 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4182 g[0] = 1.0;
4183 true
4184 }
4185 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4186 g[0] = self.a * x[0];
4187 true
4188 }
4189 fn eval_jac_g(
4190 &mut self,
4191 _x: Option<&[Number]>,
4192 _new_x: bool,
4193 mode: SparsityRequest<'_>,
4194 ) -> bool {
4195 match mode {
4196 SparsityRequest::Structure { irow, jcol } => {
4197 irow[0] = 0;
4198 jcol[0] = 0;
4199 }
4200 SparsityRequest::Values { values } => {
4201 values[0] = self.a;
4202 }
4203 }
4204 true
4205 }
4206 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
4207 types[0] = Linearity::Linear;
4208 true
4209 }
4210 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
4211 }
4212
4213 /// Finalize `SingletonLowerRow` at its optimum `x = 2` with the reduced
4214 /// solve's `z_l = 1` on the presolve-made bound, returning the full-space
4215 /// row multiplier and the handed-back list.
4216 fn finalize_singleton(a: Number, decl_l: Number) -> (Number, Vec<(usize, bool)>) {
4217 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SingletonLowerRow { a, decl_l }));
4218 let opts = PresolveOptions {
4219 enabled: true,
4220 bound_tightening: true,
4221 redundant_constraint_removal: true,
4222 ..PresolveOptions::defaults()
4223 };
4224 let mut wrapped = PresolveTnlp::new(inner, opts);
4225 let info = wrapped.get_nlp_info().expect("init ok");
4226 assert_eq!(wrapped.n_dropped_rows(), 1, "a={a}: the row is dropped");
4227 assert_eq!(info.m, 0);
4228 let b = wrapped.cached_bounds().expect("inited");
4229 assert!((b.x_l[0] - 2.0).abs() < 1e-12, "a={a}: x_l = {}", b.x_l[0]);
4230 let (x, z_l, z_u) = ([2.0], [1.0], [0.0]);
4231 let (g, lambda): ([Number; 0], [Number; 0]) = ([], []);
4232 wrapped.finalize_solution(
4233 Solution {
4234 status: pounce_nlp::alg_types::SolverReturn::Success,
4235 x: &x,
4236 z_l: &z_l,
4237 z_u: &z_u,
4238 g: &g,
4239 lambda: &lambda,
4240 obj_value: 2.0,
4241 },
4242 &IpoptData::default(),
4243 &IpoptCq::default(),
4244 );
4245 let (_, lamf) = wrapped.finalized_full_solution().expect("finalized");
4246 assert_eq!(lamf.len(), 1, "full-space lambda regains the original row");
4247 (lamf[0], wrapped.bound_multipliers_handed_to_rows().to_vec())
4248 }
4249
4250 /// M24 (see the module-level "Dual-attribution caveat"): the multiplier
4251 /// of a bound presolve made from a singleton row it then dropped goes
4252 /// back to the row, as a `presolve=no` solve reports it. Ipopt's
4253 /// convention, `∇f + Jᵀλ − z_l + z_u = 0`: `1 + a·λ − 0 = 0`, so
4254 /// `λ = −1/a`, for either sign of `a` (with `a < 0` the row's *upper*
4255 /// side implies the variable's *lower* bound).
4256 #[test]
4257 fn dropped_singleton_row_takes_back_its_bound_dual() {
4258 for a in [1.0, 4.0, -1.0, -0.5] {
4259 let (lam, handed) = finalize_singleton(a, 0.0);
4260 assert!(
4261 (lam - (-1.0 / a)).abs() < 1e-12,
4262 "a={a}: dropped-row λ = {lam}, expected {}",
4263 -1.0 / a
4264 );
4265 assert_eq!(handed, vec![(0, false)], "a={a}: z_l[0] was handed back");
4266 // Stationarity with the bound multiplier zeroed.
4267 let stat = 1.0 + a * lam;
4268 assert!(
4269 stat.abs() < 1e-12,
4270 "a={a}: KKT stationarity residual {stat}"
4271 );
4272 }
4273 }
4274
4275 /// A declared bound that already sits where the row puts it keeps its
4276 /// multiplier: the two are a degenerate pair and the tie goes to the
4277 /// incumbent, as for `linear_eq_elim`'s transferred bounds.
4278 #[test]
4279 fn a_declared_bound_tied_with_the_row_keeps_its_dual() {
4280 let (lam, handed) = finalize_singleton(1.0, 2.0);
4281 assert_eq!(lam, 0.0, "the row is redundant against the declared bound");
4282 assert!(handed.is_empty(), "{handed:?}");
4283 }
4284
4285 /// The inverse, for a warm start: the dropped row's supplied multiplier
4286 /// becomes the bound multiplier of the bound presolve made from it, so
4287 /// a `.sol` written through presolve round-trips.
4288 #[test]
4289 fn a_warm_row_multiplier_seeds_the_bound_presolve_made_from_it() {
4290 for a in [1.0, -0.5] {
4291 let inner: Rc<RefCell<dyn TNLP>> =
4292 Rc::new(RefCell::new(SingletonLowerRow { a, decl_l: 0.0 }));
4293 let opts = PresolveOptions {
4294 enabled: true,
4295 bound_tightening: true,
4296 redundant_constraint_removal: true,
4297 ..PresolveOptions::defaults()
4298 };
4299 let mut wrapped = PresolveTnlp::new(inner, opts);
4300 let _ = wrapped.get_nlp_info().expect("init ok");
4301 let (mut x, mut z_l, mut z_u) = ([0.0], [Number::NAN], [Number::NAN]);
4302 let mut lambda: [Number; 0] = [];
4303 assert!(wrapped.get_starting_point(StartingPoint {
4304 init_x: true,
4305 x: &mut x,
4306 init_z: true,
4307 z_l: &mut z_l,
4308 z_u: &mut z_u,
4309 init_lambda: true,
4310 lambda: &mut lambda,
4311 }));
4312 assert!((z_l[0] - 1.0).abs() < 1e-12, "a={a}: z_l = {}", z_l[0]);
4313 assert!(z_u[0].is_nan(), "a={a}: the upper side stays unseeded");
4314 }
4315 }
4316
4317 /// `min (x0+1)² + (x1−3)² s.t. C0: x0 = 0.5, C1: x0 + x1 ≤ 2`, no
4318 /// declared bounds. Presolve fixes x0 from C0, derives `x1 ≤ 1.5` from
4319 /// C1, and drops both rows (M24, derived bounds). At `x* = (0.5, 1.5)`
4320 /// the original multipliers are unique: `λ = (−6, 3)`, no bound
4321 /// multipliers.
4322 struct DerivedChain;
4323 impl TNLP for DerivedChain {
4324 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4325 Some(NlpInfo {
4326 n: 2,
4327 m: 2,
4328 nnz_jac_g: 3,
4329 nnz_h_lag: 0,
4330 index_style: IndexStyle::C,
4331 })
4332 }
4333 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4334 b.x_l.fill(-1e19);
4335 b.x_u.fill(1e19);
4336 b.g_l.copy_from_slice(&[0.5, -1e19]);
4337 b.g_u.copy_from_slice(&[0.5, 2.0]);
4338 true
4339 }
4340 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4341 if sp.init_x {
4342 sp.x.copy_from_slice(&[0.5, 1.5]);
4343 }
4344 if sp.init_lambda {
4345 sp.lambda.copy_from_slice(&[-6.0, 3.0]);
4346 }
4347 true
4348 }
4349 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
4350 Some((x[0] + 1.0).powi(2) + (x[1] - 3.0).powi(2))
4351 }
4352 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4353 g[0] = 2.0 * (x[0] + 1.0);
4354 g[1] = 2.0 * (x[1] - 3.0);
4355 true
4356 }
4357 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4358 g[0] = x[0];
4359 g[1] = x[0] + x[1];
4360 true
4361 }
4362 fn eval_jac_g(
4363 &mut self,
4364 _x: Option<&[Number]>,
4365 _new_x: bool,
4366 mode: SparsityRequest<'_>,
4367 ) -> bool {
4368 match mode {
4369 SparsityRequest::Structure { irow, jcol } => {
4370 irow.copy_from_slice(&[0, 1, 1]);
4371 jcol.copy_from_slice(&[0, 0, 1]);
4372 }
4373 SparsityRequest::Values { values } => values.copy_from_slice(&[1.0, 1.0, 1.0]),
4374 }
4375 true
4376 }
4377 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
4378 types.fill(Linearity::Linear);
4379 true
4380 }
4381 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
4382 }
4383
4384 fn derived_chain() -> PresolveTnlp {
4385 let opts = PresolveOptions {
4386 enabled: true,
4387 bound_tightening: true,
4388 redundant_constraint_removal: true,
4389 ..PresolveOptions::defaults()
4390 };
4391 let mut wrapped = PresolveTnlp::new(Rc::new(RefCell::new(DerivedChain)), opts);
4392 let info = wrapped.get_nlp_info().expect("init ok");
4393 assert_eq!(info.m, 0, "both rows are dropped");
4394 let b = wrapped.cached_bounds().expect("inited");
4395 assert!(
4396 (b.x_u[1] - 1.5).abs() < 1e-12,
4397 "x1 ≤ 1.5 derived: {}",
4398 b.x_u[1]
4399 );
4400 wrapped
4401 }
4402
4403 /// Outbound: the reduced solve's `z_U(x1) = 3` on the derived bound and
4404 /// x0's fixed-variable multiplier go back to C1 and C0.
4405 #[test]
4406 fn a_derived_bound_multiplier_is_recovered_onto_its_rows() {
4407 let mut wrapped = derived_chain();
4408 // Reduced stationarity: x0 (fixed) 3 − z_l = 0; x1 −3 + z_u = 0.
4409 let (x, z_l, z_u) = ([0.5, 1.5], [3.0, 0.0], [0.0, 3.0]);
4410 let (g, lambda): ([Number; 0], [Number; 0]) = ([], []);
4411 wrapped.finalize_solution(
4412 Solution {
4413 status: pounce_nlp::alg_types::SolverReturn::Success,
4414 x: &x,
4415 z_l: &z_l,
4416 z_u: &z_u,
4417 g: &g,
4418 lambda: &lambda,
4419 obj_value: 4.5,
4420 },
4421 &IpoptData::default(),
4422 &IpoptCq::default(),
4423 );
4424 let (_, lam) = wrapped.finalized_full_solution().expect("finalized");
4425 assert!(
4426 (lam[0] + 6.0).abs() < 1e-9 && (lam[1] - 3.0).abs() < 1e-9,
4427 "λ = {lam:?}"
4428 );
4429 assert_eq!(wrapped.derived_bound_dual_recovery(), Some((1, true)));
4430 let ov = wrapped.bound_multiplier_overrides().to_vec();
4431 assert!(
4432 ov.iter().all(|&(_, l, u)| l.abs() < 1e-9 && u.abs() < 1e-9) && ov.len() == 2,
4433 "{ov:?}"
4434 );
4435 assert!(wrapped.postsolve_rewrote_duals());
4436 }
4437
4438 /// Inbound: C1's supplied multiplier seeds the derived bound it implies,
4439 /// so the reduced start is stationary where the supplied one was.
4440 #[test]
4441 fn a_dropped_rows_warm_multiplier_seeds_the_derived_bound() {
4442 let mut wrapped = derived_chain();
4443 let (mut x, mut z_l, mut z_u) = ([0.0; 2], [Number::NAN; 2], [Number::NAN; 2]);
4444 let mut lambda: [Number; 0] = [];
4445 assert!(wrapped.get_starting_point(StartingPoint {
4446 init_x: true,
4447 x: &mut x,
4448 init_z: true,
4449 z_l: &mut z_l,
4450 z_u: &mut z_u,
4451 init_lambda: true,
4452 lambda: &mut lambda,
4453 }));
4454 assert!((z_u[1] - 3.0).abs() < 1e-12, "z_U(x1) = {}", z_u[1]);
4455 assert!(z_l[1].is_nan(), "x1's lower side stays unseeded");
4456 // x0: C0 (singleton, λ0 = −6) seeds z_L = 6, C1 adds z_U = 3; the
4457 // reduced stationarity there, 3 − 6 + 3, closes.
4458 assert!(
4459 (z_l[0] - 6.0).abs() < 1e-12 && (z_u[0] - 3.0).abs() < 1e-12,
4460 "{z_l:?} {z_u:?}"
4461 );
4462 }
4463
4464 /// M25 scratch: one variable, box `x ∈ [0, 10]`, two contradictory linear
4465 /// rows `x ≥ 5` and `x ≤ 3`. Phase 1 tightens `x_l = 5`, `x_u = 3` and
4466 /// flags infeasible. Auxiliary is off, so the reduction stack is empty and
4467 /// the rollback guard does NOT fire.
4468 struct TwoContradictoryRows;
4469 impl TNLP for TwoContradictoryRows {
4470 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4471 Some(NlpInfo {
4472 n: 1,
4473 m: 2,
4474 nnz_jac_g: 2,
4475 nnz_h_lag: 0,
4476 index_style: IndexStyle::C,
4477 })
4478 }
4479 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4480 b.x_l[0] = 0.0;
4481 b.x_u[0] = 10.0;
4482 b.g_l[0] = 5.0; // x ≥ 5
4483 b.g_u[0] = 1e19;
4484 b.g_l[1] = -1e19; // x ≤ 3
4485 b.g_u[1] = 3.0;
4486 true
4487 }
4488 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4489 if sp.init_x {
4490 sp.x[0] = 4.0;
4491 }
4492 true
4493 }
4494 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
4495 Some(0.0)
4496 }
4497 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4498 g[0] = 0.0;
4499 true
4500 }
4501 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4502 g[0] = x[0];
4503 g[1] = x[0];
4504 true
4505 }
4506 fn eval_jac_g(
4507 &mut self,
4508 _x: Option<&[Number]>,
4509 _new_x: bool,
4510 mode: SparsityRequest<'_>,
4511 ) -> bool {
4512 match mode {
4513 SparsityRequest::Structure { irow, jcol } => {
4514 irow.copy_from_slice(&[0, 1]);
4515 jcol.copy_from_slice(&[0, 0]);
4516 }
4517 SparsityRequest::Values { values } => {
4518 values.copy_from_slice(&[1.0, 1.0]);
4519 }
4520 }
4521 true
4522 }
4523 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
4524 types[0] = Linearity::Linear;
4525 types[1] = Linearity::Linear;
4526 true
4527 }
4528 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
4529 }
4530
4531 /// Regression for M25. A genuine Phase-1 infeasibility found with an empty
4532 /// reduction stack (auxiliary off, so the aux rollback guard never fires)
4533 /// must NOT hand crossed bounds `x_l > x_u` to the IPM — that triggers an
4534 /// invalid-problem failure instead of a clean infeasibility verdict.
4535 /// Presolve restores the original box and lets the IPM certify
4536 /// infeasibility itself; the `infeasible` flag is still surfaced for
4537 /// diagnostics.
4538 #[test]
4539 fn phase1_infeasible_restores_valid_box_for_ipm() {
4540 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoContradictoryRows));
4541 let opts = PresolveOptions {
4542 enabled: true,
4543 bound_tightening: true,
4544 auxiliary: false,
4545 ..PresolveOptions::defaults()
4546 };
4547 let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
4548 let _info = wrapped.get_nlp_info().expect("init ok");
4549
4550 // The infeasibility is still detected and surfaced for diagnostics.
4551 assert!(
4552 wrapped.tighten_report().infeasible,
4553 "Phase 1 must still flag the empty feasible region"
4554 );
4555
4556 // ...and, with no Phase-0 elimination in force, it is a genuine
4557 // contradiction in the model rather than a presolve artifact, so it is
4558 // certified. This is what lets the solver report "proved infeasible"
4559 // instead of re-deriving a weaker numerical verdict.
4560 assert_eq!(
4561 wrapped.certified_infeasible(),
4562 Some(InfeasibilityProof::BoundPropagation),
4563 "a Phase-1 contradiction on an un-clamped box must be certified"
4564 );
4565
4566 // But the box handed to the IPM is the original, valid box — not the
4567 // crossed `[5, 3]` Phase 1 derived.
4568 let b = wrapped.cached_bounds().expect("inited");
4569 assert!(
4570 b.x_l[0] <= b.x_u[0] + 1e-12,
4571 "M25: bounds handed to IPM must be valid, got x_l={} > x_u={}",
4572 b.x_l[0],
4573 b.x_u[0]
4574 );
4575 assert!(
4576 (b.x_l[0] - 0.0).abs() < 1e-12 && (b.x_u[0] - 10.0).abs() < 1e-12,
4577 "box restored to the original [0, 10], got [{}, {}]",
4578 b.x_l[0],
4579 b.x_u[0]
4580 );
4581 }
4582
4583 /// 1-variable, 2-constraint TNLP that drives FBBT into a partial
4584 /// tightening followed by a genuine infeasibility. Both constraints are
4585 /// `g = x` (default `NonLinear` linearity, so they are FBBT-handled, not
4586 /// turned into linear rows Phase 1 would catch first). Row 0 demands
4587 /// `x ∈ [0.3, 0.7]` (tightens the box `x ∈ [0, 1]`); row 1 demands
4588 /// `x = 5` (infeasible). FBBT applies row 0, then row 1 flags the box
4589 /// empty — leaving the bounds in the partially-tightened `[0.3, 0.7]`
4590 /// state the `FbbtReport` contract says must not be trusted.
4591 struct FbbtPartialThenInfeasible;
4592 impl TNLP for FbbtPartialThenInfeasible {
4593 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4594 Some(NlpInfo {
4595 n: 1,
4596 m: 2,
4597 nnz_jac_g: 2,
4598 nnz_h_lag: 0,
4599 index_style: IndexStyle::C,
4600 })
4601 }
4602 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4603 b.x_l[0] = 0.0;
4604 b.x_u[0] = 1.0;
4605 b.g_l[0] = 0.3;
4606 b.g_u[0] = 0.7;
4607 b.g_l[1] = 5.0;
4608 b.g_u[1] = 5.0;
4609 true
4610 }
4611 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4612 if sp.init_x {
4613 sp.x[0] = 0.5;
4614 }
4615 true
4616 }
4617 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
4618 Some(0.0)
4619 }
4620 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4621 g[0] = 0.0;
4622 true
4623 }
4624 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4625 g[0] = x[0];
4626 g[1] = x[0];
4627 true
4628 }
4629 fn eval_jac_g(
4630 &mut self,
4631 _x: Option<&[Number]>,
4632 _new_x: bool,
4633 mode: SparsityRequest<'_>,
4634 ) -> bool {
4635 match mode {
4636 SparsityRequest::Structure { irow, jcol } => {
4637 irow.copy_from_slice(&[0, 1]);
4638 jcol.copy_from_slice(&[0, 0]);
4639 }
4640 SparsityRequest::Values { values } => {
4641 values.copy_from_slice(&[1.0, 1.0]);
4642 }
4643 }
4644 true
4645 }
4646 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
4647 }
4648
4649 /// Returns the tape `x` (i.e. `Var(0)`) for both constraints.
4650 struct VarTapeProvider;
4651 impl ExpressionProvider for VarTapeProvider {
4652 fn constraint_expression(
4653 &self,
4654 i: usize,
4655 ) -> Option<pounce_nlp::expression_provider::FbbtTape> {
4656 use pounce_nlp::expression_provider::{FbbtOp, FbbtTape};
4657 if i < 2 {
4658 Some(FbbtTape {
4659 ops: vec![FbbtOp::Var(0)],
4660 })
4661 } else {
4662 None
4663 }
4664 }
4665 }
4666
4667 /// H12: when FBBT detects a genuine infeasibility, the partially
4668 /// tightened (and per its own contract "undefined") bounds must NOT
4669 /// reach the reduced problem. Presolve has no infeasibility channel, so
4670 /// it discards FBBT's bounds and lets the IPM run on the pre-FBBT box —
4671 /// here the original `[0, 1]`, never the corrupted `[0.3, 0.7]`.
4672 #[test]
4673 fn fbbt_infeasibility_discards_corrupted_bounds() {
4674 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FbbtPartialThenInfeasible));
4675 let provider: Rc<RefCell<dyn ExpressionProvider>> = Rc::new(RefCell::new(VarTapeProvider));
4676 let opts = PresolveOptions {
4677 enabled: true,
4678 fbbt: true,
4679 ..PresolveOptions::defaults()
4680 };
4681 let mut wrapped =
4682 PresolveTnlp::with_expression_provider(Rc::clone(&inner), Rc::clone(&provider), opts);
4683 let info = wrapped.get_nlp_info().expect("init ok");
4684 assert_eq!(info.n, 1);
4685 assert_eq!(info.m, 2, "nonlinear rows are not dropped by presolve");
4686
4687 // FBBT must have reached the infeasible row.
4688 let rpt = wrapped.fbbt_report().expect("fbbt ran");
4689 // No Phase-0 elimination is in force here, so the witness cannot be an
4690 // artifact — it is certified, and carries the witnessing row.
4691 assert_eq!(
4692 wrapped.certified_infeasible(),
4693 Some(InfeasibilityProof::IntervalArithmetic { witness: 1 }),
4694 "an FBBT witness on an un-clamped box must be certified"
4695 );
4696 assert_eq!(
4697 rpt.infeasibility_witness,
4698 Some(1),
4699 "row 1 (`x = 5`) is the infeasibility witness"
4700 );
4701
4702 // ...but its corrupted tightening is discarded: the reduced box is
4703 // the original [0, 1], not the partial [0.3, 0.7] FBBT left behind.
4704 let mut x_l = vec![0.0; info.n as usize];
4705 let mut x_u = vec![0.0; info.n as usize];
4706 let mut g_l = vec![0.0; info.m as usize];
4707 let mut g_u = vec![0.0; info.m as usize];
4708 assert!(wrapped.get_bounds_info(BoundsInfo {
4709 x_l: &mut x_l,
4710 x_u: &mut x_u,
4711 g_l: &mut g_l,
4712 g_u: &mut g_u,
4713 }));
4714 assert_eq!(
4715 (x_l[0], x_u[0]),
4716 (0.0, 1.0),
4717 "FBBT's undefined-on-infeasibility bounds must not reach the IPM (H12)"
4718 );
4719 }
4720
4721 /// 3-variable, 3-row TNLP that pits a Phase-0 auxiliary clamp against a
4722 /// kept nonlinear row. Rows 0/1 are a square LINEAR equality block
4723 /// (`x0 + x1 = 3`, `x0 - x1 = 1` → `(x0, x1) = (2, 1)`) that Phase 0
4724 /// eliminates, clamping `x0 = 2`. Row 2 is tagged `NonLinear` (so it is
4725 /// FBBT-handled, never turned into a linear row Phase 1 would catch) and
4726 /// reads `x0 + x2 = 20` with `x2 ∈ [0, 1]`. Over the *aux-clamped* box
4727 /// `x0 ∈ [2, 2]` FBBT sees `x0 + x2 ∈ [2, 3]`, disjoint from the required
4728 /// `20`, and witnesses infeasibility on row 2 — an infeasibility that
4729 /// exists ONLY because the aux clamp pinned `x0`. On the un-clamped box
4730 /// `x0` is free and FBBT tightens it to `[19, 20]` with no witness.
4731 struct AuxClampBreaksKeptNonlinearRow;
4732 impl TNLP for AuxClampBreaksKeptNonlinearRow {
4733 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4734 Some(NlpInfo {
4735 n: 3,
4736 m: 3,
4737 nnz_jac_g: 6,
4738 nnz_h_lag: 0,
4739 index_style: IndexStyle::C,
4740 })
4741 }
4742 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4743 b.x_l[0] = -1e19;
4744 b.x_u[0] = 1e19;
4745 b.x_l[1] = -1e19;
4746 b.x_u[1] = 1e19;
4747 b.x_l[2] = 0.0;
4748 b.x_u[2] = 1.0;
4749 b.g_l[0] = 3.0;
4750 b.g_u[0] = 3.0;
4751 b.g_l[1] = 1.0;
4752 b.g_u[1] = 1.0;
4753 b.g_l[2] = 20.0;
4754 b.g_u[2] = 20.0;
4755 true
4756 }
4757 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4758 if sp.init_x {
4759 sp.x[0] = 0.0;
4760 sp.x[1] = 0.0;
4761 sp.x[2] = 0.0;
4762 }
4763 true
4764 }
4765 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
4766 Some(0.0)
4767 }
4768 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4769 for v in g.iter_mut() {
4770 *v = 0.0;
4771 }
4772 true
4773 }
4774 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4775 g[0] = x[0] + x[1];
4776 g[1] = x[0] - x[1];
4777 g[2] = x[0] + x[2];
4778 true
4779 }
4780 fn eval_jac_g(
4781 &mut self,
4782 _x: Option<&[Number]>,
4783 _new_x: bool,
4784 mode: SparsityRequest<'_>,
4785 ) -> bool {
4786 match mode {
4787 SparsityRequest::Structure { irow, jcol } => {
4788 irow.copy_from_slice(&[0, 0, 1, 1, 2, 2]);
4789 jcol.copy_from_slice(&[0, 1, 0, 1, 0, 2]);
4790 }
4791 SparsityRequest::Values { values } => {
4792 values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0, 1.0, 1.0]);
4793 }
4794 }
4795 true
4796 }
4797 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
4798 types[0] = Linearity::Linear;
4799 types[1] = Linearity::Linear;
4800 // Tagged NonLinear so it is FBBT-handled, not a Phase-1 linear row.
4801 types[2] = Linearity::NonLinear;
4802 true
4803 }
4804 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
4805 }
4806
4807 /// Supplies the FBBT tape `x0 + x2` for row 2 only (rows 0/1 are linear
4808 /// and need no tape).
4809 struct AuxBreakProvider;
4810 impl ExpressionProvider for AuxBreakProvider {
4811 fn constraint_expression(
4812 &self,
4813 i: usize,
4814 ) -> Option<pounce_nlp::expression_provider::FbbtTape> {
4815 use pounce_nlp::expression_provider::{FbbtOp, FbbtTape};
4816 if i == 2 {
4817 Some(FbbtTape {
4818 ops: vec![FbbtOp::Var(0), FbbtOp::Var(2), FbbtOp::Add(0, 1)],
4819 })
4820 } else {
4821 None
4822 }
4823 }
4824 }
4825
4826 /// Regression for F6 (follow-up to H12). An FBBT infeasibility witnessed
4827 /// while a Phase-0 auxiliary elimination is in force must roll back that
4828 /// elimination and re-run FBBT on the un-clamped box — mirroring the
4829 /// Phase-1 aux rollback (#53). Restoring only the pre-FBBT box leaves the
4830 /// aux clamps in place, handing the IPM a reduced problem whose
4831 /// infeasibility is a presolve artifact (here `x0` pinned to 2 makes the
4832 /// kept row `x0 + x2 = 20` infeasible). After the rollback the dropped
4833 /// rows are restored, FBBT finds no infeasibility on the free box, and the
4834 /// solver sees the full original problem.
4835 #[test]
4836 fn fbbt_infeasibility_with_aux_clamp_rolls_back_phase0() {
4837 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(AuxClampBreaksKeptNonlinearRow));
4838 let provider: Rc<RefCell<dyn ExpressionProvider>> = Rc::new(RefCell::new(AuxBreakProvider));
4839 let opts = PresolveOptions {
4840 enabled: true,
4841 auxiliary: true,
4842 auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
4843 bound_tightening: true,
4844 fbbt: true,
4845 ..PresolveOptions::defaults()
4846 };
4847 let mut wrapped =
4848 PresolveTnlp::with_expression_provider(Rc::clone(&inner), Rc::clone(&provider), opts);
4849 let info = wrapped.get_nlp_info().expect("init ok");
4850
4851 // Confirm the scenario actually fired: Phase 0 eliminated the 2×2
4852 // linear block (clamping x0, x1). Without this the test would pass
4853 // vacuously.
4854 assert_eq!(
4855 wrapped.auxiliary_diagnostics().vars_eliminated,
4856 2,
4857 "Phase 0 must have clamped the linear block — otherwise the \
4858 rollback path is never exercised"
4859 );
4860
4861 // The headline fix: the elimination is rolled back, so every dropped
4862 // row is restored. Without the rollback Phase 0's two dropped rows
4863 // would leave `m == 1`.
4864 assert_eq!(
4865 info.m, 3,
4866 "FBBT infeasibility under an aux clamp must roll Phase 0 back, \
4867 restoring the dropped rows (got m={})",
4868 info.m
4869 );
4870
4871 // FBBT, re-run on the un-clamped box, finds no infeasibility: the
4872 // witness was an artifact of the aux clamp.
4873 let rpt = wrapped.fbbt_report().expect("fbbt ran");
4874 assert!(
4875 rpt.infeasibility_witness.is_none(),
4876 "FBBT must not witness infeasibility on the un-clamped box, got {:?}",
4877 rpt.infeasibility_witness
4878 );
4879
4880 // The soundness property for the certification channel, on the one
4881 // scenario that can break it: this model *is feasible*, and the only
4882 // infeasibility ever seen was manufactured by Phase 0's own clamp.
4883 // Certifying it would make POUNCE report "proved infeasible" for a
4884 // problem that has solutions — strictly worse than the numerical
4885 // verdict this replaces. The proof must be withheld.
4886 assert_eq!(
4887 wrapped.certified_infeasible(),
4888 None,
4889 "a presolve-manufactured infeasibility must never be certified — \
4890 this model is feasible"
4891 );
4892
4893 // x0 is no longer pinned to the aux value 2; FBBT over `x0 + x2 = 20`
4894 // with `x2 ∈ [0, 1]` tightens it to `[19, 20]`. Bounds stay valid.
4895 let mut x_l = vec![0.0; info.n as usize];
4896 let mut x_u = vec![0.0; info.n as usize];
4897 let mut g_l = vec![0.0; info.m as usize];
4898 let mut g_u = vec![0.0; info.m as usize];
4899 assert!(wrapped.get_bounds_info(BoundsInfo {
4900 x_l: &mut x_l,
4901 x_u: &mut x_u,
4902 g_l: &mut g_l,
4903 g_u: &mut g_u,
4904 }));
4905 for i in 0..(info.n as usize) {
4906 assert!(
4907 x_l[i] <= x_u[i] + 1e-12,
4908 "bounds handed to IPM must be valid: x_l[{i}]={} > x_u[{i}]={}",
4909 x_l[i],
4910 x_u[i]
4911 );
4912 }
4913 assert!(
4914 x_l[0] > 2.0 + 1e-6,
4915 "x0 must no longer be clamped to the aux value 2 (got x_l[0]={})",
4916 x_l[0]
4917 );
4918 }
4919
4920 /// 2-variable, 2-row TNLP whose ORIGINAL problem is FEASIBLE (at
4921 /// `(x, y) = (2, 1)`) but where Phase-0's nonlinear block solve picks the
4922 /// WRONG root of a multi-root block. Row 0 is the 1×1 nonlinear block
4923 /// `x² = 4` (roots ±2); the starting point `x = -1` drives the damped
4924 /// Newton to the root `x = -2`. Row 1 is the kept row `x + y = 3` with
4925 /// `y ∈ [0, 2]` — tagged `NonLinear` so it is FBBT-handled (a `Linear`
4926 /// tag would let Phase 1 catch the clamp first and exercise the Phase-1
4927 /// rollback instead of the F6 FBBT rollback under test). Under the clamp
4928 /// `x = -2` the kept row needs `y = 5 > 2`: infeasible. At the OTHER root
4929 /// `x = +2` it needs `y = 1 ∈ [0, 2]`: feasible. So FBBT's witness is
4930 /// purely a Phase-0 artifact of the wrong root, on a feasible original.
4931 ///
4932 /// Phase-0 mechanics that make the scenario fire: DM matches row 0 to
4933 /// `x` (its only column) and row 1 to `y`; BTF solves the block
4934 /// `{row 0, x}` first (Newton from the probe `x = -1` → `-2`, inside
4935 /// `x ∈ [-10, 10]`, residual 0, accepted and clamped) and then rejects
4936 /// the block `{row 1, y}` as `OutOfBounds` (`y = 3 − (−2) = 5 ∉ [0, 2]`),
4937 /// which keeps row 1 visible to FBBT.
4938 struct WrongRootClampBreaksFeasibleOriginal;
4939 impl TNLP for WrongRootClampBreaksFeasibleOriginal {
4940 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4941 Some(NlpInfo {
4942 n: 2,
4943 m: 2,
4944 nnz_jac_g: 3,
4945 nnz_h_lag: 0,
4946 index_style: IndexStyle::C,
4947 })
4948 }
4949 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4950 b.x_l[0] = -10.0;
4951 b.x_u[0] = 10.0;
4952 b.x_l[1] = 0.0;
4953 b.x_u[1] = 2.0;
4954 b.g_l[0] = 4.0;
4955 b.g_u[0] = 4.0;
4956 b.g_l[1] = 3.0;
4957 b.g_u[1] = 3.0;
4958 true
4959 }
4960 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4961 if sp.init_x {
4962 // The probe seeds the block Newton: from -1 it converges to
4963 // the wrong root -2 (a start of +1 would find +2 and the
4964 // scenario would not fire).
4965 sp.x[0] = -1.0;
4966 sp.x[1] = 0.0;
4967 }
4968 true
4969 }
4970 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
4971 Some(0.0)
4972 }
4973 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4974 for v in g.iter_mut() {
4975 *v = 0.0;
4976 }
4977 true
4978 }
4979 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4980 g[0] = x[0] * x[0];
4981 g[1] = x[0] + x[1];
4982 true
4983 }
4984 fn eval_jac_g(
4985 &mut self,
4986 x: Option<&[Number]>,
4987 _new_x: bool,
4988 mode: SparsityRequest<'_>,
4989 ) -> bool {
4990 match mode {
4991 SparsityRequest::Structure { irow, jcol } => {
4992 irow.copy_from_slice(&[0, 1, 1]);
4993 jcol.copy_from_slice(&[0, 0, 1]);
4994 }
4995 SparsityRequest::Values { values } => {
4996 // ∂(x²)/∂x = 2x — genuinely point-dependent so the block
4997 // Newton sees the true (sign-carrying) derivative at each
4998 // iterate. Values requests always carry `Some(x)` here
4999 // (probe fetch and Phase-0 callback both pass it).
5000 let x0 = x.map(|x| x[0]).unwrap_or(-1.0);
5001 values.copy_from_slice(&[2.0 * x0, 1.0, 1.0]);
5002 }
5003 }
5004 true
5005 }
5006 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
5007 types[0] = Linearity::NonLinear;
5008 // Tagged NonLinear so it is FBBT-handled, not a Phase-1 linear row.
5009 types[1] = Linearity::NonLinear;
5010 true
5011 }
5012 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
5013 }
5014
5015 /// Supplies the FBBT tape `x + y` for the kept row 1 only (row 0 is the
5016 /// eliminated block; it needs no tape for this scenario).
5017 struct WrongRootKeptRowProvider;
5018 impl ExpressionProvider for WrongRootKeptRowProvider {
5019 fn constraint_expression(
5020 &self,
5021 i: usize,
5022 ) -> Option<pounce_nlp::expression_provider::FbbtTape> {
5023 use pounce_nlp::expression_provider::{FbbtOp, FbbtTape};
5024 if i == 1 {
5025 Some(FbbtTape {
5026 ops: vec![FbbtOp::Var(0), FbbtOp::Var(1), FbbtOp::Add(0, 1)],
5027 })
5028 } else {
5029 None
5030 }
5031 }
5032 }
5033
5034 /// F6's headline failure mode, end to end: a FEASIBLE original problem
5035 /// must not be declared infeasible because Phase 0 clamped a multi-root
5036 /// nonlinear block at the wrong root. The companion test above
5037 /// (`fbbt_infeasibility_with_aux_clamp_rolls_back_phase0`) exercises the
5038 /// rollback *mechanism* but its original model is itself infeasible; here
5039 /// the original is feasible at `(2, 1)`, Newton lands on `x = -2`
5040 /// (breaking the kept row `x + y = 3`, `y ∈ [0, 2]`), FBBT witnesses the
5041 /// artifact infeasibility, and the rollback must rescue the solve: full
5042 /// row set restored, clamp gone, FBBT witness-free on the re-run — so the
5043 /// IPM receives the feasible full problem instead of a wrong "infeasible"
5044 /// verdict.
5045 #[test]
5046 fn fbbt_rollback_rescues_feasible_original_from_wrong_root_clamp() {
5047 let inner: Rc<RefCell<dyn TNLP>> =
5048 Rc::new(RefCell::new(WrongRootClampBreaksFeasibleOriginal));
5049 let provider: Rc<RefCell<dyn ExpressionProvider>> =
5050 Rc::new(RefCell::new(WrongRootKeptRowProvider));
5051 let opts = PresolveOptions {
5052 enabled: true,
5053 auxiliary: true,
5054 auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
5055 bound_tightening: true,
5056 fbbt: true,
5057 ..PresolveOptions::defaults()
5058 };
5059 let mut wrapped =
5060 PresolveTnlp::with_expression_provider(Rc::clone(&inner), Rc::clone(&provider), opts);
5061 let info = wrapped.get_nlp_info().expect("init ok");
5062
5063 // Pre-rollback, the scenario must actually have fired: Phase 0
5064 // eliminated the 1×1 block, clamping x at a Newton root. Without
5065 // this the test passes vacuously (diagnostics survive the rollback).
5066 assert_eq!(
5067 wrapped.auxiliary_diagnostics().vars_eliminated,
5068 1,
5069 "Phase 0 must have solved and clamped the x² = 4 block — \
5070 otherwise the wrong-root path is never exercised"
5071 );
5072
5073 // The rollback restored the full model. `m == 2` proves both halves:
5074 // (a) FBBT witnessed the artifact infeasibility (no witness → no
5075 // rollback → row 0 stays dropped → m == 1), and (b) that witness can
5076 // only arise from the wrong root (-2): at +2 the kept row is
5077 // satisfiable with y = 1 ∈ [0, 2] and FBBT finds nothing.
5078 assert_eq!(
5079 info.m, 2,
5080 "wrong-root clamp on a feasible original must trigger the F6 \
5081 rollback and restore the dropped block row (got m={})",
5082 info.m
5083 );
5084
5085 // The re-run on the un-clamped box is witness-free: the original is
5086 // feasible, so nothing survives the rollback.
5087 let rpt = wrapped.fbbt_report().expect("fbbt ran");
5088 assert!(
5089 rpt.infeasibility_witness.is_none(),
5090 "feasible original: FBBT must not witness infeasibility after \
5091 the rollback, got {:?}",
5092 rpt.infeasibility_witness
5093 );
5094
5095 // The box handed to the IPM is valid, the wrong-root clamp is gone,
5096 // and the feasible point (x, y) = (2, 1) is still inside it.
5097 let mut x_l = vec![0.0; info.n as usize];
5098 let mut x_u = vec![0.0; info.n as usize];
5099 let mut g_l = vec![0.0; info.m as usize];
5100 let mut g_u = vec![0.0; info.m as usize];
5101 assert!(wrapped.get_bounds_info(BoundsInfo {
5102 x_l: &mut x_l,
5103 x_u: &mut x_u,
5104 g_l: &mut g_l,
5105 g_u: &mut g_u,
5106 }));
5107 for i in 0..(info.n as usize) {
5108 assert!(
5109 x_l[i] <= x_u[i] + 1e-12,
5110 "bounds handed to IPM must be valid: x_l[{i}]={} > x_u[{i}]={}",
5111 x_l[i],
5112 x_u[i]
5113 );
5114 }
5115 assert!(
5116 x_u[0] - x_l[0] > 1e-6,
5117 "x must no longer be clamped (got [{}, {}])",
5118 x_l[0],
5119 x_u[0]
5120 );
5121 assert!(
5122 x_l[0] <= 2.0 + 1e-9 && 2.0 <= x_u[0] + 1e-9,
5123 "the feasible root x = 2 must survive in the IPM's box, got \
5124 [{}, {}]",
5125 x_l[0],
5126 x_u[0]
5127 );
5128 assert!(
5129 x_l[1] <= 1.0 + 1e-9 && 1.0 <= x_u[1] + 1e-9,
5130 "the feasible y = 1 must survive in the IPM's box, got [{}, {}]",
5131 x_l[1],
5132 x_u[1]
5133 );
5134 }
5135
5136 /// L46: under a row drop, a genuinely per-constraint metadata vector
5137 /// (length `m_in`) must be subset to the kept rows, and the inverse
5138 /// `expand` must restore it (dropped rows defaulted). This is the
5139 /// canonical, contract-conforming case — the only one any real inner
5140 /// TNLP produces (e.g. `nl_reader`'s per-row `con_names`).
5141 #[test]
5142 fn con_metadata_per_row_vector_round_trips_under_row_drop() {
5143 let m_in = 3;
5144 let rows_kept = vec![0usize, 2]; // row 1 dropped by presolve
5145 let mut inner = MetaData::default();
5146 inner.strings.insert(
5147 "names".to_string(),
5148 vec!["c0".to_string(), "c1".to_string(), "c2".to_string()],
5149 );
5150 inner.integers.insert("flags".to_string(), vec![10, 11, 12]);
5151 inner
5152 .numerics
5153 .insert("weights".to_string(), vec![1.0, 2.0, 3.0]);
5154
5155 let reduced = project_con_metadata(&inner, &rows_kept, m_in);
5156 assert_eq!(
5157 reduced.strings["names"],
5158 vec!["c0".to_string(), "c2".to_string()]
5159 );
5160 assert_eq!(reduced.integers["flags"], vec![10, 12]);
5161 assert_eq!(reduced.numerics["weights"], vec![1.0, 3.0]);
5162
5163 // finalize_metadata's inverse: reduced (m_out=2) back to m_in=3.
5164 let restored = expand_con_metadata(&reduced, &rows_kept, m_in);
5165 assert_eq!(
5166 restored.strings["names"],
5167 vec!["c0".to_string(), String::new(), "c2".to_string()]
5168 );
5169 assert_eq!(restored.integers["flags"], vec![10, 0, 12]);
5170 assert_eq!(restored.numerics["weights"], vec![1.0, 0.0, 3.0]);
5171 }
5172
5173 /// L46 hazard, characterized: a *global* metadata vector that happens
5174 /// to have length `m_in` is indistinguishable from a per-row vector
5175 /// (the untyped `MetaData` API carries no arity tag), so projection
5176 /// silently subsets it. This pins the known-imperfect behavior so a
5177 /// future arity-aware fix has a regression anchor; the value pounce
5178 /// ships today is unaffected because no real inner TNLP emits a
5179 /// coincidentally-`m_in`-length global constraint vector.
5180 #[test]
5181 fn con_metadata_length_heuristic_misfires_on_coincidental_global() {
5182 let m_in = 3;
5183 let rows_kept = vec![0usize, 2]; // a row is dropped, so projection is non-identity
5184 let mut inner = MetaData::default();
5185 // Semantically global (e.g. a 3-entry config tuple) that just
5186 // happens to be length 3 == m_in.
5187 inner
5188 .integers
5189 .insert("global_triple".to_string(), vec![100, 200, 300]);
5190
5191 let reduced = project_con_metadata(&inner, &rows_kept, m_in);
5192 // Wrongly treated as per-row and subset to [100, 300]; the
5193 // faithful value would be the untouched [100, 200, 300].
5194 assert_eq!(
5195 reduced.integers["global_triple"],
5196 vec![100, 300],
5197 "documents the L46 length-heuristic misfire on a coincidental global vector"
5198 );
5199
5200 // A global vector whose length differs from m_in is correctly
5201 // passed through untouched (the heuristic only misfires on the
5202 // exact-length coincidence).
5203 let mut inner2 = MetaData::default();
5204 inner2
5205 .numerics
5206 .insert("two_globals".to_string(), vec![1.5, 2.5]);
5207 let reduced2 = project_con_metadata(&inner2, &rows_kept, m_in);
5208 assert_eq!(reduced2.numerics["two_globals"], vec![1.5, 2.5]);
5209 }
5210
5211 /// gh#391 scratch. One variable in `[0, 1]`, `m` rows evaluated as
5212 /// `coeff[i] * x + offset[i]` against per-row declared bounds — enough to
5213 /// drive `witness_refutes_infeasibility` directly and compare the two
5214 /// [`WitnessRule`] forms on the same numbers.
5215 struct WitnessProbeRows {
5216 coeff: Vec<Number>,
5217 offset: Vec<Number>,
5218 g_l: Vec<Number>,
5219 g_u: Vec<Number>,
5220 x0: Number,
5221 }
5222
5223 impl TNLP for WitnessProbeRows {
5224 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
5225 Some(NlpInfo {
5226 n: 1,
5227 m: self.coeff.len() as Index,
5228 nnz_jac_g: self.coeff.len() as Index,
5229 nnz_h_lag: 0,
5230 index_style: IndexStyle::C,
5231 })
5232 }
5233 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
5234 b.x_l[0] = 0.0;
5235 b.x_u[0] = 1.0;
5236 b.g_l.copy_from_slice(&self.g_l);
5237 b.g_u.copy_from_slice(&self.g_u);
5238 true
5239 }
5240 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
5241 if sp.init_x {
5242 sp.x[0] = self.x0;
5243 }
5244 true
5245 }
5246 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
5247 Some(0.0)
5248 }
5249 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
5250 g[0] = 0.0;
5251 true
5252 }
5253 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
5254 for ((out, &c), &o) in g.iter_mut().zip(&self.coeff).zip(&self.offset) {
5255 *out = c * x[0] + o;
5256 }
5257 true
5258 }
5259 fn eval_jac_g(
5260 &mut self,
5261 _x: Option<&[Number]>,
5262 _new_x: bool,
5263 mode: SparsityRequest<'_>,
5264 ) -> bool {
5265 match mode {
5266 SparsityRequest::Structure { irow, jcol } => {
5267 for (i, r) in irow.iter_mut().enumerate() {
5268 *r = i as Index;
5269 }
5270 jcol.fill(0);
5271 }
5272 SparsityRequest::Values { values } => values.copy_from_slice(&self.coeff),
5273 }
5274 true
5275 }
5276 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
5277 types.fill(Linearity::Linear);
5278 true
5279 }
5280 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
5281 }
5282
5283 fn refutes(model: WitnessProbeRows, rule: WitnessRule) -> bool {
5284 let (g_l, g_u) = (model.g_l.clone(), model.g_u.clone());
5285 let m = g_l.len();
5286 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(model));
5287 witness_refutes_infeasibility(&inner, 1, m, &[0.0], &[1.0], &g_l, &g_u, 1e-8, rule)
5288 }
5289
5290 /// The gh#391 mechanism in one assertion pair, on identical numbers.
5291 ///
5292 /// `s*x == 0.2*s` with `s*x == 0.8*s` over `x ∈ [0, 1]` is empty at every
5293 /// `s > 0` — the crossing is `0.6` regardless. At `s = 1e-12` the clamped
5294 /// accepting form reinstates the absolute floor (`tol * max(scale, 1)` =
5295 /// `1e-8`), every point of the box "satisfies" both rows, and the witness
5296 /// withdraws a scale-free proof. Measured against the row's declared
5297 /// magnitude with no clamp, the same point violates by `0.6` relative and
5298 /// refutes nothing.
5299 #[test]
5300 fn down_scaled_rows_only_defeat_the_clamped_witness_form() {
5301 let model = |_s: Number| WitnessProbeRows {
5302 coeff: vec![1e-12, 1e-12],
5303 offset: vec![0.0, 0.0],
5304 g_l: vec![0.2e-12, 0.8e-12],
5305 g_u: vec![0.2e-12, 0.8e-12],
5306 x0: 0.5,
5307 };
5308 assert!(
5309 refutes(model(1e-12), WitnessRule::SolverAcceptance),
5310 "the clamped form accepts every point of the box at this row scale \
5311 — this is the behavior gh#391 is about, pinned so the contrast \
5312 below stays meaningful"
5313 );
5314 assert!(
5315 !refutes(model(1e-12), WitnessRule::DeclaredRowRelative),
5316 "0.6 of the row's own declared magnitude is a violation at every \
5317 scale; the strict rule must not withdraw the proof"
5318 );
5319 }
5320
5321 /// The same rows at unit scale: both forms agree, which is the point —
5322 /// the strict rule changes nothing where the clamp was never active.
5323 #[test]
5324 fn unit_scale_rows_refute_under_neither_form() {
5325 let model = || WitnessProbeRows {
5326 coeff: vec![1.0, 1.0],
5327 offset: vec![0.0, 0.0],
5328 g_l: vec![0.2, 0.8],
5329 g_u: vec![0.2, 0.8],
5330 x0: 0.5,
5331 };
5332 assert!(!refutes(model(), WitnessRule::SolverAcceptance));
5333 assert!(!refutes(model(), WitnessRule::DeclaredRowRelative));
5334 }
5335
5336 /// The hazard the strict rule has to handle explicitly: a *homogeneous* row
5337 /// (`g_l = g_u = 0`) has no declared magnitude, so a pure relative test
5338 /// would be unsatisfiable by construction — the violation *is* the scale,
5339 /// and any float-noise residual reads as a full-magnitude violation. Such a
5340 /// row keeps the absolute floor, so a genuinely feasible point still
5341 /// refutes and the proof is still withheld. Fail closed.
5342 #[test]
5343 fn homogeneous_row_keeps_the_absolute_floor_under_the_strict_rule() {
5344 // `x - 0.5 == 0` evaluated at the modeller's `x0 = 0.5`, off by an ulp
5345 // of noise. Feasible; the witness must say so under both rules.
5346 let noisy = || WitnessProbeRows {
5347 coeff: vec![1.0],
5348 offset: vec![-0.5 + 1e-16],
5349 g_l: vec![0.0],
5350 g_u: vec![0.0],
5351 x0: 0.5,
5352 };
5353 assert!(refutes(noisy(), WitnessRule::SolverAcceptance));
5354 assert!(
5355 refutes(noisy(), WitnessRule::DeclaredRowRelative),
5356 "a homogeneous row has nothing to be relative to; float noise on it \
5357 must not be promoted to a violation"
5358 );
5359
5360 // A real violation on a homogeneous row still refutes nothing: the
5361 // fallback is the absolute floor, not blanket acceptance.
5362 let violated = || WitnessProbeRows {
5363 coeff: vec![1.0],
5364 offset: vec![0.6],
5365 g_l: vec![0.0],
5366 g_u: vec![0.0],
5367 x0: 0.5,
5368 };
5369 assert!(!refutes(violated(), WitnessRule::SolverAcceptance));
5370 assert!(!refutes(violated(), WitnessRule::DeclaredRowRelative));
5371 }
5372
5373 /// The strict rule is relative, not absolute: on a row declared near `1e30`
5374 /// a residual of `1e5` is fifteen relative digits of agreement and still
5375 /// refutes. This is the extreme-coefficient class the witness gate exists
5376 /// for, and it must survive the rule change.
5377 #[test]
5378 fn strict_rule_still_refutes_at_extreme_row_magnitude() {
5379 let model = || WitnessProbeRows {
5380 coeff: vec![2e30],
5381 offset: vec![1e5],
5382 g_l: vec![1e30],
5383 g_u: vec![1e30],
5384 x0: 0.5,
5385 };
5386 assert!(refutes(model(), WitnessRule::SolverAcceptance));
5387 assert!(refutes(model(), WitnessRule::DeclaredRowRelative));
5388 }
5389
5390 /// End-to-end through the wrapper: the rule is a property of the call site,
5391 /// so a default wrapper (one that would be solved through) keeps the #380
5392 /// behavior at sub-tolerance row scale, and only a caller that has
5393 /// established the solve cannot run gets the proof.
5394 #[test]
5395 fn probing_without_a_solve_certifies_where_the_default_wrapper_withholds() {
5396 let opts = PresolveOptions {
5397 enabled: true,
5398 bound_tightening: true,
5399 auxiliary: false,
5400 ..PresolveOptions::defaults()
5401 };
5402 let model = || WitnessProbeRows {
5403 coeff: vec![1e-12, 1e-12],
5404 offset: vec![0.0, 0.0],
5405 g_l: vec![0.2e-12, 0.8e-12],
5406 g_u: vec![0.2e-12, 0.8e-12],
5407 x0: 0.5,
5408 };
5409
5410 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(model()));
5411 let mut solved_through = PresolveTnlp::new(inner, opts);
5412 solved_through.get_nlp_info().expect("init ok");
5413 assert!(
5414 solved_through.tighten_report().infeasible,
5415 "bound propagation sees the contradiction at every scale"
5416 );
5417 assert_eq!(
5418 solved_through.certified_infeasible(),
5419 None,
5420 "#380: a wrapper that will be solved through must not claim a proof \
5421 the solver's own acceptance test would contradict"
5422 );
5423
5424 let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(model()));
5425 let mut probe = PresolveTnlp::new(inner, opts).probing_without_a_solve();
5426 probe.get_nlp_info().expect("init ok");
5427 assert_eq!(
5428 probe.certified_infeasible(),
5429 Some(InfeasibilityProof::BoundPropagation),
5430 "gh#391: with no solve to contradict, the scale-free crossing is a \
5431 proof at every row scale"
5432 );
5433 }
5434}