Skip to main content

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(&reg).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}